├── .cargo └── config.toml ├── .gitattributes ├── .github └── workflows │ ├── build.yml │ ├── test.yml │ ├── update-deps.yml │ └── update_deps.mjs ├── .gitignore ├── .ottotime ├── .prettierrc ├── .rustfmt.toml ├── .vscode └── settings.json ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── README.md ├── assets ├── README.md ├── example.gif └── example.tape ├── build.rs ├── cliff.toml ├── package.json ├── pkg ├── bin.js ├── install.js ├── package.json ├── pnpm-lock.yaml └── run.js ├── pnpm-lock.yaml ├── src ├── args.rs ├── cookie.rs ├── create │ ├── git.rs │ ├── install_deps.rs │ ├── mod.rs │ ├── next_steps.rs │ ├── package_json.rs │ └── scaffold.rs ├── input │ ├── git.rs │ ├── install_deps.rs │ ├── mod.rs │ ├── project_features.rs │ └── project_location.rs ├── main.rs ├── telemetry.rs ├── test.rs └── utils.rs ├── telemetry-server ├── .gitignore ├── db.sql ├── package.json ├── pnpm-lock.yaml ├── src │ └── worker.ts ├── tsconfig.json └── wrangler.jsonc └── template_builder ├── Cargo.lock ├── Cargo.toml ├── src ├── lib.rs └── utils.rs └── templates ├── base ├── .gitignore ├── .prettierrc ├── eslint.config.js ├── package.json ├── postcss.config.mjs ├── src │ ├── app.css │ ├── app.d.ts │ ├── app.html │ ├── lib │ │ └── components │ │ │ └── NextStep.svelte │ └── routes │ │ ├── +layout.svelte │ │ └── +page.svelte ├── static │ └── favicon.png ├── svelte.config.js ├── tailwind.config.cjs ├── tsconfig.json └── vite.config.ts ├── config.json └── extras ├── .github └── workflows │ ├── {Bun,Sidecar}deploy-worker.yml │ ├── {Npm,Sidecar}deploy-worker.yml │ ├── {Pnpm,Sidecar}deploy-worker.yml │ └── {Yarn,Sidecar}deploy-worker.yml ├── common ├── src │ └── {Sidecar}common.ts └── {Sidecar}package.json ├── prisma ├── {D1|Turso,Auth}schema.prisma ├── {D1|Turso}schema.prisma ├── {D1}push.mjs ├── {Planetscale,Auth}schema.prisma ├── {Planetscale}schema.prisma ├── {Sqlite,Auth}schema.prisma ├── {Sqlite}schema.prisma └── {Turso}push.mjs ├── src ├── lib │ ├── auth │ │ └── {Auth}index.ts │ ├── db │ │ ├── {D1|Planetscale|Sqlite|Turso,Auth}schema.d.ts │ │ ├── {D1|Planetscale|Sqlite|Turso}schema.d.ts │ │ ├── {D1}index.ts │ │ ├── {Planetscale}index.ts │ │ ├── {Sqlite}index.ts │ │ └── {Turso}index.ts │ ├── server │ │ ├── routes │ │ │ ├── {Trpc,Auth}_app.ts │ │ │ └── {Trpc}_app.ts │ │ ├── {Trpc,Auth}context.ts │ │ ├── {Trpc,Auth}trpc.ts │ │ ├── {Trpc}context.ts │ │ ├── {Trpc}server.ts │ │ └── {Trpc}trpc.ts │ └── {Trpc}trpc.ts ├── routes │ ├── api │ │ ├── auth │ │ │ ├── callback │ │ │ │ └── {Auth}+server.ts │ │ │ ├── login │ │ │ │ └── {Auth}+server.ts │ │ │ └── logout │ │ │ │ └── {Auth}+server.ts │ │ └── trpc │ │ │ └── [...trpc] │ │ │ └── {Trpc}+server.ts │ ├── {D1|Planetscale|Sqlite}+page.svelte │ ├── {Tailwind4,D1|Planetscale|Sqlite}+page.svelte │ ├── {Tailwind4,Trpc,D1|Planetscale|Sqlite|Turso}+page.svelte │ ├── {Tailwind4,Trpc}+page.svelte │ ├── {Tailwind4}+page.svelte │ ├── {Trpc,D1|Planetscale|Sqlite|Turso}+page.svelte │ ├── {Trpc}+layout.server.ts │ ├── {Trpc}+layout.svelte │ ├── {Trpc}+page.server.ts │ └── {Trpc}+page.svelte ├── {Auth,D1}hooks.server.ts ├── {Auth,Sqlite|Planetscale|Turso}hooks.server.ts ├── {Auth}app.d.ts ├── {D1}hooks.server.ts ├── {Edge,Auth,D1,Sidecar}app.d.ts ├── {Edge,Auth,D1}app.d.ts ├── {Edge,Auth,Sidecar}app.d.ts ├── {Edge,Auth}app.d.ts ├── {Edge,D1,Sidecar}app.d.ts ├── {Edge,D1}app.d.ts ├── {Edge,Sidecar}app.d.ts ├── {Edge}app.d.ts └── {Tailwind4}app.css ├── worker ├── src │ └── {Sidecar}worker.ts ├── {Sidecar,Npm}package.json ├── {Sidecar}.gitignore ├── {Sidecar}package.json ├── {Sidecar}tsconfig.json └── {Sidecar}wrangler.jsonc ├── {Auth}package.json ├── {D1,Auth}.env ├── {D1|Planetscale|Sqlite|Turso}package.json ├── {D1|Planetscale|Sqlite}.prettierignore ├── {D1}.env ├── {D1}package.json ├── {Edge,D1,Sidecar}wrangler.jsonc ├── {Edge,D1}wrangler.jsonc ├── {Edge,Sidecar}wrangler.jsonc ├── {Edge}package.json ├── {Edge}svelte.config.js ├── {Edge}wrangler.jsonc ├── {Planetscale,Auth}.env ├── {Planetscale}.env ├── {Planetscale}package.json ├── {Sidecar,Edge}package.json ├── {Sidecar,Npm|Yarn|Bun}package.json ├── {Sidecar,Npm}package.json ├── {Sidecar,Pnpm}pnpm-workspace.yaml ├── {Sidecar}.npmrc ├── {Sidecar}.prettierignore ├── {Sidecar}package.json ├── {Sqlite,Auth}.env ├── {Sqlite}.env ├── {Sqlite}package.json ├── {Tailwind4}DELETE:postcss.config.mjs ├── {Tailwind4}DELETE:tailwind.config.cjs ├── {Tailwind4}package.json ├── {Tailwind4}vite.config.ts ├── {Trpc}package.json ├── {Turso,Auth}.env ├── {Turso}.env └── {Turso}package.json /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [target.x86_64-apple-darwin] 2 | linker = "x86_64-apple-darwin20.4-clang" 3 | ar = "x86_64-apple-darwin20.4-ar" 4 | 5 | [target.aarch64-apple-darwin] 6 | linker = "aarch64-apple-darwin20.4-clang" 7 | ar = "aarch64-apple-darwin20.4-ar" 8 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Prevents merge conflicts in Ottotime files 2 | .ottotime merge=union 3 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | contents: write 12 | id-token: write 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 0 18 | - name: Install Node.js 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: 20 22 | - name: Get version 23 | id: get_version 24 | run: echo "version=$(grep "version" Cargo.toml | head -n1 | cut -d '"' -f2)" >> $GITHUB_OUTPUT 25 | - name: Compare version to NPM 26 | run: | 27 | NPM_VERSION=$(npm view create-o7-app version) 28 | MY_VERSION=${{ steps.get_version.outputs.version }} 29 | if [ "$NPM_VERSION" == "$MY_VERSION" ]; then 30 | echo "NPM version is the same as the version in Cargo.toml, exiting" 31 | exit 1 32 | fi 33 | - name: Install pnpm 34 | run: npm i -g pnpm 35 | 36 | - name: Generate changelogs 37 | run: | 38 | # Full changelog 39 | pnpx git-cliff --tag ${{ steps.get_version.outputs.version }} -o CHANGELOG.md 40 | # Changelog for the release 41 | pnpx git-cliff --tag ${{ steps.get_version.outputs.version }} --unreleased --strip header -o CHANGELOG-release.md 42 | 43 | - name: Install Rust 44 | uses: dtolnay/rust-toolchain@stable 45 | with: 46 | components: rustfmt 47 | targets: x86_64-unknown-linux-musl,x86_64-pc-windows-gnu,i686-pc-windows-gnu,x86_64-apple-darwin,aarch64-apple-darwin 48 | # - name: Test 49 | # run: cargo test 50 | - name: Install MinGW 51 | run: | 52 | sudo apt-get update 53 | sudo apt-get install -y gcc-mingw-w64-x86-64 gcc-mingw-w64-i686 54 | - name: Install MUSL 55 | run: | 56 | sudo apt-get install -y musl-tools 57 | - name: Cache OSXCross 58 | id: cache-osxcross 59 | uses: actions/cache@v4 60 | with: 61 | path: osxcross 62 | key: ${{ runner.os }}-osxcross-11.3 63 | save-always: true 64 | 65 | - name: Install OSXCross 66 | if: steps.cache-osxcross.outputs.cache-hit != 'true' 67 | run: | 68 | git clone https://github.com/tpoechtrager/osxcross 69 | cd osxcross 70 | sudo tools/get_dependencies.sh 71 | wget -nc https://github.com/phracker/MacOSX-SDKs/releases/download/11.3/MacOSX11.3.sdk.tar.xz 72 | mv MacOSX11.3.sdk.tar.xz tarballs/ 73 | UNATTENDED=yes OSX_VERSION_MIN=10.7 ./build.sh 74 | - name: Build Linux 75 | run: cargo build --release --target x86_64-unknown-linux-musl 76 | - name: Build MacOS 77 | run: | 78 | export PATH="$(pwd)/osxcross/target/bin:$PATH" 79 | export LIBZ_SYS_STATIC=1 80 | export CC=o64-clang 81 | export CXX=o64-clang++ 82 | cargo build --release --target x86_64-apple-darwin 83 | cargo build --release --target aarch64-apple-darwin 84 | - name: Build Windows 85 | run: cargo build --release --target x86_64-pc-windows-gnu 86 | - name: Build Windows 32 87 | run: cargo build --release --target i686-pc-windows-gnu 88 | - name: Copy Artifacts 89 | run: | 90 | mkdir artifacts 91 | mv target/x86_64-unknown-linux-musl/release/create-o7-app artifacts/create-o7-app-linux 92 | mv target/x86_64-pc-windows-gnu/release/create-o7-app.exe artifacts/create-o7-app-win64.exe 93 | mv target/i686-pc-windows-gnu/release/create-o7-app.exe artifacts/create-o7-app-win32.exe 94 | mv target/x86_64-apple-darwin/release/create-o7-app artifacts/create-o7-app-macos 95 | mv target/aarch64-apple-darwin/release/create-o7-app artifacts/create-o7-app-macos-arm64 96 | mkdir -p compressed/artifacts 97 | for file in artifacts/*; do tar -czvf "compressed/$file.tar.gz" "$file"; done 98 | 99 | - name: Commit changelog 100 | run: | 101 | git add CHANGELOG.md 102 | git config --local user.email "create-o7-app@users.noreply.github.com" 103 | git config --local user.name "create-o7-app[bot]" 104 | git commit -m "ci: Update changelog for ${{ steps.get_version.outputs.version }}" 105 | git push 106 | 107 | - name: Release Binaries 108 | uses: softprops/action-gh-release@v2 109 | with: 110 | body_path: CHANGELOG-release.md 111 | tag_name: ${{ steps.get_version.outputs.version }} 112 | files: compressed/artifacts/* 113 | - name: Publish NPM Package 114 | run: | 115 | cd pkg 116 | cp ../README.md . 117 | jq '.version = "${{ steps.get_version.outputs.version }}"' package.json > tmp && mv tmp package.json 118 | npm config set //registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }} 119 | echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" >> ~/.npmrc 120 | npm publish 121 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | pull_request: 4 | 5 | jobs: 6 | create-shards: 7 | runs-on: ubuntu-latest 8 | outputs: 9 | shards: ${{ steps.shards.outputs.shards }} 10 | shard-count: ${{ steps.shards.outputs.count }} 11 | steps: 12 | - id: shards 13 | run: | 14 | shard_count=6 15 | 16 | echo "count=[$shard_count]" >> $GITHUB_OUTPUT 17 | max_shard=$((shard_count - 1)) 18 | echo "shards=[$(seq -s ', ' 0 $max_shard)]" >> $GITHUB_OUTPUT 19 | 20 | test: 21 | runs-on: ubuntu-latest 22 | needs: create-shards 23 | strategy: 24 | fail-fast: false 25 | matrix: 26 | shard: ${{ fromJson(needs.create-shards.outputs.shards) }} 27 | shard-count: ${{ fromJson(needs.create-shards.outputs.shard-count) }} 28 | steps: 29 | - name: Checkout 30 | uses: actions/checkout@v4 31 | 32 | - uses: actions/cache@v4 33 | with: 34 | save-always: true 35 | path: | 36 | ~/.cargo/bin/ 37 | ~/.cargo/registry/index/ 38 | ~/.cargo/registry/cache/ 39 | ~/.cargo/git/db/ 40 | target/ 41 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 42 | 43 | - name: Install Rust 44 | uses: dtolnay/rust-toolchain@stable 45 | with: 46 | components: rustfmt 47 | 48 | - name: Install Node.js 49 | uses: actions/setup-node@v4 50 | with: 51 | node-version: 20 52 | - name: Install pnpm 53 | run: npm i -g pnpm 54 | - name: Test 55 | run: cargo test 56 | env: 57 | SHARD: ${{ matrix.shard }} 58 | SHARD_COUNT: ${{ matrix.shard-count }} 59 | -------------------------------------------------------------------------------- /.github/workflows/update-deps.yml: -------------------------------------------------------------------------------- 1 | name: Update Dependencies 2 | on: 3 | schedule: 4 | - cron: '0 7 * * *' 5 | workflow_dispatch: 6 | 7 | jobs: 8 | update: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Detect open PR 12 | id: detect_open_pr 13 | uses: actions/github-script@v7 14 | with: 15 | result-encoding: string 16 | script: | 17 | const { data: pullRequests } = await github.rest.pulls.list({ 18 | owner: context.repo.owner, 19 | repo: context.repo.repo, 20 | state: 'open', 21 | base: 'main', 22 | head: `${context.repo.owner}:updates`, 23 | }); 24 | // save pr number to github_env 25 | if (pullRequests[0]) { 26 | require('fs').appendFileSync(process.env.GITHUB_ENV, 27 | `PR_NUMBER=${pullRequests[0].number}\n` 28 | ); 29 | } 30 | 31 | return pullRequests[0]?.head?.ref ?? 'main'; 32 | 33 | - id: create_token 34 | uses: tibdex/github-app-token@v2 35 | with: 36 | app_id: ${{ secrets.APP_ID }} 37 | private_key: ${{ secrets.APP_PRIVATE_KEY }} 38 | 39 | - name: Checkout 40 | uses: actions/checkout@v4 41 | with: 42 | token: ${{ steps.create_token.outputs.token }} 43 | ref: ${{ steps.detect_open_pr.outputs.result }} 44 | 45 | - name: Install Node.js 46 | uses: actions/setup-node@v4 47 | with: 48 | node-version: 20 49 | - name: Install Bun 50 | uses: oven-sh/setup-bun@v2 51 | - name: Install Rust 52 | uses: dtolnay/rust-toolchain@stable 53 | with: 54 | components: rustfmt 55 | 56 | - name: Do updates 57 | run: | 58 | { 59 | echo 'PR_BODY<> $GITHUB_ENV 63 | 64 | - name: Cargo update 65 | run: cargo update 66 | 67 | - name: Open PR 68 | if: steps.detect_open_pr.outputs.result == 'main' 69 | uses: peter-evans/create-pull-request@v6 70 | with: 71 | token: ${{ steps.create_token.outputs.token }} 72 | commit-message: 'ci: Update dependencies' 73 | title: Update dependencies 74 | body: ${{ env.PR_BODY }} 75 | branch: updates 76 | 77 | - name: Push to existing PR 78 | if: steps.detect_open_pr.outputs.result != 'main' 79 | run: | 80 | if git diff --quiet; then 81 | echo "No changes" 82 | exit 1 83 | fi 84 | git add . 85 | 86 | git config --local user.email "create-o7-app@users.noreply.github.com" 87 | git config --local user.name "create-o7-app[bot]" 88 | git commit -m "ci: Update dependencies" 89 | 90 | git push origin ${{ steps.detect_open_pr.outputs.result }} 91 | 92 | - name: Comment on PR 93 | if: steps.detect_open_pr.outputs.result != 'main' 94 | uses: actions/github-script@v7 95 | with: 96 | github-token: ${{ steps.create_token.outputs.token }} 97 | script: | 98 | await github.rest.issues.createComment({ 99 | issue_number: ${{ env.PR_NUMBER }}, 100 | owner: context.repo.owner, 101 | repo: context.repo.repo, 102 | body: process.env.PR_BODY, 103 | }); 104 | -------------------------------------------------------------------------------- /.github/workflows/update_deps.mjs: -------------------------------------------------------------------------------- 1 | import { resolve, basename, join } from 'node:path'; 2 | import { fileURLToPath } from 'node:url'; 3 | import { readdir, readFile, writeFile } from 'node:fs/promises'; 4 | 5 | const isNewPr = process.argv[2] === 'main'; 6 | const dryRun = process.argv.includes('--dry-run'); 7 | 8 | const IGNORE_DEPS = ['common']; 9 | 10 | export async function getUpdates() { 11 | const projectRoot = resolve(fileURLToPath(import.meta.url), '../../..'); 12 | const templateRoot = join(projectRoot, 'template_builder/templates'); 13 | 14 | /** 15 | * 16 | * @param {any} pkg 17 | * @param {'dependencies' | 'devDependencies'} key 18 | * @returns true if any dependencies were updated 19 | */ 20 | async function processDependencies(pkg, key) { 21 | if (!pkg[key]) return []; 22 | let dirty = []; 23 | for (const [name, currentVersion] of Object.entries(pkg[key])) { 24 | if (currentVersion === null) continue; 25 | if (IGNORE_DEPS.includes(name)) continue; 26 | 27 | let tag = 'latest'; 28 | if (currentVersion.includes('-next')) { 29 | tag = 'next'; 30 | } 31 | if (name === 'tailwindcss' && currentVersion[1] === '3') { 32 | tag = '3'; 33 | } 34 | let prefix = currentVersion[0]; 35 | if (prefix !== '^' && prefix !== '~') { 36 | prefix = ''; 37 | } 38 | let latest = await latestVersion(name, tag); 39 | if (!latest) continue; 40 | latest = prefix + latest; 41 | 42 | if (latest !== currentVersion) { 43 | dirty.push([name, currentVersion, latest]); 44 | pkg[key][name] = latest; 45 | } 46 | } 47 | return dirty; 48 | } 49 | 50 | if (isNewPr) { 51 | const cargoTomlPath = join(projectRoot, 'Cargo.toml'); 52 | const cargoToml = await readFile(cargoTomlPath, 'utf8'); 53 | const version = cargoToml.match(/version = "(.*)"/)?.[1]; 54 | if (!version) { 55 | console.error('Could not find version in Cargo.toml'); 56 | process.exit(1); 57 | } 58 | const [major, minor, patch] = version.split('.'); 59 | const newVersion = `${major}.${minor}.${parseInt(patch) + 1}`; 60 | if (!dryRun) { 61 | await writeFile( 62 | cargoTomlPath, 63 | cargoToml.replace(/version = "(.*)"/, `version = "${newVersion}"`), 64 | ); 65 | } 66 | console.log(`_Bumped version to ${newVersion}_\n\n`); 67 | } 68 | 69 | for await (const f of getFiles(templateRoot)) { 70 | const groups = basename(f).match(/^(\{[^{}]*\})?package\.json$/); 71 | if (!groups) continue; 72 | const pkg = JSON.parse(await readFile(f, 'utf8')); 73 | const updates = await Promise.all([ 74 | processDependencies(pkg, 'dependencies'), 75 | processDependencies(pkg, 'devDependencies'), 76 | ]).then((results) => results.flat()); 77 | 78 | if (updates.length) { 79 | if (!dryRun) { 80 | await writeFile(f, JSON.stringify(pkg, null, '\t') + '\n'); 81 | } 82 | const features = prettifyFeatures(groups[1]); 83 | console.log(`| \`${features}\` | old | new |`); 84 | console.log('|-|-|-|'); 85 | for (const [name, currentVersion, latest] of updates) { 86 | console.log(`| ${name} | \`${currentVersion}\` | \`${latest}\` |`); 87 | } 88 | console.log('\n\n'); 89 | } 90 | } 91 | const cloudflareVersion = await latestVersion( 92 | '@cloudflare/workers-types', 93 | 'latest', 94 | ); 95 | const cloudflareDate = cloudflareVersion.split('.')[1]; 96 | if (!cloudflareDate || !/^[0-9]{8}$/.test(cloudflareDate)) { 97 | console.error( 98 | `Invalid @cloudflare/workers-types version: ${cloudflareVersion}`, 99 | ); 100 | process.exit(1); 101 | } 102 | const compatibilityDate = `${cloudflareDate.substring( 103 | 0, 104 | 4, 105 | )}-${cloudflareDate.substring(4, 6)}-${cloudflareDate.substring(6, 8)}`; 106 | 107 | let changedFiles = []; 108 | for await (const f of getFiles(templateRoot)) { 109 | const groups = basename(f).match(/^(\{[^{}]*\})?wrangler\.jsonc$/); 110 | 111 | if (!groups) continue; 112 | const wrangler = await import(f, { assert: { type: 'jsonc' } }); 113 | 114 | const oldVersion = wrangler.compatibility_date; 115 | 116 | if (oldVersion !== compatibilityDate) { 117 | if (!dryRun) { 118 | const text = (await readFile(f, 'utf8')).replace( 119 | `"${oldVersion}"`, 120 | `"${compatibilityDate}"`, 121 | ); 122 | await writeFile(f, text); 123 | } 124 | changedFiles.push([prettifyFeatures(groups[1]), oldVersion]); 125 | } 126 | } 127 | if (changedFiles.length) { 128 | console.log(`| \`compatibility_date\` | old | new |`); 129 | console.log('|-|-|-|'); 130 | for (const [name, oldVersion] of changedFiles) { 131 | console.log(`| ${name} | \`${oldVersion}\` | \`${compatibilityDate}\` |`); 132 | } 133 | console.log('\n\n'); 134 | } 135 | } 136 | 137 | /** 138 | * 139 | * @param {string} packageName 140 | * @param {string} tag 141 | * @returns {Promise} 142 | */ 143 | async function latestVersion(packageName, tag) { 144 | const url = new URL( 145 | encodeURIComponent(packageName).replace(/^%40/, '@'), 146 | 'https://registry.npmjs.org/', 147 | ); 148 | const res = await fetch(url, { 149 | headers: { 150 | accept: 151 | 'application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8, */*', 152 | }, 153 | }); 154 | const data = await res.json(); 155 | 156 | if (packageName === 'tailwindcss' && tag === '3') { 157 | const v3Versions = Object.keys(data?.versions ?? {}) 158 | .filter((v) => Bun.semver.satisfies(v, '3')) 159 | .sort(Bun.semver.order); 160 | const mostRecent = v3Versions[v3Versions.length - 1]; 161 | return mostRecent; 162 | } 163 | 164 | return data?.['dist-tags']?.[tag]; 165 | } 166 | 167 | /** 168 | * 169 | * @param {string} dir 170 | * @returns {AsyncGenerator} 171 | */ 172 | async function* getFiles(dir) { 173 | const dirents = await readdir(dir, { withFileTypes: true }); 174 | for (const dirent of dirents) { 175 | const res = resolve(dir, dirent.name); 176 | if (dirent.isDirectory()) { 177 | yield* getFiles(res); 178 | } else { 179 | yield res; 180 | } 181 | } 182 | } 183 | /** 184 | * @param {string | undefined} features 185 | */ 186 | function prettifyFeatures(features) { 187 | if (features === undefined) return 'base'; 188 | return features 189 | .substring(1, features.length - 1) // strip {} 190 | .replace(/,/g, ', ') // add spaces to commas 191 | .replace(/\|/g, ' \\| '); // escape and prettify pipes 192 | } 193 | 194 | getUpdates(); 195 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | /my-o7-app 3 | 4 | node_modules 5 | -------------------------------------------------------------------------------- /.ottotime: -------------------------------------------------------------------------------- 1 | # OTTOTIME 2 | # Do not edit manually. Check into git. 3 | 1736538264- 4:37 4 | 1738869101- 5:28 5 | 1738869430-115:28 6 | 1738876426- 32:57 7 | 1738878532- 81:14 8 | 1738884625- 46:23 9 | 1738887917- 4:19 10 | 1738956932- 0:34 11 | 1738958362- 15:54 12 | 1740554835- 23:27 13 | 1740700468- 2:20 14 | 1738958362- 4:31 15 | 1740603160- 12:56 16 | 1740606231- 0:14 17 | 1740688246- 0:30 18 | 1741307017- 14:15 19 | 1743012236- 10:23 20 | 1743027095- 23:01 21 | 1743055187- 36:06 22 | 1746742652- 8:37 23 | 1746743455- 3:24 24 | 1747165482- 0:30 25 | 1747512642- 17:36 26 | 1747514034- 1:49 27 | 1748215040- 58:02 28 | 1748218572- 4:53 29 | 1748549996- 5:20 30 | 1748554743- 0:17 31 | 1748554945- 2:30 32 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "tabWidth": 2, 5 | "useTabs": true, 6 | "trailingComma": "all" 7 | } 8 | -------------------------------------------------------------------------------- /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | hard_tabs = true 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "rust-analyzer.linkedProjects": [ 3 | "Cargo.toml", 4 | "template_builder/Cargo.toml" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "create-o7-app" 3 | authors = ["Ottomated"] 4 | version = "0.12.1" 5 | edition = "2021" 6 | 7 | [dependencies] 8 | anyhow = "1.0.86" 9 | clap = { version = "4.5.9", features = ["derive"] } 10 | crossterm = "0.28.1" 11 | directories = "6.0.0" 12 | dunce = "1.0.4" 13 | human-repr = "1.1.0" 14 | inquire = "0.7.5" 15 | once_cell = "1.19.0" 16 | pathdiff = "0.2.1" 17 | serde = { version = "1.0.203", features = ["derive"] } 18 | serde_json = "1.0.117" 19 | ureq = { version = "3.0.4", features = ["json"], default-features = false } 20 | which = "7.0.2" 21 | 22 | [build-dependencies] 23 | template_builder = { path = "./template_builder" } 24 | 25 | [dev-dependencies] 26 | tempfile = "3.10.1" 27 | itertools = "0.14.0" 28 | 29 | [profile.release] 30 | opt-level = 's' # Optimize for size 31 | lto = true 32 | strip = true 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | o7 Logo 3 |

4 | 5 |

create-o7-app

6 | 7 |

An opinionated CLI for creating type-safe Svelte apps.

8 |

9 | pnpm create o7-app 10 |

11 |
12 | 13 |

14 | 15 |

16 | 17 |

What is the o7 Stack?

18 | 19 | - [Svelte](https://svelte.dev) 20 | - [Tailwind CSS](https://tailwindcss.com/) 21 | - [Typescript](https://www.typescriptlang.org/) 22 | - [Prisma](https://www.prisma.io/) 23 | - [Kysely](https://github.com/kysely-org/kysely) 24 | - [tRPC](https://trpc.io) 25 | - [Lucia](https://lucia-auth.com/) 26 | 27 | > **Why both Prisma and Kysely?** `create-o7-app`'s template includes Kysely for **Edge support** and **fast cold starts**, with all the convenience of using Prisma for migrations. 28 | 29 | > **Isn't Lucia Auth deprecated?** No - while the Lucia _library_ is deprecated, Lucia transitioned into a tutorial for implementing authentication, which the Auth template sets up for you. 30 | 31 |

Getting Started

32 | 33 | First, run the CLI to scaffold your app: 34 | 35 | ```bash 36 | pnpm create o7-app 37 | # OR 38 | bun create o7-app 39 | # OR 40 | npm create o7-app@latest 41 | # OR 42 | yarn create o7-app 43 | ``` 44 | 45 | Then, open your new app in your favorite IDE and get started! A good place to look first is `src/routes/+page.svelte` for your frontend or `src/lib/server/routes/_app.ts` for tRPC. 46 | 47 | ## [Changelog](https://github.com/ottomated/create-o7-app/blob/main/CHANGELOG.md) 48 | 49 | ## Upcoming 50 | 51 | - [ ] Move the tutorial to a README file 52 | - [ ] Replace the dependency on `@tanstack/svelte-query` with a more lightweight tRPC client 53 | -------------------------------------------------------------------------------- /assets/README.md: -------------------------------------------------------------------------------- 1 | Generate new gif by running `vhs example.tape` 2 | [vhs](https://github.com/charmbracelet/vhs) 3 | -------------------------------------------------------------------------------- /assets/example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ottomated/create-o7-app/13fa87dbd6b14a81bd1d51cd4d00bd6d0bd6ecaf/assets/example.gif -------------------------------------------------------------------------------- /assets/example.tape: -------------------------------------------------------------------------------- 1 | 2 | Output example.gif 3 | 4 | Require pnpm 5 | 6 | Set Shell "bash" 7 | Set FontSize 20 8 | Set Width 1200 9 | Set Height 600 10 | 11 | # Setup 12 | Hide 13 | Type "rm -rf my-o7-app && clear" 14 | Enter 15 | Sleep 1s 16 | Show 17 | 18 | Sleep 500ms 19 | Type "bun create o7-app" 20 | Sleep 500ms 21 | Enter 22 | 23 | Wait+Screen /Where should/ 24 | Sleep 2s Enter 25 | 26 | Sleep 1s Type "y" Sleep 200ms Enter 27 | Sleep 1s Enter 28 | 29 | Sleep 1s 30 | Down 31 | Sleep 500ms 32 | Enter 33 | 34 | Sleep 1s 35 | Enter 36 | Sleep 1s 37 | Enter 38 | 39 | Sleep 1s Type "y" Sleep 200ms Enter 40 | Sleep 1s Type "y" Sleep 200ms Enter 41 | Sleep 1s Type "y" Sleep 200ms Enter 42 | 43 | Wait+Line 44 | Sleep 3s 45 | 46 | # Cleanup 47 | Hide 48 | Type "rm -rf my-o7-app" 49 | Enter 50 | -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | use template_builder::Builder; 2 | 3 | pub fn main() { 4 | println!("cargo:rerun-if-changed=template_builder/templates"); 5 | let builder = Builder::default(); 6 | let res = builder.build(); 7 | if let Err(err) = res { 8 | panic!("{:?}", err) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /cliff.toml: -------------------------------------------------------------------------------- 1 | [changelog] 2 | header = """ 3 | # Changelog\n 4 | """ 5 | 6 | body = """ 7 | {% if version %}\ 8 | ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} 9 | {% else %}\ 10 | ## [unreleased] 11 | {% endif %}\ 12 | {% for group, commits in commits | group_by(attribute="group") %} 13 | {% if group | striptags | trim == "Dependencies" %} 14 | {% if not loop.first %}
{% endif %} 15 | 16 | **Dependency Updates:** \ 17 | {% for commit in commits %}\ 18 | [{{ loop.index }}\ 19 | {% if not loop.last %},{% endif %}\ 20 | ](https://github.com/ottomated/create-o7-app/commit/{{ commit.id }})\ 21 | {% if not loop.last %} {% endif %}\ 22 | {% endfor %} 23 | {% else %} 24 | ### {{ group | striptags | trim }} 25 | {% for commit in commits %} 26 | - {% if commit.scope %}*({{ commit.scope }})* {% endif %}\ 27 | {% if commit.breaking %}[**breaking**] {% endif %}\ 28 | {{ commit.message }} 29 | {% endfor %} 30 | {% endif %} 31 | {% endfor %}\n 32 | """ 33 | # template for the changelog footer 34 | footer = """ 35 | {% if releases | length == 1 %} 36 | ### See the [full changelog](https://github.com/ottomated/create-o7-app/blob/main/CHANGELOG.md) 37 | {% endif %} 38 | 39 | """ 40 | # remove the leading and trailing s 41 | trim = true 42 | # postprocessors 43 | postprocessors = [ 44 | # { pattern = '', replace = "https://github.com/orhun/git-cliff" }, # replace repository URL 45 | ] 46 | 47 | [git] 48 | conventional_commits = true 49 | filter_unconventional = true 50 | split_commits = false 51 | # regex for preprocessing the commit messages 52 | commit_preprocessors = [] 53 | # regex for parsing and grouping commits 54 | commit_parsers = [ 55 | { message = "^feat", group = "features" }, 56 | { message = "^fix", group = "fixes" }, 57 | { message = "^refactor", group = "refactor" }, 58 | { message = "^doc", group = "docs" }, 59 | { message = "^perf", group = "performance" }, 60 | { message = "^internal", group = "internal" }, 61 | { message = "^chore\\(release\\): prepare for", skip = true }, 62 | { message = "^chore: Release", skip = true }, 63 | { message = "^ci: Update changelog", skip = true }, 64 | { message = "^chore\\(deps.*\\)", skip = true }, 65 | { message = "^chore\\(pr\\)", skip = true }, 66 | { message = "^chore\\(pull\\)", skip = true }, 67 | { message = "^ci: Update dependencies", group = "Dependencies" }, 68 | { message = "^chore|^ci", group = "misc" }, 69 | ] 70 | protect_breaking_commits = false 71 | filter_commits = false 72 | topo_order = false 73 | sort_commits = "oldest" 74 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "@types/node": "^22.15.21" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /pkg/bin.js: -------------------------------------------------------------------------------- 1 | const os = require('os'); 2 | 3 | const platforms = [ 4 | { 5 | type: 'Windows_NT', 6 | arch: 'x64', 7 | file: 'win64.exe', 8 | }, 9 | { 10 | type: 'Windows_NT', 11 | arch: 'ia32', 12 | file: 'win32.exe', 13 | }, 14 | { 15 | type: 'Linux', 16 | arch: 'x64', 17 | file: 'linux', 18 | }, 19 | { 20 | type: 'Darwin', 21 | arch: 'x64', 22 | file: 'macos', 23 | }, 24 | { 25 | type: 'Darwin', 26 | arch: 'arm64', 27 | file: 'macos-arm64', 28 | }, 29 | ]; 30 | 31 | const type = os.type(); 32 | const arch = os.arch(); 33 | const supported = platforms.find((p) => p.type === type && p.arch === arch); 34 | if (!supported) { 35 | throw new Error(`Unsupported platform: ${type} ${arch}`); 36 | } 37 | 38 | const { join } = require('path'); 39 | const { existsSync, mkdirSync } = require('fs'); 40 | const { Readable } = require('stream'); 41 | const { x: extract } = require('tar'); 42 | const { spawnSync } = require('child_process'); 43 | const { version } = require('./package.json'); 44 | 45 | const dir = join(__dirname, 'node_modules', '.bin'); 46 | const bin = join(dir, `create-o7-app-${supported.file}`); 47 | 48 | const exists = existsSync(bin); 49 | 50 | async function install() { 51 | if (exists) return; 52 | 53 | if (!existsSync(dir)) { 54 | mkdirSync(dir, { recursive: true }); 55 | } 56 | 57 | const res = await fetch( 58 | `https://github.com/ottomated/create-o7-app/releases/download/${version}/create-o7-app-${supported.file}.tar.gz`, 59 | ); 60 | if (!res.ok) { 61 | console.error(`Error fetching release: ${res.statusText}`); 62 | process.exit(1); 63 | } 64 | const sink = Readable.fromWeb(res.body).pipe(extract({ strip: 1, C: dir })); 65 | 66 | return new Promise((resolve) => { 67 | sink.on('finish', () => resolve()); 68 | sink.on('error', (err) => { 69 | console.error(`Error fetching release: ${err.message}`); 70 | process.exit(1); 71 | }); 72 | }); 73 | } 74 | 75 | async function run() { 76 | if (!exists) await install(); 77 | const args = process.argv.slice(2); 78 | const child = spawnSync(bin, args, { 79 | cwd: process.cwd(), 80 | stdio: 'inherit', 81 | }); 82 | if (child.error) { 83 | console.error(child.error); 84 | child.exit(1); 85 | } 86 | process.exit(child.status); 87 | } 88 | 89 | module.exports = { install, run }; 90 | -------------------------------------------------------------------------------- /pkg/install.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | require("./bin").install(); -------------------------------------------------------------------------------- /pkg/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "create-o7-app", 3 | "version": "TEMPLATE", 4 | "description": "Create web applications with the o7 stack", 5 | "license": "MIT", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/ottomated/create-o7-app.git" 9 | }, 10 | "keywords": [ 11 | "create-o7-app", 12 | "o7", 13 | "svelte", 14 | "sveltekit", 15 | "tailwind", 16 | "tRPC", 17 | "typescript", 18 | "planetscale", 19 | "prisma", 20 | "kysely", 21 | "d1", 22 | "cloudflare", 23 | "lucia", 24 | "authentication", 25 | "turso", 26 | "websocket" 27 | ], 28 | "bin": { 29 | "create-o7-app": "run.js" 30 | }, 31 | "scripts": { 32 | "postinstall": "node ./install.js" 33 | }, 34 | "dependencies": { 35 | "tar": "^7.4.3" 36 | }, 37 | "engines": { 38 | "node": ">=18" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /pkg/pnpm-lock.yaml: -------------------------------------------------------------------------------- 1 | lockfileVersion: '9.0' 2 | 3 | settings: 4 | autoInstallPeers: true 5 | excludeLinksFromLockfile: false 6 | 7 | importers: 8 | 9 | .: 10 | dependencies: 11 | tar: 12 | specifier: ^7.4.3 13 | version: 7.4.3 14 | 15 | packages: 16 | 17 | '@isaacs/cliui@8.0.2': 18 | resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} 19 | engines: {node: '>=12'} 20 | 21 | '@isaacs/fs-minipass@4.0.1': 22 | resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} 23 | engines: {node: '>=18.0.0'} 24 | 25 | '@pkgjs/parseargs@0.11.0': 26 | resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} 27 | engines: {node: '>=14'} 28 | 29 | ansi-regex@5.0.1: 30 | resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} 31 | engines: {node: '>=8'} 32 | 33 | ansi-regex@6.1.0: 34 | resolution: {integrity: sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==} 35 | engines: {node: '>=12'} 36 | 37 | ansi-styles@4.3.0: 38 | resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} 39 | engines: {node: '>=8'} 40 | 41 | ansi-styles@6.2.1: 42 | resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} 43 | engines: {node: '>=12'} 44 | 45 | balanced-match@1.0.2: 46 | resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} 47 | 48 | brace-expansion@2.0.1: 49 | resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} 50 | 51 | chownr@3.0.0: 52 | resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} 53 | engines: {node: '>=18'} 54 | 55 | color-convert@2.0.1: 56 | resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} 57 | engines: {node: '>=7.0.0'} 58 | 59 | color-name@1.1.4: 60 | resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} 61 | 62 | cross-spawn@7.0.6: 63 | resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} 64 | engines: {node: '>= 8'} 65 | 66 | eastasianwidth@0.2.0: 67 | resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} 68 | 69 | emoji-regex@8.0.0: 70 | resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} 71 | 72 | emoji-regex@9.2.2: 73 | resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} 74 | 75 | foreground-child@3.3.0: 76 | resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==} 77 | engines: {node: '>=14'} 78 | 79 | glob@10.4.5: 80 | resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} 81 | hasBin: true 82 | 83 | is-fullwidth-code-point@3.0.0: 84 | resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} 85 | engines: {node: '>=8'} 86 | 87 | isexe@2.0.0: 88 | resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} 89 | 90 | jackspeak@3.4.3: 91 | resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} 92 | 93 | lru-cache@10.4.3: 94 | resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} 95 | 96 | minimatch@9.0.5: 97 | resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} 98 | engines: {node: '>=16 || 14 >=14.17'} 99 | 100 | minipass@7.1.2: 101 | resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} 102 | engines: {node: '>=16 || 14 >=14.17'} 103 | 104 | minizlib@3.0.1: 105 | resolution: {integrity: sha512-umcy022ILvb5/3Djuu8LWeqUa8D68JaBzlttKeMWen48SjabqS3iY5w/vzeMzMUNhLDifyhbOwKDSznB1vvrwg==} 106 | engines: {node: '>= 18'} 107 | 108 | mkdirp@3.0.1: 109 | resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} 110 | engines: {node: '>=10'} 111 | hasBin: true 112 | 113 | package-json-from-dist@1.0.1: 114 | resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} 115 | 116 | path-key@3.1.1: 117 | resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} 118 | engines: {node: '>=8'} 119 | 120 | path-scurry@1.11.1: 121 | resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} 122 | engines: {node: '>=16 || 14 >=14.18'} 123 | 124 | rimraf@5.0.10: 125 | resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} 126 | hasBin: true 127 | 128 | shebang-command@2.0.0: 129 | resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} 130 | engines: {node: '>=8'} 131 | 132 | shebang-regex@3.0.0: 133 | resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} 134 | engines: {node: '>=8'} 135 | 136 | signal-exit@4.1.0: 137 | resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} 138 | engines: {node: '>=14'} 139 | 140 | string-width@4.2.3: 141 | resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} 142 | engines: {node: '>=8'} 143 | 144 | string-width@5.1.2: 145 | resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} 146 | engines: {node: '>=12'} 147 | 148 | strip-ansi@6.0.1: 149 | resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} 150 | engines: {node: '>=8'} 151 | 152 | strip-ansi@7.1.0: 153 | resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} 154 | engines: {node: '>=12'} 155 | 156 | tar@7.4.3: 157 | resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} 158 | engines: {node: '>=18'} 159 | 160 | which@2.0.2: 161 | resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} 162 | engines: {node: '>= 8'} 163 | hasBin: true 164 | 165 | wrap-ansi@7.0.0: 166 | resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} 167 | engines: {node: '>=10'} 168 | 169 | wrap-ansi@8.1.0: 170 | resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} 171 | engines: {node: '>=12'} 172 | 173 | yallist@5.0.0: 174 | resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} 175 | engines: {node: '>=18'} 176 | 177 | snapshots: 178 | 179 | '@isaacs/cliui@8.0.2': 180 | dependencies: 181 | string-width: 5.1.2 182 | string-width-cjs: string-width@4.2.3 183 | strip-ansi: 7.1.0 184 | strip-ansi-cjs: strip-ansi@6.0.1 185 | wrap-ansi: 8.1.0 186 | wrap-ansi-cjs: wrap-ansi@7.0.0 187 | 188 | '@isaacs/fs-minipass@4.0.1': 189 | dependencies: 190 | minipass: 7.1.2 191 | 192 | '@pkgjs/parseargs@0.11.0': 193 | optional: true 194 | 195 | ansi-regex@5.0.1: {} 196 | 197 | ansi-regex@6.1.0: {} 198 | 199 | ansi-styles@4.3.0: 200 | dependencies: 201 | color-convert: 2.0.1 202 | 203 | ansi-styles@6.2.1: {} 204 | 205 | balanced-match@1.0.2: {} 206 | 207 | brace-expansion@2.0.1: 208 | dependencies: 209 | balanced-match: 1.0.2 210 | 211 | chownr@3.0.0: {} 212 | 213 | color-convert@2.0.1: 214 | dependencies: 215 | color-name: 1.1.4 216 | 217 | color-name@1.1.4: {} 218 | 219 | cross-spawn@7.0.6: 220 | dependencies: 221 | path-key: 3.1.1 222 | shebang-command: 2.0.0 223 | which: 2.0.2 224 | 225 | eastasianwidth@0.2.0: {} 226 | 227 | emoji-regex@8.0.0: {} 228 | 229 | emoji-regex@9.2.2: {} 230 | 231 | foreground-child@3.3.0: 232 | dependencies: 233 | cross-spawn: 7.0.6 234 | signal-exit: 4.1.0 235 | 236 | glob@10.4.5: 237 | dependencies: 238 | foreground-child: 3.3.0 239 | jackspeak: 3.4.3 240 | minimatch: 9.0.5 241 | minipass: 7.1.2 242 | package-json-from-dist: 1.0.1 243 | path-scurry: 1.11.1 244 | 245 | is-fullwidth-code-point@3.0.0: {} 246 | 247 | isexe@2.0.0: {} 248 | 249 | jackspeak@3.4.3: 250 | dependencies: 251 | '@isaacs/cliui': 8.0.2 252 | optionalDependencies: 253 | '@pkgjs/parseargs': 0.11.0 254 | 255 | lru-cache@10.4.3: {} 256 | 257 | minimatch@9.0.5: 258 | dependencies: 259 | brace-expansion: 2.0.1 260 | 261 | minipass@7.1.2: {} 262 | 263 | minizlib@3.0.1: 264 | dependencies: 265 | minipass: 7.1.2 266 | rimraf: 5.0.10 267 | 268 | mkdirp@3.0.1: {} 269 | 270 | package-json-from-dist@1.0.1: {} 271 | 272 | path-key@3.1.1: {} 273 | 274 | path-scurry@1.11.1: 275 | dependencies: 276 | lru-cache: 10.4.3 277 | minipass: 7.1.2 278 | 279 | rimraf@5.0.10: 280 | dependencies: 281 | glob: 10.4.5 282 | 283 | shebang-command@2.0.0: 284 | dependencies: 285 | shebang-regex: 3.0.0 286 | 287 | shebang-regex@3.0.0: {} 288 | 289 | signal-exit@4.1.0: {} 290 | 291 | string-width@4.2.3: 292 | dependencies: 293 | emoji-regex: 8.0.0 294 | is-fullwidth-code-point: 3.0.0 295 | strip-ansi: 6.0.1 296 | 297 | string-width@5.1.2: 298 | dependencies: 299 | eastasianwidth: 0.2.0 300 | emoji-regex: 9.2.2 301 | strip-ansi: 7.1.0 302 | 303 | strip-ansi@6.0.1: 304 | dependencies: 305 | ansi-regex: 5.0.1 306 | 307 | strip-ansi@7.1.0: 308 | dependencies: 309 | ansi-regex: 6.1.0 310 | 311 | tar@7.4.3: 312 | dependencies: 313 | '@isaacs/fs-minipass': 4.0.1 314 | chownr: 3.0.0 315 | minipass: 7.1.2 316 | minizlib: 3.0.1 317 | mkdirp: 3.0.1 318 | yallist: 5.0.0 319 | 320 | which@2.0.2: 321 | dependencies: 322 | isexe: 2.0.0 323 | 324 | wrap-ansi@7.0.0: 325 | dependencies: 326 | ansi-styles: 4.3.0 327 | string-width: 4.2.3 328 | strip-ansi: 6.0.1 329 | 330 | wrap-ansi@8.1.0: 331 | dependencies: 332 | ansi-styles: 6.2.1 333 | string-width: 5.1.2 334 | strip-ansi: 7.1.0 335 | 336 | yallist@5.0.0: {} 337 | -------------------------------------------------------------------------------- /pkg/run.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | require("./bin").run(); -------------------------------------------------------------------------------- /pnpm-lock.yaml: -------------------------------------------------------------------------------- 1 | lockfileVersion: '9.0' 2 | 3 | settings: 4 | autoInstallPeers: true 5 | excludeLinksFromLockfile: false 6 | 7 | importers: 8 | 9 | .: 10 | devDependencies: 11 | '@types/node': 12 | specifier: ^22.15.21 13 | version: 22.15.21 14 | 15 | packages: 16 | 17 | '@types/node@22.15.21': 18 | resolution: {integrity: sha512-EV/37Td6c+MgKAbkcLG6vqZ2zEYHD7bvSrzqqs2RIhbA6w3x+Dqz8MZM3sP6kGTeLrdoOgKZe+Xja7tUB2DNkQ==} 19 | 20 | undici-types@6.21.0: 21 | resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} 22 | 23 | snapshots: 24 | 25 | '@types/node@22.15.21': 26 | dependencies: 27 | undici-types: 6.21.0 28 | 29 | undici-types@6.21.0: {} 30 | -------------------------------------------------------------------------------- /src/args.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | 3 | use crate::utils::PackageManager; 4 | 5 | #[derive(clap::Parser, Debug)] 6 | #[command(version, author, long_about = None)] 7 | pub struct Args { 8 | /// Override the automatic package manager detection 9 | #[arg(value_enum, long)] 10 | pub package_manager: Option, 11 | 12 | /// Turn off telemetry reporting 13 | #[arg(long)] 14 | pub disable_telemetry: bool, 15 | 16 | /// Turn on telemetry reporting 17 | #[arg(long)] 18 | pub enable_telemetry: bool, 19 | } 20 | 21 | pub fn parse() -> Args { 22 | Args::parse() 23 | } 24 | -------------------------------------------------------------------------------- /src/cookie.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use anyhow::Context; 4 | use directories::ProjectDirs; 5 | 6 | fn get_cookie_file(name: &str) -> anyhow::Result { 7 | Ok(ProjectDirs::from("net", "Ottomated", "create-o7-app") 8 | .context("Could not determine app config directory")? 9 | .config_dir() 10 | .join(name)) 11 | } 12 | 13 | pub fn get_cookie(name: &str) -> anyhow::Result> { 14 | let file = 15 | get_cookie_file(name).with_context(|| format!("Could not get cookie file for {name}"))?; 16 | if !file.exists() { 17 | return Ok(None); 18 | } 19 | let text = std::fs::read_to_string(file) 20 | .with_context(|| format!("Could not read cookie file for {name}"))?; 21 | Ok(Some(text)) 22 | } 23 | 24 | pub fn get_cookie_bool(name: &str, default: bool) -> bool { 25 | match get_cookie(name) { 26 | Ok(Some(value)) if default => value != "false", 27 | Ok(Some(value)) if !default => value == "true", 28 | _ => default, 29 | } 30 | } 31 | 32 | pub fn set_cookie(name: &str, value: &str) -> anyhow::Result<()> { 33 | let file = 34 | get_cookie_file(name).with_context(|| format!("Could not get cookie file for {name}"))?; 35 | std::fs::create_dir_all(file.parent().unwrap()) 36 | .with_context(|| format!("Could not create cookie directory for {name}"))?; 37 | std::fs::write(file, value) 38 | .with_context(|| format!("Could not write cookie file for {name}"))?; 39 | Ok(()) 40 | } 41 | -------------------------------------------------------------------------------- /src/create/git.rs: -------------------------------------------------------------------------------- 1 | use std::process::{Command, Stdio}; 2 | 3 | use anyhow::Error; 4 | 5 | use crate::{create::log_step_start, input::UserInput}; 6 | 7 | use super::log_step_end; 8 | macro_rules! run_git { 9 | ( 10 | $git:ident, 11 | $input:ident, 12 | $step:expr 13 | ) => { 14 | let args = match $step { 15 | GitStep::Init => vec!["init"], 16 | GitStep::Add => vec!["add", "."], 17 | GitStep::Commit => vec!["commit", "-q", "-m", crate::utils::INITIAL_COMMIT], 18 | }; 19 | let status = Command::new(&$git) 20 | .args(args) 21 | .current_dir(&$input.location.path) 22 | .stdout(Stdio::inherit()) 23 | .stderr(Stdio::inherit()) 24 | .status(); 25 | match status { 26 | Ok(status) => { 27 | if !status.success() { 28 | return Err(($step, anyhow::anyhow!("Could not create git repository"))); 29 | } 30 | } 31 | Err(e) => { 32 | return Err(($step, anyhow::Error::from(e))); 33 | } 34 | } 35 | }; 36 | } 37 | 38 | #[derive(Debug)] 39 | pub enum GitStep { 40 | Init, 41 | Add, 42 | Commit, 43 | } 44 | 45 | pub fn create_repo(input: &UserInput) -> Result<(), (GitStep, Error)> { 46 | let Some(git) = &input.git else { 47 | return Ok(()); 48 | }; 49 | 50 | let start = log_step_start("Creating git repository...\n"); 51 | 52 | run_git!(git, input, GitStep::Init); 53 | 54 | run_git!(git, input, GitStep::Add); 55 | 56 | run_git!(git, input, GitStep::Commit); 57 | 58 | println!(); 59 | 60 | log_step_end(start); 61 | 62 | Ok(()) 63 | } 64 | -------------------------------------------------------------------------------- /src/create/install_deps.rs: -------------------------------------------------------------------------------- 1 | use std::{ffi::OsStr, process::Command}; 2 | 3 | use crate::{ 4 | create::log_step_start, 5 | input::UserInput, 6 | utils::{Feature, PackageManager}, 7 | }; 8 | use anyhow::{bail, Context, Result}; 9 | 10 | use super::log_step_end; 11 | 12 | pub fn install_deps(input: &UserInput) -> Result<()> { 13 | let Some(pm) = &input.install_deps else { 14 | return Ok(()); 15 | }; 16 | 17 | let start = log_step_start("Installing dependencies..."); 18 | 19 | let mut cmd = Command::new(&pm.exec_path); 20 | cmd.current_dir(&input.location.path).arg("install"); 21 | #[cfg(test)] 22 | { 23 | cmd.stdout(std::process::Stdio::null()) 24 | .stderr(std::process::Stdio::null()); 25 | } 26 | let cmd = cmd.status().with_context(|| { 27 | format!( 28 | "Failed to execute {:?}", 29 | pm.exec_path 30 | .file_name() 31 | .unwrap_or(OsStr::new("package manager")) 32 | ) 33 | })?; 34 | 35 | println!(); 36 | 37 | if !cmd.success() { 38 | bail!("Could not install dependencies"); 39 | } 40 | 41 | // Run svelte-kit sync 42 | let mut sync = match pm.package_manager { 43 | PackageManager::Yarn => { 44 | let mut sync = Command::new(&pm.exec_path); 45 | sync.args(["exec", "svelte-kit", "sync"]); 46 | sync 47 | } 48 | _ => { 49 | let mut sync = Command::new(input.location.path.join("node_modules/.bin/svelte-kit")); 50 | sync.arg("sync"); 51 | sync 52 | } 53 | }; 54 | sync.current_dir(&input.location.path); 55 | 56 | let sync = sync.status().context("Failed to run svelte-kit sync")?; 57 | 58 | if !sync.success() { 59 | bail!("Could not run svelte-kit sync"); 60 | } 61 | 62 | // Run wrangler types 63 | if input.features.contains(&Feature::Edge) { 64 | let mut wrangler_types = Command::new(&pm.exec_path); 65 | wrangler_types 66 | .args(["run", "typegen"]) 67 | .current_dir(&input.location.path); 68 | 69 | let sync = wrangler_types 70 | .status() 71 | .context("Failed to generate wrangler types")?; 72 | 73 | if !sync.success() { 74 | bail!("Could not generate wrangler types"); 75 | } 76 | } 77 | 78 | log_step_end(start); 79 | 80 | Ok(()) 81 | } 82 | -------------------------------------------------------------------------------- /src/create/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod git; 2 | mod install_deps; 3 | mod next_steps; 4 | mod package_json; 5 | mod scaffold; 6 | 7 | use crate::{ 8 | create::{git::create_repo, install_deps::install_deps, package_json::create_package_json}, 9 | input::UserInput, 10 | utils::get_package_manager, 11 | }; 12 | use anyhow::{Context, Result}; 13 | use crossterm::style::{style, Stylize}; 14 | use human_repr::HumanDuration; 15 | use std::{fs, time::Instant}; 16 | 17 | use self::scaffold::scaffold; 18 | 19 | mod templates { 20 | include!(concat!(env!("OUT_DIR"), "/templates.rs")); 21 | } 22 | 23 | pub fn create(mut input: UserInput) -> Result<()> { 24 | // Ensure the project directory is empty 25 | if input.location.path.exists() { 26 | fs::remove_dir_all(&input.location.path).context("Could not clear project directory")?; 27 | } 28 | fs::create_dir_all(&input.location.path).context("Could not create project directory")?; 29 | 30 | // Add the package manager to the features 31 | let package_manager = input 32 | .install_deps 33 | .as_ref() 34 | .map(|p| p.package_manager.clone()) 35 | .unwrap_or_else(get_package_manager); 36 | input.features.insert(package_manager.to_feature()); 37 | 38 | println!(); 39 | // Scaffold (copy files) 40 | let start = log_step_start("Copying template..."); 41 | scaffold(&input)?; 42 | log_step_end(start); 43 | 44 | let start = log_step_start("Creating package.json..."); 45 | create_package_json(&input)?; 46 | log_step_end(start); 47 | 48 | let install_deps_res = install_deps(&input); 49 | if let Err(e) = &install_deps_res { 50 | log_step_error(e); 51 | } 52 | 53 | let create_repo_res = create_repo(&input); 54 | let git_error = match &create_repo_res { 55 | Ok(_) => None, 56 | Err((step, e)) => { 57 | log_step_error(e); 58 | Some(step) 59 | } 60 | }; 61 | 62 | next_steps::print(&input, git_error, install_deps_res.is_ok()); 63 | 64 | Ok(()) 65 | } 66 | 67 | pub fn log_step_error(err: &anyhow::Error) { 68 | let end = style("❌ Error:").red().bold(); 69 | println!("{end} {}\n", style(err.to_string()).red()); 70 | } 71 | 72 | pub fn log_step_start(step: &str) -> Instant { 73 | let logo = style("{O}").dark_magenta().bold(); 74 | let step = style(step).magenta(); 75 | println!("{logo} {step}"); 76 | 77 | Instant::now() 78 | } 79 | 80 | pub fn log_step_end(start: Instant) { 81 | let end = style(format!( 82 | "✔ Finished in {}\n", 83 | start.elapsed().human_duration() 84 | )) 85 | .green(); 86 | println!("{end}"); 87 | } 88 | -------------------------------------------------------------------------------- /src/create/next_steps.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | use crossterm::style::{style, Stylize}; 4 | 5 | use super::git::GitStep; 6 | use crate::utils::{get_package_manager, Feature, INITIAL_COMMIT}; 7 | use crate::{create::log_step_start, input::UserInput}; 8 | 9 | fn log_with_info(command: String, info: &str) { 10 | let command = style(command).green(); 11 | let info = style(info).green().dim(); 12 | println!(" {command} {info}"); 13 | } 14 | 15 | pub fn print(input: &UserInput, git_error: Option<&GitStep>, install_deps: bool) { 16 | log_step_start("All done! Next steps:"); 17 | 18 | log_with_info( 19 | format!("cd {}", get_shortest_path(&input.location.path)), 20 | "(navigate to your project's folder)", 21 | ); 22 | 23 | let git_command = git_error.map(|step| match step { 24 | GitStep::Init => format!( 25 | "git init && git add . && git commit -m \"{}\"", 26 | INITIAL_COMMIT 27 | ), 28 | GitStep::Add => format!("git add . && git commit -m \"{}\"", INITIAL_COMMIT), 29 | GitStep::Commit => format!("git commit -m \"{}\"", INITIAL_COMMIT), 30 | }); 31 | 32 | if let Some(git_command) = git_command { 33 | log_with_info(git_command, "(initialize your git repository)"); 34 | } 35 | 36 | match input.install_deps { 37 | Some(ref pm) => { 38 | if !install_deps { 39 | log_with_info( 40 | format!("{} install", pm.package_manager), 41 | "(install dependencies)", 42 | ); 43 | if input.features.contains(&Feature::Edge) { 44 | log_with_info( 45 | format!("{} run typegen", pm.package_manager), 46 | "(generate Cloudflare types)", 47 | ); 48 | } 49 | } 50 | 51 | log_with_info( 52 | pm.package_manager.run_script("dev"), 53 | "(start the dev server)", 54 | ); 55 | } 56 | None => { 57 | let package_manager = get_package_manager(); 58 | log_with_info( 59 | format!("{} install", package_manager), 60 | "(install dependencies)", 61 | ); 62 | if input.features.contains(&Feature::Edge) { 63 | log_with_info( 64 | format!("{} run typegen", package_manager), 65 | "(generate Cloudflare types)", 66 | ); 67 | } 68 | log_with_info(package_manager.run_script("dev"), "(start the dev server)"); 69 | } 70 | }; 71 | } 72 | 73 | fn get_shortest_path(path: &Path) -> String { 74 | let absolute = format!("{}", path.display()); 75 | 76 | let relative = match std::env::current_dir() { 77 | Ok(current_dir) => { 78 | pathdiff::diff_paths(&absolute, current_dir).map(|path| format!("{}", path.display())) 79 | } 80 | Err(_) => None, 81 | }; 82 | match relative { 83 | Some(relative) => { 84 | if relative.len() < absolute.len() { 85 | relative 86 | } else { 87 | absolute 88 | } 89 | } 90 | None => absolute, 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/create/package_json.rs: -------------------------------------------------------------------------------- 1 | use super::templates; 2 | use crate::{input::UserInput, utils::PackageManager}; 3 | use anyhow::{Context, Result}; 4 | use serde::Serialize; 5 | use serde_json::{ser::PrettyFormatter, Serializer}; 6 | use std::fs; 7 | 8 | pub fn create_package_json(input: &UserInput) -> Result<()> { 9 | let (base, extras) = templates::get_package_jsons(); 10 | let mut package_json = base.contents; 11 | 12 | for extra in extras { 13 | let included = extra.features.is_subset(&input.features); 14 | if !included { 15 | continue; 16 | } 17 | package_json.merge(extra.contents); 18 | } 19 | 20 | package_json.name = Some(&input.location.name); 21 | 22 | package_json.package_manager = input.install_deps.as_ref().and_then(|p| p.version_string()); 23 | 24 | if !input 25 | .install_deps 26 | .as_ref() 27 | .is_some_and(|pm| pm.package_manager == PackageManager::Pnpm) 28 | { 29 | package_json.pnpm = None; 30 | } 31 | 32 | let target_path = &input.location.path.join("package.json"); 33 | let formatter = PrettyFormatter::with_indent(b"\t"); 34 | let buf = Vec::new(); 35 | let mut ser = Serializer::with_formatter(buf, formatter); 36 | package_json 37 | .serialize(&mut ser) 38 | .context("Failed to serialize package.json")?; 39 | 40 | fs::write(target_path, ser.into_inner())?; 41 | 42 | Ok(()) 43 | } 44 | -------------------------------------------------------------------------------- /src/create/scaffold.rs: -------------------------------------------------------------------------------- 1 | use super::templates; 2 | use crate::input::UserInput; 3 | use anyhow::{Context, Result}; 4 | use std::{collections::HashSet, fs}; 5 | 6 | const REPLACABLE_EXTENSIONS: [&str; 3] = ["html", "json", "jsonc"]; 7 | const NAME_PLACEHOLDER: &[u8] = b"__o7__name__"; 8 | 9 | pub fn scaffold(input: &UserInput) -> Result<()> { 10 | let templates = templates::get_templates(); 11 | 12 | let name_replacement = input.location.name.as_bytes(); 13 | 14 | let files_to_delete: HashSet<_> = templates 15 | .iter() 16 | .filter(|t| t.is_delete && t.features.is_subset(&input.features)) 17 | .map(|t| t.path) 18 | .collect(); 19 | 20 | for template in templates { 21 | if files_to_delete.contains(&template.path) { 22 | continue; 23 | } 24 | let included = template.features.is_subset(&input.features); 25 | if !included { 26 | continue; 27 | } 28 | 29 | let target_path = input.location.path.join(template.path); 30 | let folder = target_path.parent(); 31 | if let Some(folder) = folder { 32 | fs::create_dir_all(folder) 33 | .with_context(|| format!("Could not create {}", folder.display()))?; 34 | } 35 | 36 | let extension = target_path.extension().and_then(|e| e.to_str()); 37 | 38 | let contents = match extension { 39 | Some(ext) if REPLACABLE_EXTENSIONS.contains(&ext) => { 40 | let mut result = template.contents.to_vec(); 41 | 42 | if let Some(pos) = template 43 | .contents 44 | .windows(NAME_PLACEHOLDER.len()) 45 | .position(|window| window == NAME_PLACEHOLDER) 46 | { 47 | result.splice( 48 | pos..pos + NAME_PLACEHOLDER.len(), 49 | name_replacement.iter().cloned(), 50 | ); 51 | } 52 | 53 | result 54 | } 55 | _ => template.contents.to_vec(), 56 | }; 57 | fs::write(target_path, contents) 58 | .with_context(|| format!("Could not write {}", template.path))?; 59 | } 60 | 61 | Ok(()) 62 | } 63 | -------------------------------------------------------------------------------- /src/input/git.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | use crossterm::style::{style, Stylize}; 3 | use inquire::{ui::RenderConfig, Confirm}; 4 | use std::{ 5 | path::{Path, PathBuf}, 6 | process::Command, 7 | }; 8 | 9 | use super::{project_location::ProjectLocation, warn_render_config}; 10 | 11 | pub fn prompt(render_config: &RenderConfig, location: &ProjectLocation) -> Result> { 12 | let git_path = which::which("git"); 13 | 14 | let closest_git_root; 15 | 16 | let git = match git_path { 17 | Ok(git_path) => { 18 | closest_git_root = get_closest_git_root(&git_path, &location.path)?; 19 | 20 | let git = Confirm::new("Initialize a new git repository?") 21 | .with_render_config(*render_config) 22 | // Default to true if not in a git repo 23 | .with_default(closest_git_root.is_none()) 24 | .prompt()?; 25 | if git { 26 | git_path 27 | } else { 28 | return Ok(None); 29 | } 30 | } 31 | Err(_) => { 32 | let warn = style("!").red(); 33 | let message = style("Git not found - https://github.com/git-guides/install-git") 34 | .yellow() 35 | .bold(); 36 | println!("{warn} {message}"); 37 | println!( 38 | " {}", 39 | style("(git is optional, but recommended)").yellow().dim() 40 | ); 41 | return Ok(None); 42 | } 43 | }; 44 | 45 | if let Some(closest_git_root) = closest_git_root { 46 | let init = Confirm::new( 47 | "Your new project is inside a git repository. Still initialize a new one?", 48 | ) 49 | .with_render_config(warn_render_config()) 50 | .with_help_message(&format!("{} is a git repository", closest_git_root)) 51 | .with_default(false) 52 | .prompt()?; 53 | if !init { 54 | return Ok(None); 55 | } 56 | } 57 | 58 | Ok(Some(git)) 59 | // if 60 | } 61 | 62 | fn get_closest_git_root(git: &PathBuf, directory: &Path) -> Result> { 63 | let closest_existing_dir = directory 64 | .parent() 65 | .unwrap_or(directory) 66 | .ancestors() 67 | .find(|dir| dir.exists()) 68 | .context("Could not find closest existing directory")?; 69 | 70 | let output = Command::new(git) 71 | .current_dir(closest_existing_dir) 72 | .arg("rev-parse") 73 | .arg("--show-toplevel") 74 | .output() 75 | .context("Failed to execute 'git rev-parse --show-toplevel'")?; 76 | 77 | if !output.status.success() { 78 | return Ok(None); 79 | } 80 | 81 | let output = String::from_utf8(output.stdout) 82 | .context("'git rev-parse --show-toplevel' output is invalid UTF-8")?; 83 | 84 | Ok(Some(output.trim().to_string())) 85 | } 86 | -------------------------------------------------------------------------------- /src/input/install_deps.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | ffi::OsStr, 3 | path::{Path, PathBuf}, 4 | process::Command, 5 | }; 6 | 7 | use crate::utils::PackageManager; 8 | use anyhow::{Context, Result}; 9 | use crossterm::style::{style, Stylize}; 10 | use inquire::{ui::RenderConfig, Confirm}; 11 | 12 | #[derive(Debug)] 13 | pub struct ProjectPackageManager { 14 | pub package_manager: PackageManager, 15 | pub package_manager_version: Option, 16 | pub exec_path: PathBuf, 17 | } 18 | 19 | impl ProjectPackageManager { 20 | pub fn version_string(&self) -> Option { 21 | self.package_manager_version 22 | .as_ref() 23 | .map(|version| format!("{manager}@{version}", manager = self.package_manager)) 24 | } 25 | } 26 | 27 | pub fn prompt( 28 | render_config: &RenderConfig, 29 | mut package_manager: PackageManager, 30 | ) -> Result> { 31 | let package_manager_path = which::which(format!("{}", package_manager)); 32 | 33 | let mut old_package_manager = None; 34 | 35 | let (path, is_fallback) = 36 | match package_manager_path { 37 | Ok(package_manager_path) => (package_manager_path, false), 38 | Err(_) => { 39 | let npm_path = which::which("npm"); 40 | match npm_path { 41 | Ok(npm_path) => { 42 | old_package_manager = Some(package_manager); 43 | package_manager = PackageManager::Npm; 44 | (npm_path, true) 45 | } 46 | Err(_) => { 47 | let warn = style("!").red(); 48 | let message = 49 | style("No package manager installed - https://volta.sh to install") 50 | .yellow() 51 | .bold(); 52 | println!("{warn} {message}"); 53 | println!( 54 | " {}", 55 | style("(you must install a package manager, such as pnpm, before developing)").yellow().dim() 56 | ); 57 | return Ok(None); 58 | } 59 | } 60 | } 61 | }; 62 | 63 | let message = format!("Would you like us to run '{package_manager} install'?"); 64 | let mut install_deps = Confirm::new(&message) 65 | .with_render_config(*render_config) 66 | .with_default(package_manager != PackageManager::Npm); 67 | 68 | let help = format!( 69 | "Falling back to npm, as {} was not found", 70 | old_package_manager.unwrap_or(PackageManager::Npm) 71 | ); 72 | if is_fallback { 73 | install_deps = install_deps.with_help_message(&help); 74 | } 75 | let install_deps = install_deps.prompt()?; 76 | 77 | if !install_deps { 78 | return Ok(None); 79 | } 80 | 81 | let version = match get_package_manager_version(&path) { 82 | Ok(version) => Some(version), 83 | Err(err) => { 84 | println!("{}", style(err).red()); 85 | None 86 | } 87 | }; 88 | 89 | Ok(Some(ProjectPackageManager { 90 | package_manager, 91 | package_manager_version: version, 92 | exec_path: path, 93 | })) 94 | } 95 | 96 | fn get_package_manager_version(exec_path: &Path) -> Result { 97 | let output = Command::new(exec_path) 98 | .arg("--version") 99 | .output() 100 | .with_context(|| { 101 | format!( 102 | "Failed to execute {:?} --version", 103 | exec_path 104 | .file_name() 105 | .unwrap_or(OsStr::new("package manager")) 106 | ) 107 | })?; 108 | 109 | let version = String::from_utf8(output.stdout) 110 | .context("package manager version is invalid UTF-8")? 111 | .trim() 112 | .to_string(); 113 | 114 | Ok(version) 115 | } 116 | -------------------------------------------------------------------------------- /src/input/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod git; 2 | pub mod install_deps; 3 | pub mod project_features; 4 | pub mod project_location; 5 | 6 | use std::{collections::HashSet, path::PathBuf}; 7 | 8 | use crate::{ 9 | telemetry, 10 | utils::{get_package_manager, Feature, PackageManager}, 11 | }; 12 | 13 | use self::{install_deps::ProjectPackageManager, project_location::ProjectLocation}; 14 | use anyhow::Result; 15 | use inquire::ui::{Attributes, Color, RenderConfig, StyleSheet}; 16 | 17 | #[derive(Debug)] 18 | pub struct UserInput { 19 | pub location: ProjectLocation, 20 | pub features: HashSet, 21 | pub git: Option, 22 | pub install_deps: Option, 23 | } 24 | 25 | pub fn prompt() -> Result { 26 | let package_manager = get_package_manager(); 27 | 28 | if package_manager == PackageManager::Pnpm { 29 | // pnpm create sometimes eats the first line of output 30 | println!(); 31 | } 32 | 33 | telemetry::print_initial_warning(); 34 | 35 | let render_config = RenderConfig { 36 | prompt: StyleSheet { 37 | att: Attributes::BOLD, 38 | ..Default::default() 39 | }, 40 | ..Default::default() 41 | }; 42 | let location = project_location::prompt(&render_config)?; 43 | 44 | let features = project_features::prompt(&render_config)?; 45 | 46 | let git = git::prompt(&render_config, &location)?; 47 | 48 | let install_deps = install_deps::prompt(&render_config, package_manager)?; 49 | 50 | Ok(UserInput { 51 | location, 52 | features, 53 | git, 54 | install_deps, 55 | }) 56 | } 57 | 58 | pub fn warn_render_config() -> RenderConfig<'static> { 59 | RenderConfig { 60 | prompt: StyleSheet { 61 | att: Attributes::BOLD, 62 | fg: Some(Color::LightYellow), 63 | ..Default::default() 64 | }, 65 | ..Default::default() 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/input/project_features.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | 3 | use crate::utils::{get_feature_list, Feature, FeatureDetails}; 4 | use anyhow::Result; 5 | use crossterm::style::{style, Stylize}; 6 | use inquire::{ui::RenderConfig, Confirm, Select}; 7 | 8 | pub fn prompt(_render_config: &RenderConfig) -> Result> { 9 | println!( 10 | "{} {}", 11 | style(">").green(), 12 | style("Which features would you like to enable?").bold() 13 | ); 14 | let mut selected_features = HashSet::new(); 15 | let feature_list = get_feature_list(); 16 | 17 | for feature in feature_list.into_iter() { 18 | match feature { 19 | FeatureDetails::Boolean(ref details) => { 20 | let (should_show, default_value) = details.should_show(&selected_features); 21 | if !should_show { 22 | if default_value { 23 | println!( 24 | "{} {} {}", 25 | style(">").green(), 26 | feature, 27 | style("Yes, Required").yellow() 28 | ); 29 | selected_features.insert(details.feature); 30 | } 31 | continue; 32 | } 33 | 34 | let yes = Confirm::new(&format!("{feature}")) 35 | .with_default(default_value) 36 | .prompt()?; 37 | if yes { 38 | selected_features.insert(details.feature); 39 | } 40 | } 41 | FeatureDetails::Option(ref details) => { 42 | let should_show = details.should_show(&selected_features); 43 | if !should_show { 44 | continue; 45 | } 46 | let possible_options = details 47 | .options 48 | .iter() 49 | .filter(|option| option.should_show(&selected_features)) 50 | .collect(); 51 | let option = Select::new(&format!("{feature}"), possible_options).prompt()?; 52 | 53 | if let Some(feature) = option.feature { 54 | selected_features.insert(feature); 55 | } 56 | } 57 | } 58 | } 59 | 60 | Ok(selected_features) 61 | } 62 | -------------------------------------------------------------------------------- /src/input/project_location.rs: -------------------------------------------------------------------------------- 1 | use std::{ffi::OsStr, fs, path::PathBuf}; 2 | 3 | use crate::utils::DEFAULT_NAME; 4 | use anyhow::{bail, Result}; 5 | use dunce::canonicalize; 6 | use inquire::{ui::RenderConfig, validator::Validation, Confirm, Text}; 7 | 8 | use super::warn_render_config; 9 | 10 | #[derive(Debug)] 11 | pub struct ProjectLocation { 12 | pub name: String, 13 | pub path: PathBuf, 14 | } 15 | 16 | fn get_file_name(path: &PathBuf) -> String { 17 | path.file_name() 18 | .unwrap_or(OsStr::new(path)) 19 | .to_string_lossy() 20 | .to_string() 21 | } 22 | 23 | fn get_project_name(input: &str) -> Result { 24 | let input_path = PathBuf::from(input); 25 | 26 | let path = if input_path.exists() { 27 | canonicalize(input_path)? 28 | } else { 29 | fs::create_dir(&input_path)?; 30 | let res = canonicalize(&input_path); 31 | fs::remove_dir(input_path)?; 32 | res? 33 | }; 34 | let file_name = get_file_name(&path); 35 | if path.is_file() { 36 | bail!("{} is a file that exists", file_name); 37 | } 38 | Ok(ProjectLocation { 39 | name: file_name, 40 | path, 41 | }) 42 | } 43 | 44 | pub fn prompt(render_config: &RenderConfig) -> Result { 45 | let name = Text::new("Where should we create your project?") 46 | .with_default(DEFAULT_NAME) 47 | .with_render_config(*render_config) 48 | .with_validator(|text: &str| { 49 | let location = get_project_name(text); 50 | match location { 51 | Ok(location) => { 52 | if location 53 | .name 54 | .chars() 55 | .all(|char| VALID_CHARS.contains(&char)) 56 | { 57 | Ok(Validation::Valid) 58 | } else { 59 | Ok(Validation::Invalid( 60 | format!("Project name ({}) must only contain lowercase alphanumeric characters, dashes, and underscores", location.name).into(), 61 | )) 62 | } 63 | } 64 | Err(err) => Ok(Validation::Invalid(err.into())), 65 | } 66 | }) 67 | .prompt()?; 68 | 69 | let location = get_project_name(&name)?; 70 | if location.path.exists() { 71 | let file_count = location.path.read_dir()?.count(); 72 | if file_count > 0 { 73 | let should_continue = Confirm::new( 74 | "That location is not empty. Would you like to empty it and continue?", 75 | ) 76 | .with_render_config(warn_render_config()) 77 | .with_default(false) 78 | .with_help_message(&format!( 79 | "{} has {} file{}.", 80 | location.path.to_string_lossy(), 81 | file_count, 82 | if file_count == 1 { "" } else { "s" } 83 | )) 84 | .prompt()?; 85 | if !should_continue { 86 | return prompt(render_config); 87 | } 88 | } 89 | } 90 | 91 | Ok(location) 92 | } 93 | 94 | const VALID_CHARS: &[char] = &[ 95 | 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 96 | 't', 'u', 'v', 'w', 'x', 'y', 'z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '-', '_', 97 | ]; 98 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | mod args; 2 | mod cookie; 3 | mod create; 4 | mod input; 5 | mod telemetry; 6 | #[cfg(test)] 7 | mod test; 8 | mod utils; 9 | 10 | use anyhow::Result; 11 | 12 | use crate::create::create; 13 | 14 | fn main() -> Result<()> { 15 | let arguments = args::parse(); 16 | 17 | if let Some(package_manager) = arguments.package_manager { 18 | utils::PACKAGE_MANAGER_OVERRIDE 19 | .lock() 20 | .unwrap() 21 | .replace(package_manager); 22 | } 23 | 24 | if arguments.disable_telemetry { 25 | telemetry::disable(); 26 | std::process::exit(0); 27 | } 28 | 29 | if arguments.enable_telemetry { 30 | telemetry::enable(); 31 | std::process::exit(0); 32 | } 33 | 34 | let input = input::prompt()?; 35 | 36 | telemetry::report((&input).into()); 37 | 38 | create(input)?; 39 | 40 | Ok(()) 41 | } 42 | -------------------------------------------------------------------------------- /src/telemetry.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | cookie::{get_cookie_bool, set_cookie}, 3 | input::UserInput, 4 | utils::{get_package_manager, Feature, PackageManager}, 5 | }; 6 | use crossterm::style::{style, Stylize}; 7 | use serde::Serialize; 8 | 9 | const TELEMETRY_URL: &str = "http://o7-telemetry.ottomated.net/report"; 10 | 11 | pub fn print_initial_warning() { 12 | if get_cookie_bool("telemetry_warned", false) { 13 | // Already warned 14 | return; 15 | } 16 | 17 | let pm = get_package_manager(); 18 | let message = style(format!( 19 | "{}{}{}", 20 | style("Anonymous telemetry is enabled by default, run '").dark_grey(), 21 | style(format!("{pm} create o7-app --disable-telemetry")).dark_green(), 22 | style("' to disable").dark_grey(), 23 | )) 24 | .dark_grey(); 25 | 26 | println!(); 27 | println!("{message}"); 28 | 29 | if let Err(err) = set_cookie("telemetry_warned", "true") { 30 | eprintln!("This message will be displayed again because we couldn't write a file: {err}"); 31 | } 32 | println!(); 33 | } 34 | 35 | pub fn enable() { 36 | set_cookie("telemetry", "true").unwrap(); 37 | 38 | println!(); 39 | println!("Telemetry enabled :)"); 40 | println!(); 41 | } 42 | 43 | pub fn disable() { 44 | set_cookie("telemetry", "false").unwrap(); 45 | 46 | let package_manager = get_package_manager(); 47 | let enable_command = format!("{package_manager} create o7-app --enable-telemetry"); 48 | println!(); 49 | println!("Telemetry disabled :("); 50 | println!("Run '{}' to re-enable", style(enable_command).green()); 51 | println!(); 52 | } 53 | 54 | #[derive(Debug, Serialize)] 55 | pub struct TelemetryReport { 56 | version: String, 57 | package_manager: PackageManager, 58 | install_deps: bool, 59 | git_init: bool, 60 | features: Vec, 61 | } 62 | 63 | impl From<&UserInput> for TelemetryReport { 64 | fn from(input: &UserInput) -> Self { 65 | Self { 66 | version: env!("CARGO_PKG_VERSION").to_string(), 67 | package_manager: get_package_manager(), 68 | install_deps: input.install_deps.is_some(), 69 | git_init: input.git.is_some(), 70 | features: input.features.iter().cloned().collect(), 71 | } 72 | } 73 | } 74 | 75 | pub fn report(report: TelemetryReport) { 76 | if !get_cookie_bool("telemetry", true) { 77 | return; 78 | } 79 | let res = ureq::post(TELEMETRY_URL).send_json(report); 80 | if cfg!(debug_assertions) { 81 | if let Err(err) = res { 82 | eprintln!("{err}\n{err:?}"); 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/test.rs: -------------------------------------------------------------------------------- 1 | use std::cmp::min; 2 | use std::collections::HashSet; 3 | use std::num::NonZeroUsize; 4 | use std::path::PathBuf; 5 | use std::process::{Command, Output}; 6 | use std::sync::atomic::{AtomicU16, Ordering}; 7 | use std::sync::{Arc, RwLock}; 8 | use std::thread::available_parallelism; 9 | use std::time::Duration; 10 | use std::{fs, panic, thread}; 11 | 12 | use itertools::Itertools; 13 | 14 | use crate::utils::{get_feature_list, Feature, FeatureDetails}; 15 | use crate::{ 16 | create::create, 17 | input::{install_deps::ProjectPackageManager, project_location::ProjectLocation, UserInput}, 18 | utils::PackageManager, 19 | }; 20 | 21 | fn make_input(features: HashSet) -> UserInput { 22 | let tmp = tempfile::tempdir().unwrap(); 23 | UserInput { 24 | location: ProjectLocation { 25 | name: "o7-test".to_string(), 26 | path: tmp.path().join("o7-test"), 27 | }, 28 | features, 29 | install_deps: Some(ProjectPackageManager { 30 | package_manager: PackageManager::Pnpm, 31 | package_manager_version: None, 32 | exec_path: which::which("pnpm").unwrap(), 33 | }), 34 | git: None, 35 | } 36 | } 37 | 38 | fn test_pnpm(dir: &PathBuf, args: &[&'static str]) -> Result { 39 | let output = Command::new("pnpm") 40 | .args(args) 41 | .current_dir(dir) 42 | .output() 43 | .unwrap(); 44 | if !output.status.success() { 45 | return Err(format!( 46 | "pnpm {} failed with stdout:\n\n{}\n\nstderr: {}", 47 | args.iter().join(" "), 48 | String::from_utf8_lossy(&output.stdout), 49 | String::from_utf8_lossy(&output.stderr) 50 | )); 51 | } 52 | 53 | Ok(output) 54 | } 55 | 56 | fn generate_combinations(features: Vec) -> Vec> { 57 | let mut last_step = vec![HashSet::new()]; 58 | 59 | for i in 0..features.len() { 60 | let feature_details = features.get(i).unwrap(); 61 | let mut next = vec![]; 62 | for past in last_step { 63 | match feature_details { 64 | FeatureDetails::Boolean(details) => { 65 | let (show, value) = details.should_show(&past); 66 | if !show { 67 | let mut set = past.clone(); 68 | if value { 69 | set.insert(details.feature); 70 | } 71 | next.push(set); 72 | } else { 73 | let mut yes = past.clone(); 74 | yes.insert(details.feature); 75 | let no = past; 76 | next.push(yes); 77 | next.push(no); 78 | } 79 | } 80 | FeatureDetails::Option(details) => { 81 | let show = details.should_show(&past); 82 | if !show { 83 | next.push(past); 84 | continue; 85 | } 86 | for option in details.options.iter() { 87 | if !option.should_show(&past) { 88 | continue; 89 | } 90 | let mut set = past.clone(); 91 | if let Some(feature) = option.feature { 92 | set.insert(feature); 93 | } 94 | next.push(set); 95 | } 96 | } 97 | } 98 | } 99 | last_step = next; 100 | } 101 | last_step 102 | } 103 | 104 | fn create_shard(combinations: Vec>) -> Vec> { 105 | let shard_index = std::env::var("SHARD") 106 | .ok() 107 | .and_then(|s| s.parse::().ok()); 108 | let shard_count = std::env::var("SHARD_COUNT") 109 | .ok() 110 | .and_then(|s| s.parse::().ok()); 111 | 112 | if shard_index.is_none() || shard_count.is_none() { 113 | return combinations; 114 | } 115 | let shard_index = shard_index.unwrap(); 116 | let shard_count = shard_count.unwrap(); 117 | 118 | let mut chunk = vec![]; 119 | for (i, item) in combinations.into_iter().enumerate() { 120 | let index = i % shard_count; 121 | if index == shard_index { 122 | chunk.push(item); 123 | } 124 | } 125 | chunk 126 | } 127 | 128 | #[test] 129 | fn test() { 130 | let combinations = generate_combinations(get_feature_list()); 131 | let mut combinations = create_shard(combinations); 132 | 133 | // let mut combinations = vec![HashSet::new()]; 134 | // combinations[0].insert(Feature::Edge); 135 | // combinations[0].insert(Feature::D1); 136 | // combinations[0].insert(Feature::Trpc); 137 | 138 | let num_threads = min( 139 | { 140 | let possible = 141 | usize::from(available_parallelism().unwrap_or(NonZeroUsize::new(1).unwrap())); 142 | 143 | if std::env::var("CI").is_ok() { 144 | if possible == 1 { 145 | 1usize 146 | } else { 147 | possible - 1 148 | } 149 | } else { 150 | possible / 2 151 | } 152 | }, 153 | combinations.len(), 154 | ); 155 | 156 | println!( 157 | "Testing {} combinations on {num_threads} threads", 158 | combinations.len() 159 | ); 160 | 161 | let mut chunks = vec![vec![]; num_threads]; 162 | while !combinations.is_empty() { 163 | #[allow(clippy::needless_range_loop)] 164 | for i in 0..num_threads { 165 | let Some(c) = combinations.pop() else { 166 | break; 167 | }; 168 | chunks[i].push(c); 169 | } 170 | } 171 | 172 | thread::scope(|s| { 173 | let errors = Arc::new(RwLock::new(vec![])); 174 | let thread_count = Arc::new(AtomicU16::new(0)); 175 | 176 | for chunk in chunks { 177 | thread_count.fetch_add(1, Ordering::SeqCst); 178 | let errors = Arc::clone(&errors); 179 | let thread_count = Arc::clone(&thread_count); 180 | 181 | s.spawn(move || { 182 | for features in chunk { 183 | let features_debug = features.clone(); 184 | let input = make_input(features); 185 | let dir = input.location.path.clone(); 186 | let result: Result<(), String> = (|| { 187 | create(input).map_err(|e| format!("{e}"))?; 188 | // Build first so sveltekit generates its tsconfig 189 | test_pnpm(&dir, &["build"])?; 190 | test_pnpm(&dir, &["eslint", "--max-warnings", "0", "."])?; 191 | test_pnpm(&dir, &["svelte-check"])?; 192 | 193 | Ok(()) 194 | })(); 195 | if let Err(e) = result { 196 | errors.write().unwrap().push((features_debug, e)); 197 | } else { 198 | let _ = fs::remove_dir_all(&dir); 199 | } 200 | } 201 | thread_count.fetch_sub(1, Ordering::SeqCst); 202 | }); 203 | } 204 | while thread_count.load(Ordering::SeqCst) > 0 { 205 | thread::sleep(Duration::from_millis(1)); 206 | } 207 | let errors = errors.read().unwrap(); 208 | if !errors.is_empty() { 209 | panic!( 210 | "{} errors occurred:\n\n{}", 211 | errors.len(), 212 | errors 213 | .iter() 214 | .map(|(features, e)| { 215 | format!("Error with features {:?}:\n\n{}\n\n", features, e) 216 | }) 217 | .join("\n") 218 | ); 219 | } 220 | }); 221 | } 222 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | use std::collections::{BTreeMap, HashMap}; 2 | use std::env; 3 | use std::fmt::Display; 4 | use std::sync::Mutex; 5 | 6 | use once_cell::sync::Lazy; 7 | use serde::{Serialize, Serializer}; 8 | 9 | include!(concat!(env!("OUT_DIR"), "/config.rs")); 10 | 11 | #[derive(Debug, Clone, Eq, PartialEq, clap::ValueEnum, serde::Serialize)] 12 | pub enum PackageManager { 13 | Npm, 14 | Pnpm, 15 | Yarn, 16 | Bun, 17 | } 18 | 19 | impl PackageManager { 20 | pub fn run_script(&self, script: &str) -> String { 21 | match self { 22 | PackageManager::Npm => format!("npm run {script}"), 23 | PackageManager::Pnpm => format!("pnpm {script}"), 24 | PackageManager::Yarn => format!("yarn {script}"), 25 | PackageManager::Bun => format!("bun run {script}"), 26 | } 27 | } 28 | pub fn to_feature(&self) -> Feature { 29 | match self { 30 | PackageManager::Npm => Feature::Npm, 31 | PackageManager::Pnpm => Feature::Pnpm, 32 | PackageManager::Yarn => Feature::Yarn, 33 | PackageManager::Bun => Feature::Bun, 34 | } 35 | } 36 | } 37 | 38 | impl Display for PackageManager { 39 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 40 | match self { 41 | PackageManager::Npm => write!(f, "npm"), 42 | PackageManager::Pnpm => write!(f, "pnpm"), 43 | PackageManager::Yarn => write!(f, "yarn"), 44 | PackageManager::Bun => write!(f, "bun"), 45 | } 46 | } 47 | } 48 | 49 | pub static PACKAGE_MANAGER_OVERRIDE: Lazy>> = 50 | Lazy::new(|| Mutex::new(None)); 51 | 52 | pub fn get_package_manager() -> PackageManager { 53 | if let Some(package_manager) = PACKAGE_MANAGER_OVERRIDE.lock().unwrap().as_ref() { 54 | return package_manager.clone(); 55 | } 56 | // This environment variable is set by npm and yarn but pnpm seems less consistent 57 | let user_agent = env::var("npm_config_user_agent"); 58 | 59 | if user_agent.is_err() { 60 | return PackageManager::Npm; 61 | } 62 | let user_agent = user_agent.unwrap().to_lowercase(); 63 | 64 | if user_agent.starts_with("yarn") { 65 | PackageManager::Yarn 66 | } else if user_agent.starts_with("pnpm") { 67 | PackageManager::Pnpm 68 | } else if user_agent.starts_with("bun") { 69 | PackageManager::Bun 70 | } else { 71 | PackageManager::Npm 72 | } 73 | } 74 | 75 | #[derive(serde::Serialize, Debug)] 76 | #[serde(rename_all = "camelCase")] 77 | pub struct PackageJsonPartial<'a> { 78 | pub name: Option<&'a str>, 79 | pub version: Option<&'a str>, 80 | pub r#type: Option<&'a str>, 81 | pub scripts: Option>>, 82 | #[serde(skip_serializing_if = "Option::is_none")] 83 | pub workspaces: Option>, 84 | #[serde(serialize_with = "sorted_map", skip_serializing_if = "skip_if_empty")] 85 | pub dependencies: Option>>, 86 | #[serde(serialize_with = "sorted_map", skip_serializing_if = "skip_if_empty")] 87 | pub dev_dependencies: Option>>, 88 | pub package_manager: Option, 89 | #[serde(skip_serializing_if = "Option::is_none")] 90 | pub pnpm: Option>, 91 | } 92 | 93 | #[derive(serde::Serialize, Debug)] 94 | #[serde(rename_all = "camelCase")] 95 | pub struct PnpmPackageJson<'a> { 96 | pub only_built_dependencies: Option>, 97 | } 98 | 99 | pub fn skip_if_empty(map: &Option>>) -> bool { 100 | match map { 101 | Some(map) => map.is_empty(), 102 | None => true, 103 | } 104 | } 105 | 106 | fn sorted_map( 107 | map: &Option>>, 108 | serializer: S, 109 | ) -> Result { 110 | // SAFETY: this should be skipped if empty already 111 | let map = map.as_ref().unwrap(); 112 | let mut items: Vec<_> = map.iter().collect(); 113 | items.sort_by(|a, b| a.0.cmp(b.0)); 114 | BTreeMap::from_iter(items).serialize(serializer) 115 | } 116 | 117 | impl<'a> PackageJsonPartial<'a> { 118 | pub fn merge(&mut self, other: PackageJsonPartial<'a>) { 119 | if other.name.is_some() { 120 | self.name = other.name; 121 | } 122 | if other.version.is_some() { 123 | self.version = other.version; 124 | } 125 | if other.r#type.is_some() { 126 | self.r#type = other.r#type; 127 | } 128 | if other.workspaces.is_some() { 129 | self.workspaces = other.workspaces; 130 | } 131 | if other.package_manager.is_some() { 132 | self.package_manager = other.package_manager; 133 | } 134 | merge_hashmaps(&mut self.scripts, other.scripts); 135 | merge_hashmaps(&mut self.dependencies, other.dependencies); 136 | merge_hashmaps(&mut self.dev_dependencies, other.dev_dependencies); 137 | 138 | match (&mut self.pnpm, other.pnpm) { 139 | (_, None) => {} 140 | (None, Some(pnpm)) => { 141 | self.pnpm = Some(pnpm); 142 | } 143 | (Some(old), Some(new)) => { 144 | match ( 145 | &mut old.only_built_dependencies, 146 | new.only_built_dependencies, 147 | ) { 148 | (_, None) => {} 149 | (None, Some(new)) => { 150 | old.only_built_dependencies = Some(new); 151 | } 152 | (Some(ref mut old), Some(new)) => { 153 | old.extend(new); 154 | } 155 | } 156 | } 157 | } 158 | } 159 | } 160 | 161 | fn merge_hashmaps<'a>( 162 | old: &mut Option>>, 163 | new: Option>>, 164 | ) { 165 | if old.is_none() { 166 | *old = Some(HashMap::new()); 167 | } 168 | let old = old.as_mut().unwrap(); 169 | if let Some(new) = new { 170 | for (key, value) in new { 171 | old.insert(key, value); 172 | } 173 | } 174 | old.retain(|_, v| v.is_some()); 175 | } 176 | -------------------------------------------------------------------------------- /telemetry-server/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | 3 | logs 4 | _.log 5 | npm-debug.log_ 6 | yarn-debug.log* 7 | yarn-error.log* 8 | lerna-debug.log* 9 | .pnpm-debug.log* 10 | 11 | # Diagnostic reports (https://nodejs.org/api/report.html) 12 | 13 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 14 | 15 | # Runtime data 16 | 17 | pids 18 | _.pid 19 | _.seed 20 | \*.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | 24 | lib-cov 25 | 26 | # Coverage directory used by tools like istanbul 27 | 28 | coverage 29 | \*.lcov 30 | 31 | # nyc test coverage 32 | 33 | .nyc_output 34 | 35 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 36 | 37 | .grunt 38 | 39 | # Bower dependency directory (https://bower.io/) 40 | 41 | bower_components 42 | 43 | # node-waf configuration 44 | 45 | .lock-wscript 46 | 47 | # Compiled binary addons (https://nodejs.org/api/addons.html) 48 | 49 | build/Release 50 | 51 | # Dependency directories 52 | 53 | node_modules/ 54 | jspm_packages/ 55 | 56 | # Snowpack dependency directory (https://snowpack.dev/) 57 | 58 | web_modules/ 59 | 60 | # TypeScript cache 61 | 62 | \*.tsbuildinfo 63 | 64 | # Optional npm cache directory 65 | 66 | .npm 67 | 68 | # Optional eslint cache 69 | 70 | .eslintcache 71 | 72 | # Optional stylelint cache 73 | 74 | .stylelintcache 75 | 76 | # Microbundle cache 77 | 78 | .rpt2_cache/ 79 | .rts2_cache_cjs/ 80 | .rts2_cache_es/ 81 | .rts2_cache_umd/ 82 | 83 | # Optional REPL history 84 | 85 | .node_repl_history 86 | 87 | # Output of 'npm pack' 88 | 89 | \*.tgz 90 | 91 | # Yarn Integrity file 92 | 93 | .yarn-integrity 94 | 95 | # dotenv environment variable files 96 | 97 | .env 98 | .env.development.local 99 | .env.test.local 100 | .env.production.local 101 | .env.local 102 | 103 | # parcel-bundler cache (https://parceljs.org/) 104 | 105 | .cache 106 | .parcel-cache 107 | 108 | # Next.js build output 109 | 110 | .next 111 | out 112 | 113 | # Nuxt.js build / generate output 114 | 115 | .nuxt 116 | dist 117 | 118 | # Gatsby files 119 | 120 | .cache/ 121 | 122 | # Comment in the public line in if your project uses Gatsby and not Next.js 123 | 124 | # https://nextjs.org/blog/next-9-1#public-directory-support 125 | 126 | # public 127 | 128 | # vuepress build output 129 | 130 | .vuepress/dist 131 | 132 | # vuepress v2.x temp and cache directory 133 | 134 | .temp 135 | .cache 136 | 137 | # Docusaurus cache and generated files 138 | 139 | .docusaurus 140 | 141 | # Serverless directories 142 | 143 | .serverless/ 144 | 145 | # FuseBox cache 146 | 147 | .fusebox/ 148 | 149 | # DynamoDB Local files 150 | 151 | .dynamodb/ 152 | 153 | # TernJS port file 154 | 155 | .tern-port 156 | 157 | # Stores VSCode versions used for testing VSCode extensions 158 | 159 | .vscode-test 160 | 161 | # yarn v2 162 | 163 | .yarn/cache 164 | .yarn/unplugged 165 | .yarn/build-state.yml 166 | .yarn/install-state.gz 167 | .pnp.\* 168 | 169 | # wrangler project 170 | 171 | .dev.vars 172 | .wrangler/ 173 | worker-configuration.d.ts 174 | -------------------------------------------------------------------------------- /telemetry-server/db.sql: -------------------------------------------------------------------------------- 1 | -- SQLITE 2 | CREATE TABLE telemetry ( 3 | id INTEGER PRIMARY KEY AUTOINCREMENT, 4 | version TEXT NOT NULL, 5 | package_manager TEXT NOT NULL, 6 | features TEXT NOT NULL, 7 | install_deps BOOLEAN NOT NULL, 8 | git_init BOOLEAN NOT NULL, 9 | created_at DATETIME NOT NULL 10 | ) 11 | -------------------------------------------------------------------------------- /telemetry-server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "create-o7-app-telemetry-server", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "dev": "wrangler dev", 8 | "deploy": "wrangler deploy", 9 | "cf-typegen": "wrangler types" 10 | }, 11 | "devDependencies": { 12 | "typescript": "^5.8.3", 13 | "wrangler": "^4.16.1" 14 | }, 15 | "dependencies": { 16 | "zod": "^3.25.28" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /telemetry-server/src/worker.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod/v4'; 2 | 3 | const telemetrySchema = z.object({ 4 | version: z 5 | .string() 6 | // Semver regex from https://semver.org 7 | .regex( 8 | /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/, 9 | ), 10 | package_manager: z.enum(['Npm', 'Pnpm', 'Yarn', 'Bun']), 11 | install_deps: z.boolean(), 12 | git_init: z.boolean(), 13 | features: z.array(z.string()), 14 | }); 15 | 16 | export default { 17 | async fetch(request, env, ctx): Promise { 18 | if (request.method !== 'POST') 19 | return new Response('Method not allowed', { status: 405 }); 20 | 21 | const url = new URL(request.url); 22 | if (url.pathname !== '/report') 23 | return new Response('Not found', { status: 404 }); 24 | 25 | const body = await request.json().catch(() => null); 26 | if (!body) { 27 | return new Response('Bad request', { status: 400 }); 28 | } 29 | 30 | const telemetry = telemetrySchema.safeParse(body); 31 | 32 | if (!telemetry.success) { 33 | return new Response('Bad request', { status: 400 }); 34 | } 35 | 36 | ctx.waitUntil( 37 | env.DB.prepare( 38 | 'INSERT INTO telemetry (version, package_manager, install_deps, git_init, features, created_at) VALUES (?, ?, ?, ?, ?, ?)', 39 | ) 40 | .bind( 41 | telemetry.data.version, 42 | telemetry.data.package_manager, 43 | telemetry.data.install_deps, 44 | telemetry.data.git_init, 45 | JSON.stringify(telemetry.data.features), 46 | new Date().toISOString(), 47 | ) 48 | .run(), 49 | ); 50 | 51 | return new Response('OK'); 52 | }, 53 | } satisfies ExportedHandler; 54 | -------------------------------------------------------------------------------- /telemetry-server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2021", 4 | "lib": ["es2021"], 5 | "module": "es2022", 6 | "moduleResolution": "Bundler", 7 | "resolveJsonModule": true, 8 | "allowJs": true, 9 | "checkJs": false, 10 | "noEmit": true, 11 | "isolatedModules": true, 12 | "allowSyntheticDefaultImports": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "strict": true, 15 | "noUncheckedIndexedAccess": true, 16 | "skipLibCheck": true 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /telemetry-server/wrangler.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "node_modules/wrangler/config-schema.json", 3 | "name": "create-o7-app-telemetry-server", 4 | "main": "src/worker.ts", 5 | "compatibility_date": "2025-05-25", 6 | "routes": [ 7 | { 8 | "pattern": "o7-telemetry.ottomated.net", 9 | "custom_domain": true 10 | } 11 | ], 12 | "d1_databases": [ 13 | { 14 | "binding": "DB", 15 | "database_name": "create-o7-app-telemetry", 16 | "database_id": "02c3355b-e276-4f18-98ab-44a61cef7e07" 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /template_builder/Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "anyhow" 7 | version = "1.0.86" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" 10 | 11 | [[package]] 12 | name = "bitflags" 13 | version = "2.5.0" 14 | source = "registry+https://github.com/rust-lang/crates.io-index" 15 | checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" 16 | 17 | [[package]] 18 | name = "either" 19 | version = "1.12.0" 20 | source = "registry+https://github.com/rust-lang/crates.io-index" 21 | checksum = "3dca9240753cf90908d7e4aac30f630662b02aebaa1b58a3cadabdb23385b58b" 22 | 23 | [[package]] 24 | name = "errno" 25 | version = "0.3.9" 26 | source = "registry+https://github.com/rust-lang/crates.io-index" 27 | checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" 28 | dependencies = [ 29 | "libc", 30 | "windows-sys", 31 | ] 32 | 33 | [[package]] 34 | name = "home" 35 | version = "0.5.9" 36 | source = "registry+https://github.com/rust-lang/crates.io-index" 37 | checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5" 38 | dependencies = [ 39 | "windows-sys", 40 | ] 41 | 42 | [[package]] 43 | name = "itertools" 44 | version = "0.13.0" 45 | source = "registry+https://github.com/rust-lang/crates.io-index" 46 | checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" 47 | dependencies = [ 48 | "either", 49 | ] 50 | 51 | [[package]] 52 | name = "itoa" 53 | version = "1.0.11" 54 | source = "registry+https://github.com/rust-lang/crates.io-index" 55 | checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" 56 | 57 | [[package]] 58 | name = "libc" 59 | version = "0.2.155" 60 | source = "registry+https://github.com/rust-lang/crates.io-index" 61 | checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" 62 | 63 | [[package]] 64 | name = "linux-raw-sys" 65 | version = "0.4.14" 66 | source = "registry+https://github.com/rust-lang/crates.io-index" 67 | checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" 68 | 69 | [[package]] 70 | name = "proc-macro2" 71 | version = "1.0.85" 72 | source = "registry+https://github.com/rust-lang/crates.io-index" 73 | checksum = "22244ce15aa966053a896d1accb3a6e68469b97c7f33f284b99f0d576879fc23" 74 | dependencies = [ 75 | "unicode-ident", 76 | ] 77 | 78 | [[package]] 79 | name = "quote" 80 | version = "1.0.36" 81 | source = "registry+https://github.com/rust-lang/crates.io-index" 82 | checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" 83 | dependencies = [ 84 | "proc-macro2", 85 | ] 86 | 87 | [[package]] 88 | name = "rustix" 89 | version = "0.38.34" 90 | source = "registry+https://github.com/rust-lang/crates.io-index" 91 | checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" 92 | dependencies = [ 93 | "bitflags", 94 | "errno", 95 | "libc", 96 | "linux-raw-sys", 97 | "windows-sys", 98 | ] 99 | 100 | [[package]] 101 | name = "ryu" 102 | version = "1.0.18" 103 | source = "registry+https://github.com/rust-lang/crates.io-index" 104 | checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" 105 | 106 | [[package]] 107 | name = "same-file" 108 | version = "1.0.6" 109 | source = "registry+https://github.com/rust-lang/crates.io-index" 110 | checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" 111 | dependencies = [ 112 | "winapi-util", 113 | ] 114 | 115 | [[package]] 116 | name = "serde" 117 | version = "1.0.203" 118 | source = "registry+https://github.com/rust-lang/crates.io-index" 119 | checksum = "7253ab4de971e72fb7be983802300c30b5a7f0c2e56fab8abfc6a214307c0094" 120 | dependencies = [ 121 | "serde_derive", 122 | ] 123 | 124 | [[package]] 125 | name = "serde_derive" 126 | version = "1.0.203" 127 | source = "registry+https://github.com/rust-lang/crates.io-index" 128 | checksum = "500cbc0ebeb6f46627f50f3f5811ccf6bf00643be300b4c3eabc0ef55dc5b5ba" 129 | dependencies = [ 130 | "proc-macro2", 131 | "quote", 132 | "syn", 133 | ] 134 | 135 | [[package]] 136 | name = "serde_json" 137 | version = "1.0.117" 138 | source = "registry+https://github.com/rust-lang/crates.io-index" 139 | checksum = "455182ea6142b14f93f4bc5320a2b31c1f266b66a4a5c858b013302a5d8cbfc3" 140 | dependencies = [ 141 | "itoa", 142 | "ryu", 143 | "serde", 144 | ] 145 | 146 | [[package]] 147 | name = "syn" 148 | version = "2.0.66" 149 | source = "registry+https://github.com/rust-lang/crates.io-index" 150 | checksum = "c42f3f41a2de00b01c0aaad383c5a45241efc8b2d1eda5661812fda5f3cdcff5" 151 | dependencies = [ 152 | "proc-macro2", 153 | "quote", 154 | "unicode-ident", 155 | ] 156 | 157 | [[package]] 158 | name = "template_builder" 159 | version = "0.1.0" 160 | dependencies = [ 161 | "anyhow", 162 | "itertools", 163 | "proc-macro2", 164 | "quote", 165 | "serde", 166 | "serde_json", 167 | "walkdir", 168 | "which", 169 | ] 170 | 171 | [[package]] 172 | name = "unicode-ident" 173 | version = "1.0.12" 174 | source = "registry+https://github.com/rust-lang/crates.io-index" 175 | checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" 176 | 177 | [[package]] 178 | name = "walkdir" 179 | version = "2.5.0" 180 | source = "registry+https://github.com/rust-lang/crates.io-index" 181 | checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" 182 | dependencies = [ 183 | "same-file", 184 | "winapi-util", 185 | ] 186 | 187 | [[package]] 188 | name = "which" 189 | version = "6.0.1" 190 | source = "registry+https://github.com/rust-lang/crates.io-index" 191 | checksum = "8211e4f58a2b2805adfbefbc07bab82958fc91e3836339b1ab7ae32465dce0d7" 192 | dependencies = [ 193 | "either", 194 | "home", 195 | "rustix", 196 | "winsafe", 197 | ] 198 | 199 | [[package]] 200 | name = "winapi-util" 201 | version = "0.1.8" 202 | source = "registry+https://github.com/rust-lang/crates.io-index" 203 | checksum = "4d4cc384e1e73b93bafa6fb4f1df8c41695c8a91cf9c4c64358067d15a7b6c6b" 204 | dependencies = [ 205 | "windows-sys", 206 | ] 207 | 208 | [[package]] 209 | name = "windows-sys" 210 | version = "0.52.0" 211 | source = "registry+https://github.com/rust-lang/crates.io-index" 212 | checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 213 | dependencies = [ 214 | "windows-targets", 215 | ] 216 | 217 | [[package]] 218 | name = "windows-targets" 219 | version = "0.52.5" 220 | source = "registry+https://github.com/rust-lang/crates.io-index" 221 | checksum = "6f0713a46559409d202e70e28227288446bf7841d3211583a4b53e3f6d96e7eb" 222 | dependencies = [ 223 | "windows_aarch64_gnullvm", 224 | "windows_aarch64_msvc", 225 | "windows_i686_gnu", 226 | "windows_i686_gnullvm", 227 | "windows_i686_msvc", 228 | "windows_x86_64_gnu", 229 | "windows_x86_64_gnullvm", 230 | "windows_x86_64_msvc", 231 | ] 232 | 233 | [[package]] 234 | name = "windows_aarch64_gnullvm" 235 | version = "0.52.5" 236 | source = "registry+https://github.com/rust-lang/crates.io-index" 237 | checksum = "7088eed71e8b8dda258ecc8bac5fb1153c5cffaf2578fc8ff5d61e23578d3263" 238 | 239 | [[package]] 240 | name = "windows_aarch64_msvc" 241 | version = "0.52.5" 242 | source = "registry+https://github.com/rust-lang/crates.io-index" 243 | checksum = "9985fd1504e250c615ca5f281c3f7a6da76213ebd5ccc9561496568a2752afb6" 244 | 245 | [[package]] 246 | name = "windows_i686_gnu" 247 | version = "0.52.5" 248 | source = "registry+https://github.com/rust-lang/crates.io-index" 249 | checksum = "88ba073cf16d5372720ec942a8ccbf61626074c6d4dd2e745299726ce8b89670" 250 | 251 | [[package]] 252 | name = "windows_i686_gnullvm" 253 | version = "0.52.5" 254 | source = "registry+https://github.com/rust-lang/crates.io-index" 255 | checksum = "87f4261229030a858f36b459e748ae97545d6f1ec60e5e0d6a3d32e0dc232ee9" 256 | 257 | [[package]] 258 | name = "windows_i686_msvc" 259 | version = "0.52.5" 260 | source = "registry+https://github.com/rust-lang/crates.io-index" 261 | checksum = "db3c2bf3d13d5b658be73463284eaf12830ac9a26a90c717b7f771dfe97487bf" 262 | 263 | [[package]] 264 | name = "windows_x86_64_gnu" 265 | version = "0.52.5" 266 | source = "registry+https://github.com/rust-lang/crates.io-index" 267 | checksum = "4e4246f76bdeff09eb48875a0fd3e2af6aada79d409d33011886d3e1581517d9" 268 | 269 | [[package]] 270 | name = "windows_x86_64_gnullvm" 271 | version = "0.52.5" 272 | source = "registry+https://github.com/rust-lang/crates.io-index" 273 | checksum = "852298e482cd67c356ddd9570386e2862b5673c85bd5f88df9ab6802b334c596" 274 | 275 | [[package]] 276 | name = "windows_x86_64_msvc" 277 | version = "0.52.5" 278 | source = "registry+https://github.com/rust-lang/crates.io-index" 279 | checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0" 280 | 281 | [[package]] 282 | name = "winsafe" 283 | version = "0.0.19" 284 | source = "registry+https://github.com/rust-lang/crates.io-index" 285 | checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" 286 | -------------------------------------------------------------------------------- /template_builder/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "template_builder" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | proc-macro2 = "1.0.85" 8 | anyhow = "1.0.86" 9 | quote = "1.0.36" 10 | which = "6.0.1" 11 | walkdir = "2.5.0" 12 | serde = { version = "1.0.203", features = ["derive"] } 13 | serde_json = "1.0.117" 14 | itertools = "0.13.0" 15 | -------------------------------------------------------------------------------- /template_builder/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod utils; 2 | 3 | use anyhow::{Context, Result}; 4 | use proc_macro2::{Literal, TokenStream}; 5 | use quote::{format_ident, quote}; 6 | use std::collections::HashSet; 7 | use std::env::{self, current_dir}; 8 | use std::path::{Path, PathBuf}; 9 | use std::process::Command; 10 | use utils::{parse_feature_file, walk_dir, PackageJsonPartial}; 11 | use which::which; 12 | 13 | pub struct Builder { 14 | out_dir: PathBuf, 15 | rustfmt: Option, 16 | } 17 | 18 | #[derive(Debug)] 19 | struct TemplateFile { 20 | features: HashSet, 21 | is_delete: bool, 22 | path: PathBuf, 23 | contents: Vec, 24 | } 25 | 26 | #[derive(serde::Deserialize)] 27 | struct Config { 28 | default_name: String, 29 | initial_commit: String, 30 | features: Vec, 31 | } 32 | 33 | #[derive(serde::Deserialize)] 34 | struct ConfigFeature { 35 | id: String, 36 | name: String, 37 | description: String, 38 | default: Option, 39 | options: Option>, 40 | // Hide this option and auto-enable it if any of the given features are selected 41 | required_if: Option>, 42 | // Hide this option if any of the given features are selected 43 | hidden_if: Option>, 44 | // Hide this option if any of the given features are not selected 45 | hidden_if_not: Option>, 46 | } 47 | 48 | #[derive(serde::Deserialize)] 49 | struct ConfigFeatureOption { 50 | id: Option, 51 | name: String, 52 | // Hide this option if any of the given features are selected 53 | hidden_if: Option>, 54 | // Hide this option if any of the given features are not selected 55 | hidden_if_not: Option>, 56 | } 57 | 58 | fn generate_hidden_set(features: &Option>) -> TokenStream { 59 | match features { 60 | Some(features) => { 61 | let features = features 62 | .iter() 63 | .map(|f| { 64 | let id = format_ident!("{}", f); 65 | quote! { Feature::#id } 66 | }) 67 | .collect::>(); 68 | quote! { Some(vec![#(#features),*]) } 69 | } 70 | None => quote! { None }, 71 | } 72 | } 73 | 74 | impl TemplateFile { 75 | fn feature_count(&self) -> usize { 76 | self.features.len() 77 | } 78 | } 79 | 80 | impl Default for Builder { 81 | fn default() -> Self { 82 | let out_dir = Path::new(&env::var("OUT_DIR").expect("OUT_DIR not set")).to_path_buf(); 83 | let rustfmt = which("rustfmt").ok(); 84 | 85 | Self { out_dir, rustfmt } 86 | } 87 | } 88 | 89 | impl Builder { 90 | pub fn build(&self) -> Result<()> { 91 | let templates = self.load_templates()?; 92 | let config = self.load_config()?; 93 | // println!("cargo:warning={:?}", templates); 94 | self.write_file("templates.rs", self.make_templates_file(&templates))?; 95 | self.write_file("config.rs", self.make_config_file(config))?; 96 | 97 | Ok(()) 98 | } 99 | 100 | fn make_config_file(&self, config: Config) -> TokenStream { 101 | let default_name = format!("./{}", config.default_name); 102 | let initial_commit = config.initial_commit; 103 | 104 | let features = config 105 | .features 106 | .iter() 107 | .flat_map(|feature| match &feature.options { 108 | Some(options) => options 109 | .iter() 110 | .filter_map(|o| o.id.as_ref().map(|id| format_ident!("{}", id))) 111 | .collect(), 112 | None => vec![format_ident!("{}", feature.id)], 113 | }); 114 | 115 | let unique_descriptions = config 116 | .features 117 | .iter() 118 | .map(|feature| feature.description.clone()) 119 | .collect::>(); 120 | if unique_descriptions.len() != config.features.len() { 121 | panic!("Duplicate feature descriptions"); 122 | } 123 | 124 | let details_list = config 125 | .features 126 | .iter() 127 | .map(|feature| { 128 | let description = feature.description.clone(); 129 | let name = feature.name.clone(); 130 | 131 | let hidden_if = generate_hidden_set(&feature.hidden_if); 132 | let hidden_if_not = generate_hidden_set(&feature.hidden_if_not); 133 | 134 | if let Some(options) = &feature.options { 135 | let options = options 136 | .iter() 137 | .map(|option| { 138 | let feature = if let Some(id) = &option.id { 139 | let id = format_ident!("{}", id); 140 | quote! { Some(Feature::#id) } 141 | } else { 142 | quote! { None } 143 | }; 144 | let name = option.name.clone(); 145 | let hidden_if = generate_hidden_set(&option.hidden_if); 146 | let hidden_if_not = generate_hidden_set(&option.hidden_if_not); 147 | quote! { 148 | FeatureOption { 149 | feature: #feature, 150 | name: #name, 151 | hidden_if: #hidden_if, 152 | hidden_if_not: #hidden_if_not, 153 | } 154 | } 155 | }) 156 | .collect::>(); 157 | 158 | quote! { 159 | FeatureDetails::Option(OptionFeatureDetails { 160 | name: #name, 161 | description: #description, 162 | options: vec![ 163 | #(#options),* 164 | ], 165 | hidden_if: #hidden_if, 166 | hidden_if_not: #hidden_if_not, 167 | }) 168 | } 169 | } else { 170 | let id = format_ident!("{}", feature.id); 171 | let default = feature.default.unwrap_or(false); 172 | let required_if = generate_hidden_set(&feature.required_if); 173 | quote! { 174 | FeatureDetails::Boolean(BooleanFeatureDetails { 175 | feature: Feature::#id, 176 | name: #name, 177 | description: #description, 178 | default: #default, 179 | required_if: #required_if, 180 | hidden_if: #hidden_if, 181 | hidden_if_not: #hidden_if_not, 182 | }) 183 | } 184 | } 185 | }) 186 | .collect::>(); 187 | 188 | quote! { 189 | use std::collections::HashSet; 190 | 191 | pub const DEFAULT_NAME: &str = #default_name; 192 | pub const INITIAL_COMMIT: &str = #initial_commit; 193 | 194 | #[derive(Debug, Hash, Eq, PartialEq, Copy, Clone, serde::Serialize)] 195 | pub enum Feature { 196 | Npm, 197 | Pnpm, 198 | Yarn, 199 | Bun, 200 | #(#features),* 201 | } 202 | 203 | pub enum FeatureDetails { 204 | Boolean(BooleanFeatureDetails), 205 | Option(OptionFeatureDetails), 206 | } 207 | 208 | pub struct OptionFeatureDetails { 209 | pub name: &'static str, 210 | pub description: &'static str, 211 | pub options: Vec, 212 | hidden_if: Option>, 213 | hidden_if_not: Option>, 214 | } 215 | 216 | pub struct FeatureOption { 217 | pub feature: Option, 218 | pub name: &'static str, 219 | hidden_if: Option>, 220 | hidden_if_not: Option>, 221 | } 222 | 223 | impl Display for FeatureOption { 224 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 225 | write!(f, "{}", self.name) 226 | } 227 | } 228 | 229 | impl OptionFeatureDetails { 230 | pub fn should_show(&self, features: &HashSet) -> bool { 231 | if let Some(hidden_if) = &self.hidden_if { 232 | if hidden_if.iter().any(|f| features.contains(f)) { 233 | return false; 234 | } 235 | } 236 | if let Some(hidden_if_not) = &self.hidden_if_not { 237 | if hidden_if_not.iter().all(|f| !features.contains(f)) { 238 | return false; 239 | } 240 | } 241 | true 242 | } 243 | } 244 | 245 | impl FeatureOption { 246 | pub fn should_show(&self, features: &HashSet) -> bool { 247 | if let Some(hidden_if) = &self.hidden_if { 248 | if hidden_if.iter().any(|f| features.contains(f)) { 249 | return false; 250 | } 251 | } 252 | if let Some(hidden_if_not) = &self.hidden_if_not { 253 | if hidden_if_not.iter().all(|f| !features.contains(f)) { 254 | return false; 255 | } 256 | } 257 | true 258 | } 259 | } 260 | 261 | pub struct BooleanFeatureDetails { 262 | pub feature: Feature, 263 | pub name: &'static str, 264 | pub description: &'static str, 265 | pub default: bool, 266 | pub required_if: Option>, 267 | hidden_if: Option>, 268 | hidden_if_not: Option>, 269 | } 270 | 271 | impl BooleanFeatureDetails { 272 | // Returns (show, default) 273 | pub fn should_show(&self, features: &HashSet) -> (bool, bool) { 274 | if let Some(required_if) = &self.required_if { 275 | if required_if.iter().any(|f| features.contains(f)) { 276 | return (false, true); 277 | } 278 | } 279 | if let Some(hidden_if) = &self.hidden_if { 280 | if hidden_if.iter().any(|f| features.contains(f)) { 281 | return (false, false); 282 | } 283 | } 284 | if let Some(hidden_if_not) = &self.hidden_if_not { 285 | if hidden_if_not.iter().all(|f| !features.contains(f)) { 286 | return (false, false); 287 | } 288 | } 289 | (true, self.default) 290 | } 291 | } 292 | 293 | impl Display for FeatureDetails { 294 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 295 | let (name, description) = match self { 296 | FeatureDetails::Boolean(details) => (details.name, details.description), 297 | FeatureDetails::Option(details) => (details.name, details.description), 298 | }; 299 | write!( 300 | f, 301 | "{} {}", 302 | name, 303 | crossterm::style::Stylize::dim( 304 | crossterm::style::style(description) 305 | ), 306 | ) 307 | } 308 | } 309 | 310 | pub fn get_feature_list() -> Vec { 311 | vec![ 312 | #(#details_list),* 313 | ] 314 | } 315 | } 316 | } 317 | 318 | fn load_config(&self) -> Result { 319 | let config_path = current_dir() 320 | .context("Could not get current directory")? 321 | .join("template_builder/templates/config.json"); 322 | let config = 323 | std::fs::read_to_string(config_path).context("Could not read templates/config.json")?; 324 | let config: Config = 325 | serde_json::from_str(&config).context("Could not parse templates/config.json")?; 326 | 327 | Ok(config) 328 | } 329 | 330 | fn make_templates_file(&self, templates: &Vec) -> TokenStream { 331 | let mut template_files = vec![]; 332 | let mut package_jsons = (None, vec![]); 333 | 334 | for template in templates { 335 | let path = template.path.to_string_lossy(); 336 | let features = if template.features.is_empty() { 337 | quote! { HashSet::new() } 338 | } else { 339 | let mut tokens = vec![]; 340 | for feature in &template.features { 341 | let ident = format_ident!("{}", feature); 342 | tokens.push(quote! { Feature::#ident }); 343 | } 344 | quote! { HashSet::from([#(#tokens),*]) } 345 | }; 346 | if path == "package.json" { 347 | let contents: PackageJsonPartial = serde_json::from_slice(&template.contents) 348 | .unwrap_or_else(|_| { 349 | panic!( 350 | "Could not parse package.json with features {:?}", 351 | template.features 352 | ) 353 | }); 354 | let package_json = quote! { 355 | TemplateFile { 356 | path: #path, 357 | is_delete: false, 358 | contents: #contents, 359 | features: #features, 360 | } 361 | }; 362 | if template.features.is_empty() { 363 | package_jsons.0 = Some(package_json); 364 | } else { 365 | package_jsons.1.push(package_json); 366 | } 367 | } else { 368 | let contents = Literal::byte_string(&template.contents); 369 | let is_delete = template.is_delete; 370 | template_files.push(quote! { 371 | TemplateFile { 372 | path: #path, 373 | is_delete: #is_delete, 374 | contents: #contents, 375 | features: #features, 376 | } 377 | }); 378 | } 379 | } 380 | 381 | let (base, extras) = package_jsons; 382 | quote! { 383 | use crate::utils::{Feature, PackageJsonPartial, PnpmPackageJson}; 384 | use std::collections::{HashSet, HashMap}; 385 | #[derive(Debug)] 386 | pub struct TemplateFile { 387 | pub path: &'static str, 388 | pub contents: T, 389 | pub is_delete: bool, 390 | pub features: HashSet, 391 | } 392 | pub fn get_templates() -> Vec> { 393 | vec![ 394 | #(#template_files),*, 395 | ] 396 | } 397 | pub fn get_package_jsons() -> ( 398 | TemplateFile>, 399 | Vec>> 400 | ) { 401 | ( 402 | #base, 403 | vec![ 404 | #(#extras),* 405 | ] 406 | ) 407 | } 408 | } 409 | } 410 | 411 | fn load_templates(&self) -> Result> { 412 | let template_dir = current_dir() 413 | .context("Could not get current directory")? 414 | .join("template_builder/templates"); 415 | 416 | let mut templates = vec![]; 417 | 418 | let base = template_dir.join("base"); 419 | for (path, file) in walk_dir(&base) { 420 | let contents = std::fs::read(file.path()) 421 | .with_context(|| format!("Could not read {}", path.display()))?; 422 | 423 | templates.push(TemplateFile { 424 | features: HashSet::new(), 425 | is_delete: false, 426 | path, 427 | contents, 428 | }); 429 | } 430 | 431 | let extras = template_dir.join("extras"); 432 | for (path, file) in walk_dir(&extras) { 433 | let file_name = file.file_name().to_string_lossy(); 434 | 435 | let features = parse_feature_file(&file_name); 436 | 437 | let Some((files, is_delete, name)) = features else { 438 | println!("cargo:warning=Could not parse feature file {}", file_name); 439 | continue; 440 | }; 441 | 442 | let contents = std::fs::read(file.path()) 443 | .with_context(|| format!("Could not read {}", path.display()))?; 444 | 445 | let path = path.parent().unwrap().join(name); 446 | 447 | for features in files { 448 | templates.push(TemplateFile { 449 | features, 450 | is_delete, 451 | path: path.clone(), 452 | contents: contents.clone(), 453 | }); 454 | } 455 | } 456 | templates.sort_by_key(|t| t.feature_count()); 457 | 458 | Ok(templates) 459 | } 460 | 461 | fn write_file(&self, name: &str, tokens: TokenStream) -> anyhow::Result<()> { 462 | let path = self.out_dir.join(name); 463 | std::fs::write(path, tokens.to_string()) 464 | .with_context(|| format!("Could not write {}", name))?; 465 | if let Some(rustfmt) = &self.rustfmt { 466 | let status = Command::new(rustfmt).arg(self.out_dir.join(name)).status(); 467 | match status { 468 | Ok(status) if status.success() => {} 469 | Ok(status) => println!("cargo:warning=rustfmt on {} failed with {}", name, status), 470 | Err(err) => println!( 471 | "cargo:warning=rustfmt on {} failed with error: {}", 472 | name, err 473 | ), 474 | } 475 | } 476 | Ok(()) 477 | } 478 | } 479 | -------------------------------------------------------------------------------- /template_builder/src/utils.rs: -------------------------------------------------------------------------------- 1 | use itertools::Itertools; 2 | use proc_macro2::TokenStream; 3 | use quote::{quote, ToTokens}; 4 | use std::{ 5 | collections::{HashMap, HashSet}, 6 | path::{Path, PathBuf}, 7 | }; 8 | use walkdir::WalkDir; 9 | 10 | pub fn parse_feature_file(file_name: &str) -> Option<(Vec>, bool, &str)> { 11 | let open = file_name.find('{')?; 12 | if open != 0 { 13 | return None; 14 | } 15 | let close = file_name.find('}')?; 16 | 17 | let features_string = &file_name[open + 1..close]; 18 | 19 | let mut base_features = HashSet::new(); 20 | let mut or_features = Vec::new(); 21 | 22 | for feature in features_string.split(',') { 23 | if feature.contains('|') { 24 | let or_feature = feature 25 | .split('|') 26 | .map(|f| f.to_owned()) 27 | .collect::>(); 28 | or_features.push(or_feature); 29 | } else { 30 | base_features.insert(feature.to_owned()); 31 | } 32 | } 33 | let feature_sets = if or_features.is_empty() { 34 | vec![base_features] 35 | } else { 36 | or_features 37 | .iter() 38 | .multi_cartesian_product() 39 | .map(|features| { 40 | let mut set = base_features.clone(); 41 | set.extend(features.into_iter().cloned()); 42 | set 43 | }) 44 | .collect::>() 45 | }; 46 | 47 | let rest = &file_name[close + 1..]; 48 | 49 | let (file_name, is_delete) = match rest.strip_prefix("DELETE:") { 50 | Some(rest) => (rest, true), 51 | None => (rest, false), 52 | }; 53 | 54 | Some((feature_sets, is_delete, file_name)) 55 | } 56 | 57 | pub fn walk_dir(dir: &Path) -> Vec<(PathBuf, walkdir::DirEntry)> { 58 | WalkDir::new(dir) 59 | .into_iter() 60 | .map(|e| e.expect("Could not read template")) 61 | .filter(|e| e.file_type().is_file()) 62 | .map(|e| { 63 | ( 64 | e.path() 65 | .strip_prefix(dir) 66 | .expect("Could not get relative path") 67 | .to_owned(), 68 | e, 69 | ) 70 | }) 71 | .collect() 72 | } 73 | 74 | #[derive(Debug, serde::Deserialize)] 75 | #[serde(rename_all = "camelCase")] 76 | pub struct PackageJsonPartial<'a> { 77 | name: Option<&'a str>, 78 | version: Option<&'a str>, 79 | r#type: Option<&'a str>, 80 | scripts: Option>>, 81 | dependencies: Option>>, 82 | dev_dependencies: Option>>, 83 | workspaces: Option>, 84 | pnpm: Option>, 85 | } 86 | 87 | #[derive(serde::Deserialize, Debug)] 88 | #[serde(rename_all = "camelCase")] 89 | pub struct PnpmPackageJson<'a> { 90 | #[serde(borrow)] 91 | only_built_dependencies: Option>, 92 | } 93 | 94 | impl ToTokens for PnpmPackageJson<'_> { 95 | fn to_tokens(&self, tokens: &mut TokenStream) { 96 | match &self.only_built_dependencies { 97 | Some(deps) => { 98 | let items: Vec<_> = deps.iter().collect(); 99 | tokens.extend(quote! { PnpmPackageJson { 100 | only_built_dependencies: Some(HashSet::from([#(#items),*])), 101 | } }); 102 | } 103 | None => { 104 | tokens.extend(quote! { PnpmPackageJson { 105 | only_built_dependencies: None, 106 | } }); 107 | } 108 | } 109 | } 110 | } 111 | impl ToTokens for PackageJsonPartial<'_> { 112 | fn to_tokens(&self, tokens: &mut TokenStream) { 113 | let mut pieces = vec![]; 114 | pieces.push(quote! { package_manager: None }); 115 | match &self.pnpm { 116 | Some(pnpm) => pieces.push(quote! { pnpm: Some(#pnpm) }), 117 | None => pieces.push(quote! { pnpm: None }), 118 | } 119 | match self.name { 120 | Some(name) => pieces.push(quote! { name: Some(#name) }), 121 | None => pieces.push(quote! { name: None }), 122 | } 123 | match self.version { 124 | Some(version) => pieces.push(quote! { version: Some(#version) }), 125 | None => pieces.push(quote! { version: None }), 126 | } 127 | match self.r#type { 128 | Some(_type) => pieces.push(quote! { r#type: Some(#_type) }), 129 | None => pieces.push(quote! { r#type: None }), 130 | } 131 | match &self.scripts { 132 | Some(scripts) => { 133 | let scripts = hashmap_to_tokens(scripts); 134 | pieces.push(quote! { scripts: Some(#scripts) }) 135 | } 136 | None => pieces.push(quote! { scripts: None }), 137 | } 138 | match &self.dependencies { 139 | Some(dependencies) => { 140 | let dependencies = hashmap_to_tokens(dependencies); 141 | pieces.push(quote! { dependencies: Some(#dependencies) }) 142 | } 143 | None => pieces.push(quote! { dependencies: None }), 144 | } 145 | match &self.dev_dependencies { 146 | Some(dev_dependencies) => { 147 | let dev_dependencies = hashmap_to_tokens(dev_dependencies); 148 | pieces.push(quote! { dev_dependencies: Some(#dev_dependencies) }) 149 | } 150 | None => pieces.push(quote! { dev_dependencies: None }), 151 | } 152 | match &self.workspaces { 153 | Some(workspaces) => { 154 | let workspaces = workspaces 155 | .iter() 156 | .map(|workspace| quote! { #workspace }) 157 | .collect::>(); 158 | pieces.push(quote! { workspaces: Some(vec![#(#workspaces),*]) }) 159 | } 160 | None => pieces.push(quote! { workspaces: None }), 161 | } 162 | tokens.extend(quote! { PackageJsonPartial { 163 | #(#pieces),* 164 | } }); 165 | } 166 | } 167 | 168 | fn hashmap_to_tokens(hashmap: &HashMap<&str, Option<&str>>) -> TokenStream { 169 | let mut tokens = vec![]; 170 | for (key, value) in hashmap { 171 | let value = match value { 172 | Some(value) => quote! { Some(#value) }, 173 | None => quote! { None }, 174 | }; 175 | tokens.push(quote! { (#key, #value) }); 176 | } 177 | quote! { HashMap::from([#(#tokens),*]) } 178 | } 179 | -------------------------------------------------------------------------------- /template_builder/templates/base/.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | 3 | ### macOS ### 4 | # General 5 | .DS_Store 6 | .AppleDouble 7 | .LSOverride 8 | # Icon must end with two \r 9 | Icon 10 | # Thumbnails 11 | ._* 12 | # Files that might appear in the root of a volume 13 | .DocumentRevisions-V100 14 | .fseventsd 15 | .Spotlight-V100 16 | .TemporaryItems 17 | .Trashes 18 | .VolumeIcon.icns 19 | .com.apple.timemachine.donotpresent 20 | # Directories potentially created on remote AFP share 21 | .AppleDB 22 | .AppleDesktop 23 | Network Trash Folder 24 | Temporary Items 25 | .apdisk 26 | # iCloud generated files 27 | *.icloud 28 | 29 | ### Node ### 30 | # Logs 31 | logs 32 | *.log 33 | npm-debug.log* 34 | yarn-debug.log* 35 | yarn-error.log* 36 | .pnpm-debug.log* 37 | # Diagnostic reports (https://nodejs.org/api/report.html) 38 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 39 | # Runtime data 40 | pids 41 | *.pid 42 | *.seed 43 | *.pid.lock 44 | # Directory for instrumented libs generated by jscoverage/JSCover 45 | lib-cov 46 | # Coverage directory used by tools like istanbul 47 | coverage 48 | *.lcov 49 | # nyc test coverage 50 | .nyc_output 51 | # node-waf configuration 52 | .lock-wscript 53 | # Dependency directories 54 | node_modules/ 55 | jspm_packages/ 56 | # TypeScript cache 57 | *.tsbuildinfo 58 | # Optional npm cache directory 59 | .npm 60 | # Optional eslint cache 61 | .eslintcache 62 | # Optional stylelint cache 63 | .stylelintcache 64 | # Microbundle cache 65 | .rpt2_cache/ 66 | .rts2_cache_cjs/ 67 | .rts2_cache_es/ 68 | .rts2_cache_umd/ 69 | # Optional REPL history 70 | .node_repl_history 71 | # Yarn Integrity file 72 | .yarn-integrity 73 | # dotenv environment variable files 74 | .env.development.local 75 | .env.test.local 76 | .env.production.local 77 | .env.local 78 | ### Svelte ### 79 | .svelte-kit/ 80 | package 81 | ### Wrangler ### 82 | .wrangler 83 | ### Prisma hacks ### 84 | prisma/temp.db 85 | prisma/temp.sql 86 | ### yarn ### 87 | .yarn/* 88 | !.yarn/releases 89 | !.yarn/patches 90 | !.yarn/plugins 91 | !.yarn/sdks 92 | !.yarn/versions 93 | # if you are NOT using Zero-installs, then: 94 | # comment the following lines 95 | !.yarn/cache 96 | # and uncomment the following lines 97 | # .pnp.* 98 | -------------------------------------------------------------------------------- /template_builder/templates/base/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "tabWidth": 2, 5 | "useTabs": true, 6 | "trailingComma": "all", 7 | "plugins": [ 8 | "prettier-plugin-svelte", 9 | "prettier-plugin-tailwindcss" 10 | ], 11 | "overrides": [ 12 | { 13 | "files": "*.svelte", 14 | "options": { 15 | "parser": "svelte" 16 | } 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /template_builder/templates/base/eslint.config.js: -------------------------------------------------------------------------------- 1 | import globals from 'globals'; 2 | import js from '@eslint/js'; 3 | import prettier from 'eslint-plugin-prettier/recommended'; 4 | import svelte from 'eslint-plugin-svelte'; 5 | import ts from 'typescript-eslint'; 6 | import svelteConfig from './svelte.config.js'; 7 | 8 | export default ts.config( 9 | js.configs.recommended, 10 | ...ts.configs.recommended, 11 | ...svelte.configs.recommended, 12 | ...svelte.configs.prettier, 13 | prettier, 14 | { 15 | languageOptions: { 16 | globals: { 17 | ...globals.browser, 18 | ...globals.es2017, 19 | }, 20 | }, 21 | linterOptions: { 22 | reportUnusedDisableDirectives: 'off', 23 | }, 24 | }, 25 | { 26 | files: ['**/*.cjs', '**/*.mjs'], 27 | languageOptions: { 28 | globals: { 29 | ...globals.node, 30 | }, 31 | }, 32 | }, 33 | { 34 | files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'], 35 | languageOptions: { 36 | parserOptions: { 37 | projectService: true, 38 | extraFileExtensions: ['.svelte'], 39 | parser: ts.parser, 40 | svelteConfig, 41 | }, 42 | }, 43 | }, 44 | { 45 | rules: { 46 | '@typescript-eslint/no-unused-vars': [ 47 | 'warn', 48 | { 49 | argsIgnorePattern: '^_', 50 | caughtErrorsIgnorePattern: '^_', 51 | varsIgnorePattern: '^_', 52 | }, 53 | ], 54 | }, 55 | }, 56 | { 57 | rules: { 58 | 'prettier/prettier': 'warn', 59 | }, 60 | }, 61 | { ignores: ['**/.svelte-kit', 'build/', 'dist/'] }, 62 | ); 63 | -------------------------------------------------------------------------------- /template_builder/templates/base/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "template", 3 | "version": "0.0.0", 4 | "type": "module", 5 | "scripts": { 6 | "dev": "vite dev", 7 | "build": "vite build", 8 | "preview": "vite preview", 9 | "lint": "svelte-kit sync && eslint --max-warnings 0 --fix . && svelte-check" 10 | }, 11 | "dependencies": {}, 12 | "devDependencies": { 13 | "@eslint/js": "^9.27.0", 14 | "@sveltejs/adapter-auto": "^6.0.1", 15 | "@sveltejs/kit": "^2.21.1", 16 | "@sveltejs/vite-plugin-svelte": "^5.0.3", 17 | "autoprefixer": "^10.4.21", 18 | "eslint": "^9.27.0", 19 | "eslint-config-prettier": "^10.1.5", 20 | "eslint-plugin-prettier": "^5.4.0", 21 | "eslint-plugin-svelte": "^3.9.0", 22 | "globals": "^16.2.0", 23 | "postcss": "^8.5.4", 24 | "postcss-load-config": "^6.0.1", 25 | "prettier": "^3.5.3", 26 | "prettier-plugin-svelte": "^3.4.0", 27 | "prettier-plugin-tailwindcss": "^0.6.11", 28 | "svelte": "^5.33.6", 29 | "svelte-check": "^4.2.1", 30 | "tailwindcss": "^3.4.17", 31 | "typescript": "~5.8.3", 32 | "typescript-eslint": "^8.33.0", 33 | "vite": "^6.3.5" 34 | }, 35 | "pnpm": { 36 | "onlyBuiltDependencies": [ 37 | "esbuild" 38 | ] 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /template_builder/templates/base/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | import autoprefixer from 'autoprefixer'; 2 | import tailwind from 'tailwindcss'; 3 | 4 | /** @type {import('postcss-load-config').Config} */ 5 | const config = { 6 | plugins: [tailwind, autoprefixer], 7 | }; 8 | 9 | export default config; 10 | -------------------------------------------------------------------------------- /template_builder/templates/base/src/app.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | background: theme(colors.zinc.900); 7 | color: white; 8 | } 9 | -------------------------------------------------------------------------------- /template_builder/templates/base/src/app.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | namespace App { 3 | interface Platform { 4 | env: Cloudflare.Env; 5 | context: ExecutionContext; 6 | } 7 | 8 | // interface Locals {} 9 | // interface Error {} 10 | // interface PageData {} 11 | // interface PageState {} 12 | } 13 | } 14 | 15 | export {}; 16 | -------------------------------------------------------------------------------- /template_builder/templates/base/src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | __o7__name__ 7 | 8 | 9 | 10 | %sveltekit.head% 11 | 12 | 13 | 14 |
%sveltekit.body%
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /template_builder/templates/base/src/lib/components/NextStep.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 |
16 |

{title}

17 | {@render children()} 18 | 24 | Learn more 25 | 26 |
27 | -------------------------------------------------------------------------------- /template_builder/templates/base/src/routes/+layout.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | {@render children()} 8 | -------------------------------------------------------------------------------- /template_builder/templates/base/src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 | o7 Logo 7 |

Welcome to the o7 stack!

8 |

Next Steps:

9 |
10 | 14 |

15 | Edit src/routes/+page.svelte to see your 16 | changes live. 17 |

18 |

19 | The source for these cards is in src/lib/components/NextStep.svelte. 22 |

23 |

24 | There's some global styling in src/app.css. 27 |

28 |
29 |
30 |
31 | 32 | 38 | -------------------------------------------------------------------------------- /template_builder/templates/base/static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ottomated/create-o7-app/13fa87dbd6b14a81bd1d51cd4d00bd6d0bd6ecaf/template_builder/templates/base/static/favicon.png -------------------------------------------------------------------------------- /template_builder/templates/base/svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from '@sveltejs/adapter-auto'; 2 | import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; 3 | 4 | /** @type {import('@sveltejs/kit').Config} */ 5 | const config = { 6 | preprocess: [vitePreprocess()], 7 | kit: { 8 | adapter: adapter(), 9 | }, 10 | }; 11 | 12 | export default config; 13 | -------------------------------------------------------------------------------- /template_builder/templates/base/tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ['./src/**/*.{html,js,svelte,ts}'], 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [], 8 | }; 9 | -------------------------------------------------------------------------------- /template_builder/templates/base/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "allowJs": true, 6 | "checkJs": true, 7 | "esModuleInterop": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "resolveJsonModule": true, 10 | "skipLibCheck": true, 11 | "sourceMap": true, 12 | "strict": true, 13 | "allowSyntheticDefaultImports": true, 14 | "noUncheckedIndexedAccess": true, 15 | "moduleResolution": "bundler" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /template_builder/templates/base/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { sveltekit } from '@sveltejs/kit/vite'; 2 | import { defineConfig } from 'vite'; 3 | 4 | export default defineConfig({ 5 | plugins: [sveltekit()], 6 | }); 7 | -------------------------------------------------------------------------------- /template_builder/templates/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "default_name": "my-o7-app", 3 | "initial_commit": "Create o7 App", 4 | "features": [ 5 | { 6 | "id": "Auth", 7 | "name": "Auth", 8 | "description": "(with Lucia)", 9 | "default": false 10 | }, 11 | { 12 | "id": "Trpc", 13 | "name": "tRPC", 14 | "description": "(Type-safe API)", 15 | "default": true, 16 | "required_if": ["Auth"] 17 | }, 18 | { 19 | "id": "Edge", 20 | "name": "Edge", 21 | "description": "(Deploy to Cloudflare)", 22 | "default": true 23 | }, 24 | { 25 | "id": "Database", 26 | "name": "Database", 27 | "options": [ 28 | { 29 | "id": "D1", 30 | "name": "D1", 31 | "hidden_if_not": ["Edge"] 32 | }, 33 | { 34 | "id": "Sqlite", 35 | "name": "Local SQLite", 36 | "hidden_if": ["Edge"] 37 | }, 38 | { 39 | "id": "Turso", 40 | "name": "Turso" 41 | }, 42 | { 43 | "id": "Planetscale", 44 | "name": "Planetscale" 45 | }, 46 | { 47 | "name": "None", 48 | "hidden_if": ["Auth"] 49 | } 50 | ], 51 | "description": "" 52 | }, 53 | { 54 | "id": "Sidecar", 55 | "name": "Sidecar", 56 | "description": "(Additional worker for websockets, etc.)", 57 | "hidden_if_not": ["Edge"], 58 | "default": false 59 | }, 60 | { 61 | "id": "Tailwind4", 62 | "name": "Tailwind 4", 63 | "description": "(supported only by VERY modern browsers, not recommended)", 64 | "default": false 65 | } 66 | ] 67 | } 68 | -------------------------------------------------------------------------------- /template_builder/templates/extras/.github/workflows/{Bun,Sidecar}deploy-worker.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Sidecar Worker 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - 'worker/**' 9 | - 'common/**' 10 | - '.github/workflows/deploy-worker.yml' 11 | - 'bun.lockb' 12 | 13 | jobs: 14 | deploy: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | 20 | - name: Install Bun 21 | uses: oven-sh/setup-bun@v2 22 | 23 | - name: Install dependencies 24 | working-directory: worker 25 | run: bun install --frozen-lockfile 26 | 27 | - name: Deploy 28 | working-directory: worker 29 | run: bun run deploy 30 | env: 31 | CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} 32 | -------------------------------------------------------------------------------- /template_builder/templates/extras/.github/workflows/{Npm,Sidecar}deploy-worker.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Sidecar Worker 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - 'worker/**' 9 | - 'common/**' 10 | - '.github/workflows/deploy-worker.yml' 11 | - 'package-lock.json' 12 | 13 | jobs: 14 | deploy: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | 20 | - name: Install node 21 | uses: actions/setup-node@v4 22 | with: 23 | cache: 'npm' 24 | 25 | - name: Install dependencies 26 | working-directory: worker 27 | run: npm ci 28 | 29 | - name: Deploy 30 | working-directory: worker 31 | run: npm run deploy 32 | env: 33 | CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} 34 | -------------------------------------------------------------------------------- /template_builder/templates/extras/.github/workflows/{Pnpm,Sidecar}deploy-worker.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Sidecar Worker 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - 'worker/**' 9 | - 'common/**' 10 | - '.github/workflows/deploy-worker.yml' 11 | - 'pnpm-lock.yaml' 12 | 13 | jobs: 14 | deploy: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | 20 | - uses: pnpm/action-setup@v4 21 | name: Install pnpm 22 | 23 | - name: Install node 24 | uses: actions/setup-node@v4 25 | with: 26 | cache: 'pnpm' 27 | 28 | - name: Install dependencies 29 | working-directory: worker 30 | run: pnpm install --frozen-lockfile 31 | 32 | - name: Deploy 33 | working-directory: worker 34 | run: pnpm run deploy 35 | env: 36 | CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} 37 | -------------------------------------------------------------------------------- /template_builder/templates/extras/.github/workflows/{Yarn,Sidecar}deploy-worker.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Sidecar Worker 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - 'worker/**' 9 | - 'common/**' 10 | - '.github/workflows/deploy-worker.yml' 11 | - 'yarn.lock' 12 | 13 | jobs: 14 | deploy: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | 20 | - name: Install yarn 21 | run: yarn set version berry 22 | 23 | - name: Install node 24 | uses: actions/setup-node@v4 25 | with: 26 | cache: 'yarn' 27 | 28 | - name: Install dependencies 29 | working-directory: worker 30 | run: yarn install --immutable 31 | 32 | - name: Deploy 33 | working-directory: worker 34 | run: yarn run deploy 35 | env: 36 | CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} 37 | -------------------------------------------------------------------------------- /template_builder/templates/extras/common/src/{Sidecar}common.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod/v4'; 2 | 3 | export const clientToServerSchema = z.object({ 4 | type: z.literal('broadcast'), 5 | payload: z.string(), 6 | }); 7 | 8 | export type ClientToServer = z.infer; 9 | 10 | export type ServerToClient = { 11 | type: 'message'; 12 | payload: string; 13 | }; 14 | -------------------------------------------------------------------------------- /template_builder/templates/extras/common/{Sidecar}package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "common", 3 | "version": "0.0.0", 4 | "private": true, 5 | "types": "./src/common.ts", 6 | "module": "./src/common.ts", 7 | "dependencies": { 8 | "zod": "^3.25.35" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /template_builder/templates/extras/prisma/{D1|Turso,Auth}schema.prisma: -------------------------------------------------------------------------------- 1 | datasource db { 2 | provider = "sqlite" 3 | url = "" 4 | } 5 | 6 | generator kysely { 7 | provider = "prisma-kysely" 8 | 9 | output = "../src/lib/db" 10 | fileName = "schema.d.ts" 11 | } 12 | 13 | model User { 14 | id String @id 15 | twitch_id String 16 | username String 17 | 18 | sessions Session[] 19 | } 20 | 21 | model Session { 22 | id String @id 23 | expires_at Int 24 | user_id String 25 | user User @relation(fields: [user_id], references: [id]) 26 | } 27 | -------------------------------------------------------------------------------- /template_builder/templates/extras/prisma/{D1|Turso}schema.prisma: -------------------------------------------------------------------------------- 1 | datasource db { 2 | provider = "sqlite" 3 | url = "" 4 | } 5 | 6 | generator kysely { 7 | provider = "prisma-kysely" 8 | 9 | output = "../src/lib/db" 10 | fileName = "schema.d.ts" 11 | } 12 | 13 | model Example { 14 | id Int @id @default(autoincrement()) 15 | name String 16 | } 17 | -------------------------------------------------------------------------------- /template_builder/templates/extras/prisma/{D1}push.mjs: -------------------------------------------------------------------------------- 1 | import { spawnSync } from 'node:child_process'; 2 | import Database from 'better-sqlite3'; 3 | import { resolve } from 'node:path'; 4 | import { unlinkSync, writeFileSync } from 'node:fs'; 5 | import { fileURLToPath } from 'node:url'; 6 | import 'dotenv/config'; 7 | 8 | const isLocal = process.argv.includes('--local'); 9 | const localFlag = isLocal ? '--local' : '--remote'; 10 | 11 | let databaseName; 12 | 13 | if (isLocal) { 14 | databaseName = 'DB'; 15 | } else { 16 | databaseName = process.env.DATABASE_NAME; 17 | if (!databaseName) { 18 | console.error('DATABASE_NAME not set (must be the name of a D1 database)'); 19 | process.exit(1); 20 | } 21 | } 22 | const dirname = resolve(fileURLToPath(import.meta.url), '..'); 23 | 24 | const tempDb = resolve(dirname, './temp.db'); 25 | const migrationFile = resolve(dirname, './temp.sql'); 26 | try { 27 | unlinkSync(tempDb); 28 | } catch (_) { 29 | /* ignore */ 30 | } 31 | const schema = resolve(dirname, './schema.prisma'); 32 | 33 | // 1. Pull current schema 34 | const currentRes = JSON.parse( 35 | spawnSync( 36 | 'npx', 37 | [ 38 | 'wrangler', 39 | 'd1', 40 | 'execute', 41 | databaseName, 42 | localFlag, 43 | '--command', 44 | "SELECT * FROM sqlite_schema WHERE name != '_cf_KV' AND name != 'sqlite_sequence'", 45 | '--json', 46 | ], 47 | { encoding: 'utf-8' }, 48 | ).stdout, 49 | ); 50 | if (currentRes.error) { 51 | console.error(currentRes.error); 52 | console.error('Have you put your database ID in wrangler.toml?'); 53 | process.exit(1); 54 | } 55 | const current = currentRes[0].results; 56 | 57 | // 2. create dummy db with that schema 58 | const db = new Database(tempDb); 59 | for (const item of current) { 60 | if (item.sql && item.sql !== 'null') { 61 | db.prepare(item.sql).run(); 62 | } 63 | } 64 | 65 | // 3. generate migration on dummy db 66 | const migration = spawnSync( 67 | 'npx', 68 | [ 69 | 'prisma', 70 | 'migrate', 71 | 'diff', 72 | '--from-url', 73 | `file:${tempDb}`, 74 | '--to-schema-datamodel', 75 | schema, 76 | '--script', 77 | ], 78 | { encoding: 'utf-8' }, 79 | ); 80 | if (migration.status !== 0) { 81 | console.error('Prisma error:'); 82 | console.error(migration.stderr); 83 | unlinkSync(tempDb); 84 | process.exit(0); 85 | } 86 | if (migration.stdout.includes('-- This is an empty migration.')) { 87 | console.log('No changes'); 88 | unlinkSync(tempDb); 89 | process.exit(0); 90 | } 91 | 92 | const migrationSql = migration.stdout 93 | .replace(/^PRAGMA foreign_keys=OFF;/gm, 'PRAGMA defer_foreign_keys=true;') 94 | .replace(/^PRAGMA foreign_keys=ON;/gm, 'PRAGMA defer_foreign_keys=false;') 95 | .replace(/^PRAGMA foreign_key_check;/gm, ''); 96 | console.log(migrationSql); 97 | 98 | writeFileSync(migrationFile, migrationSql); 99 | 100 | // 4. apply migration on actual db 101 | const res = spawnSync( 102 | `npx`, 103 | [ 104 | 'wrangler', 105 | 'd1', 106 | 'execute', 107 | databaseName, 108 | localFlag, 109 | '--file', 110 | migrationFile, 111 | ], 112 | { stdio: 'inherit' }, 113 | ); 114 | 115 | unlinkSync(tempDb); 116 | unlinkSync(migrationFile); 117 | 118 | if (res.status !== 0) { 119 | console.error('Migration failed'); 120 | process.exit(res.status); 121 | } 122 | 123 | spawnSync('npx', ['prisma', 'generate'], { stdio: 'inherit' }); 124 | -------------------------------------------------------------------------------- /template_builder/templates/extras/prisma/{Planetscale,Auth}schema.prisma: -------------------------------------------------------------------------------- 1 | datasource db { 2 | provider = "mysql" 3 | url = env("DATABASE_URL") 4 | relationMode = "prisma" 5 | } 6 | 7 | generator kysely { 8 | provider = "prisma-kysely" 9 | 10 | output = "../src/lib/db" 11 | fileName = "schema.d.ts" 12 | } 13 | 14 | model User { 15 | id String @id 16 | twitch_id String 17 | username String 18 | 19 | sessions Session[] 20 | } 21 | 22 | model Session { 23 | id String @id 24 | expires_at Int 25 | user_id String 26 | user User @relation(fields: [user_id], references: [id]) 27 | 28 | @@index([user_id]) 29 | } 30 | -------------------------------------------------------------------------------- /template_builder/templates/extras/prisma/{Planetscale}schema.prisma: -------------------------------------------------------------------------------- 1 | datasource db { 2 | provider = "mysql" 3 | url = env("DATABASE_URL") 4 | relationMode = "prisma" 5 | } 6 | 7 | generator kysely { 8 | provider = "prisma-kysely" 9 | 10 | output = "../src/lib/db" 11 | fileName = "schema.d.ts" 12 | } 13 | 14 | model Example { 15 | id Int @id @default(autoincrement()) 16 | name String 17 | } 18 | -------------------------------------------------------------------------------- /template_builder/templates/extras/prisma/{Sqlite,Auth}schema.prisma: -------------------------------------------------------------------------------- 1 | datasource db { 2 | provider = "sqlite" 3 | url = env("DATABASE_URL") 4 | } 5 | 6 | generator kysely { 7 | provider = "prisma-kysely" 8 | 9 | output = "../src/lib/db" 10 | fileName = "schema.d.ts" 11 | } 12 | 13 | model User { 14 | id String @id 15 | twitch_id String 16 | username String 17 | 18 | sessions Session[] 19 | } 20 | 21 | model Session { 22 | id String @id 23 | expires_at Int 24 | user_id String 25 | user User @relation(fields: [user_id], references: [id]) 26 | } 27 | -------------------------------------------------------------------------------- /template_builder/templates/extras/prisma/{Sqlite}schema.prisma: -------------------------------------------------------------------------------- 1 | datasource db { 2 | provider = "sqlite" 3 | url = env("DATABASE_URL") 4 | } 5 | 6 | generator kysely { 7 | provider = "prisma-kysely" 8 | 9 | output = "../src/lib/db" 10 | fileName = "schema.d.ts" 11 | } 12 | 13 | model Example { 14 | id Int @id @default(autoincrement()) 15 | name String 16 | } 17 | -------------------------------------------------------------------------------- /template_builder/templates/extras/prisma/{Turso}push.mjs: -------------------------------------------------------------------------------- 1 | import { spawnSync } from 'node:child_process'; 2 | import Database from 'better-sqlite3'; 3 | import { resolve } from 'node:path'; 4 | import { unlinkSync } from 'node:fs'; 5 | import { fileURLToPath } from 'node:url'; 6 | import { createClient } from '@libsql/client'; 7 | import 'dotenv/config'; 8 | 9 | if (!process.env.TURSO_URL) { 10 | throw new Error('TURSO_URL not set'); 11 | } 12 | 13 | const client = createClient({ 14 | url: process.env.TURSO_URL, 15 | authToken: process.env.TURSO_TOKEN, 16 | }); 17 | 18 | const dirname = resolve(fileURLToPath(import.meta.url), '..'); 19 | 20 | const tempDb = resolve(dirname, './temp.db'); 21 | try { 22 | unlinkSync(tempDb); 23 | } catch (_) { 24 | /* ignore */ 25 | } 26 | const schema = resolve(dirname, './schema.prisma'); 27 | 28 | // 1. Pull current schema 29 | const current = await client.execute( 30 | "SELECT * FROM sqlite_schema WHERE name != 'sqlite_sequence'", 31 | ); 32 | 33 | // 2. create dummy db with that schema 34 | const db = new Database(tempDb); 35 | for (const item of current.rows) { 36 | if (item.sql && item.sql !== 'null') { 37 | db.prepare(item.sql).run(); 38 | } 39 | } 40 | 41 | // 3. generate migration on dummy db 42 | const migration = spawnSync( 43 | 'npx', 44 | [ 45 | 'prisma', 46 | 'migrate', 47 | 'diff', 48 | '--from-url', 49 | `file:${tempDb}`, 50 | '--to-schema-datamodel', 51 | schema, 52 | '--script', 53 | ], 54 | { encoding: 'utf-8' }, 55 | ); 56 | if (migration.status !== 0) { 57 | console.error('Prisma error:'); 58 | console.error(migration.stderr); 59 | unlinkSync(tempDb); 60 | process.exit(0); 61 | } 62 | if (migration.stdout.includes('-- This is an empty migration.')) { 63 | console.log('No changes'); 64 | unlinkSync(tempDb); 65 | process.exit(0); 66 | } 67 | 68 | const migrationSql = migration.stdout; 69 | 70 | console.log(migrationSql); 71 | 72 | // 4. apply migration on actual db 73 | try { 74 | await client.executeMultiple(migrationSql); 75 | } catch (e) { 76 | console.error('Migration failed', e); 77 | process.exit(1); 78 | } 79 | 80 | unlinkSync(tempDb); 81 | 82 | spawnSync('npx', ['prisma', 'generate'], { stdio: 'inherit' }); 83 | -------------------------------------------------------------------------------- /template_builder/templates/extras/src/lib/auth/{Auth}index.ts: -------------------------------------------------------------------------------- 1 | import { Twitch } from 'arctic'; 2 | import { 3 | encodeBase32LowerCaseNoPadding, 4 | encodeHexLowerCase, 5 | } from '@oslojs/encoding'; 6 | import { sha256 } from '@oslojs/crypto/sha2'; 7 | import { generateRandomString, type RandomReader } from '@oslojs/crypto/random'; 8 | import type { Selectable } from 'kysely'; 9 | import { db } from '$lib/db'; 10 | import { CLIENT_ID, CLIENT_SECRET } from '$env/static/private'; 11 | import type { DB } from '$lib/db/schema'; 12 | 13 | const ONE_DAY = 1000 * 60 * 60 * 24; 14 | 15 | const random: RandomReader = { 16 | read(bytes: Uint8Array): void { 17 | crypto.getRandomValues(bytes); 18 | }, 19 | }; 20 | 21 | export function generateId(length = 15): string { 22 | return generateRandomString( 23 | random, 24 | 'abcdefghijklmnopqrstuvwxyz0123456789', 25 | length, 26 | ); 27 | } 28 | 29 | export function generateSessionToken(): string { 30 | const bytes = new Uint8Array(20); 31 | crypto.getRandomValues(bytes); 32 | const token = encodeBase32LowerCaseNoPadding(bytes); 33 | return token; 34 | } 35 | 36 | export async function createSession( 37 | token: string, 38 | userId: string, 39 | ): Promise { 40 | const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); 41 | const session: Session = { 42 | id: sessionId, 43 | userId, 44 | expiresAt: new Date(Date.now() + ONE_DAY * 7), 45 | }; 46 | await db 47 | .insertInto('Session') 48 | .values({ 49 | id: session.id, 50 | user_id: session.userId, 51 | expires_at: Math.floor(session.expiresAt.getTime() / 1000), 52 | }) 53 | .execute(); 54 | return session; 55 | } 56 | 57 | export async function validateSessionToken( 58 | token: string, 59 | ): Promise { 60 | const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); 61 | const row = await db 62 | .selectFrom('Session as s') 63 | .innerJoin('User as u', 'u.id', 's.user_id') 64 | .select(['s.id', 's.user_id', 's.expires_at', 'u.twitch_id', 'u.username']) 65 | .where('s.id', '=', sessionId) 66 | .executeTakeFirst(); 67 | if (!row) { 68 | return { session: null, user: null }; 69 | } 70 | const session: Session = { 71 | id: row.id, 72 | userId: row.user_id, 73 | expiresAt: new Date(row.expires_at * 1000), 74 | }; 75 | if (Date.now() >= session.expiresAt.getTime()) { 76 | await db.deleteFrom('Session').where('id', '=', session.id).execute(); 77 | return { session: null, user: null }; 78 | } 79 | const user: User = { 80 | id: row.user_id, 81 | twitch_id: row.twitch_id, 82 | username: row.username, 83 | }; 84 | if (Date.now() >= session.expiresAt.getTime() - ONE_DAY * 15) { 85 | session.expiresAt = new Date(Date.now() + ONE_DAY * 30); 86 | await db 87 | .updateTable('Session') 88 | .set({ 89 | expires_at: Math.floor(session.expiresAt.getTime() / 1000), 90 | }) 91 | .where('id', '=', session.id) 92 | .execute(); 93 | } 94 | return { session, user }; 95 | } 96 | 97 | export async function invalidateSession(sessionId: string): Promise { 98 | await db.deleteFrom('Session').where('id', '=', sessionId).execute(); 99 | } 100 | 101 | export type SessionValidationResult = 102 | | { session: Session; user: User } 103 | | { session: null; user: null }; 104 | 105 | export interface Session { 106 | id: string; 107 | userId: string; 108 | expiresAt: Date; 109 | } 110 | 111 | export type User = Selectable; 112 | 113 | export let twitch: Twitch; 114 | export function initAuth(origin: string) { 115 | if (twitch) return; 116 | twitch = new Twitch(CLIENT_ID, CLIENT_SECRET, origin + '/api/auth/callback'); 117 | } 118 | -------------------------------------------------------------------------------- /template_builder/templates/extras/src/lib/db/{D1|Planetscale|Sqlite|Turso,Auth}schema.d.ts: -------------------------------------------------------------------------------- 1 | import type { ColumnType } from 'kysely'; 2 | export type Generated = 3 | T extends ColumnType 4 | ? ColumnType 5 | : ColumnType; 6 | export type Timestamp = ColumnType; 7 | 8 | export type Session = { 9 | id: string; 10 | expires_at: number; 11 | user_id: string; 12 | }; 13 | export type User = { 14 | id: string; 15 | twitch_id: string; 16 | username: string; 17 | }; 18 | export type DB = { 19 | Session: Session; 20 | User: User; 21 | }; 22 | -------------------------------------------------------------------------------- /template_builder/templates/extras/src/lib/db/{D1|Planetscale|Sqlite|Turso}schema.d.ts: -------------------------------------------------------------------------------- 1 | import type { ColumnType } from 'kysely'; 2 | export type Generated = 3 | T extends ColumnType 4 | ? ColumnType 5 | : ColumnType; 6 | export type Timestamp = ColumnType; 7 | 8 | export type Example = { 9 | id: Generated; 10 | name: string; 11 | }; 12 | export type DB = { 13 | Example: Example; 14 | }; 15 | -------------------------------------------------------------------------------- /template_builder/templates/extras/src/lib/db/{D1}index.ts: -------------------------------------------------------------------------------- 1 | import { Kysely, type RawBuilder, sql } from 'kysely'; 2 | import { D1Dialect } from 'kysely-d1'; 3 | import type { DB } from './schema'; 4 | 5 | export let db: Kysely; 6 | 7 | export function initDb(database: D1Database) { 8 | if (db) return; 9 | db = new Kysely({ 10 | dialect: new D1Dialect({ 11 | database, 12 | }), 13 | }); 14 | } 15 | 16 | export function json(obj: T): RawBuilder { 17 | return sql`${JSON.stringify(obj)}`; 18 | } 19 | -------------------------------------------------------------------------------- /template_builder/templates/extras/src/lib/db/{Planetscale}index.ts: -------------------------------------------------------------------------------- 1 | import { Kysely, type RawBuilder, sql } from 'kysely'; 2 | import { PlanetScaleDialect, inflateDates } from 'kysely-planetscale'; 3 | import type { DB } from './schema'; 4 | import { 5 | DATABASE_HOST, 6 | DATABASE_USERNAME, 7 | DATABASE_PASSWORD, 8 | } from '$env/static/private'; 9 | 10 | export const db = new Kysely({ 11 | dialect: new PlanetScaleDialect({ 12 | host: DATABASE_HOST, 13 | username: DATABASE_USERNAME, 14 | password: DATABASE_PASSWORD, 15 | cast: (field, value) => { 16 | if (field.type === 'INT8' && value === '1') return true; 17 | if (field.type === 'INT8' && value === '0') return false; 18 | return inflateDates(field, value); 19 | }, 20 | }), 21 | }); 22 | 23 | export function json(obj: T): RawBuilder { 24 | return sql`${JSON.stringify(obj)}`; 25 | } 26 | -------------------------------------------------------------------------------- /template_builder/templates/extras/src/lib/db/{Sqlite}index.ts: -------------------------------------------------------------------------------- 1 | import { Kysely, type RawBuilder, sql, SqliteDialect } from 'kysely'; 2 | import Database from 'better-sqlite3'; 3 | import { DATABASE_URL } from '$env/static/private'; 4 | import type { DB } from './schema'; 5 | 6 | export const sqlite = new Database(DATABASE_URL); 7 | 8 | export const db = new Kysely({ 9 | dialect: new SqliteDialect({ 10 | database: sqlite, 11 | }), 12 | }); 13 | 14 | export function json(obj: T): RawBuilder { 15 | return sql`${JSON.stringify(obj)}`; 16 | } 17 | -------------------------------------------------------------------------------- /template_builder/templates/extras/src/lib/db/{Turso}index.ts: -------------------------------------------------------------------------------- 1 | import { Kysely, type RawBuilder, sql } from 'kysely'; 2 | import { LibsqlDialect } from 'kysely-libsql'; 3 | import type { DB } from './schema'; 4 | import { dev } from '$app/environment'; 5 | import { TURSO_TOKEN, TURSO_URL } from '$env/static/private'; 6 | import { createClient } from '@libsql/client'; 7 | 8 | if (!TURSO_URL || !TURSO_TOKEN) { 9 | if (dev) { 10 | throw new Error('TURSO_URL and TURSO_TOKEN must be set'); 11 | } else { 12 | console.warn('TURSO_URL and TURSO_TOKEN must be set'); 13 | } 14 | } 15 | 16 | export const dbClient = createClient({ 17 | url: TURSO_URL, 18 | authToken: TURSO_TOKEN, 19 | }); 20 | 21 | export const db = new Kysely({ 22 | dialect: new LibsqlDialect({ client: dbClient }), 23 | }); 24 | 25 | export function json(obj: T): RawBuilder { 26 | return sql`${JSON.stringify(obj)}`; 27 | } 28 | -------------------------------------------------------------------------------- /template_builder/templates/extras/src/lib/server/routes/{Trpc,Auth}_app.ts: -------------------------------------------------------------------------------- 1 | import { router, publicProcedure, authedProcedure } from '../trpc'; 2 | import { z } from 'zod/v4'; 3 | 4 | export const appRouter = router({ 5 | greeting: publicProcedure 6 | .input( 7 | z.object({ 8 | name: z.string().optional(), 9 | }), 10 | ) 11 | .query(({ input }) => { 12 | return `Welcome to ${input.name ?? 'the world'}!`; 13 | }), 14 | me: publicProcedure.query(({ ctx }) => { 15 | return ctx.user; 16 | }), 17 | secret: authedProcedure.query(({ ctx }) => { 18 | // This is a protected route 19 | return `Hello, ${ctx.user.username}!`; 20 | }), 21 | }); 22 | 23 | export type AppRouter = typeof appRouter; 24 | -------------------------------------------------------------------------------- /template_builder/templates/extras/src/lib/server/routes/{Trpc}_app.ts: -------------------------------------------------------------------------------- 1 | import { router, publicProcedure } from '../trpc'; 2 | import { z } from 'zod/v4'; 3 | 4 | export const appRouter = router({ 5 | greeting: publicProcedure 6 | .input( 7 | z.object({ 8 | name: z.string().optional(), 9 | }), 10 | ) 11 | .query(({ input }) => { 12 | return `Welcome to ${input.name ?? 'the world'}!`; 13 | }), 14 | }); 15 | 16 | export type AppRouter = typeof appRouter; 17 | -------------------------------------------------------------------------------- /template_builder/templates/extras/src/lib/server/{Trpc,Auth}context.ts: -------------------------------------------------------------------------------- 1 | import type { RequestEvent } from '@sveltejs/kit'; 2 | 3 | export async function createContext(event: RequestEvent) { 4 | return { 5 | user: event.locals.user, 6 | session: event.locals.session, 7 | }; 8 | } 9 | 10 | export type Context = Awaited>; 11 | -------------------------------------------------------------------------------- /template_builder/templates/extras/src/lib/server/{Trpc,Auth}trpc.ts: -------------------------------------------------------------------------------- 1 | import { TRPCError, initTRPC } from '@trpc/server'; 2 | import type { Context } from './context'; 3 | import { transformer } from '$lib/trpc'; 4 | 5 | const t = initTRPC.context().create({ 6 | transformer, 7 | }); 8 | 9 | export const router = t.router; 10 | 11 | export const publicProcedure = t.procedure; 12 | 13 | export const authedProcedure = publicProcedure.use(({ ctx, next }) => { 14 | if (!ctx.session || !ctx.user) { 15 | throw new TRPCError({ code: 'UNAUTHORIZED' }); 16 | } 17 | return next({ 18 | ctx: { 19 | user: ctx.user, 20 | session: ctx.session, 21 | }, 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /template_builder/templates/extras/src/lib/server/{Trpc}context.ts: -------------------------------------------------------------------------------- 1 | import type { RequestEvent } from '@sveltejs/kit'; 2 | 3 | export async function createContext(_event: RequestEvent) { 4 | return {}; 5 | } 6 | 7 | export type Context = Awaited>; 8 | -------------------------------------------------------------------------------- /template_builder/templates/extras/src/lib/server/{Trpc}server.ts: -------------------------------------------------------------------------------- 1 | import { createTRPCSvelteServer } from 'trpc-svelte-query/server'; 2 | import { appRouter } from './routes/_app'; 3 | import { createContext } from './context'; 4 | 5 | export const trpcServer = createTRPCSvelteServer({ 6 | endpoint: '/api/trpc', 7 | router: appRouter, 8 | createContext, 9 | }); 10 | -------------------------------------------------------------------------------- /template_builder/templates/extras/src/lib/server/{Trpc}trpc.ts: -------------------------------------------------------------------------------- 1 | import { initTRPC } from '@trpc/server'; 2 | import type { Context } from './context'; 3 | import { transformer } from '$lib/trpc'; 4 | 5 | const t = initTRPC.context().create({ 6 | transformer, 7 | }); 8 | 9 | export const router = t.router; 10 | 11 | export const publicProcedure = t.procedure; 12 | -------------------------------------------------------------------------------- /template_builder/templates/extras/src/lib/{Trpc}trpc.ts: -------------------------------------------------------------------------------- 1 | import { createTRPCSvelte } from 'trpc-svelte-query'; 2 | import { httpBatchLink } from '@trpc/client'; 3 | import type { AppRouter } from '$lib/server/routes/_app'; 4 | import { parse, stringify, uneval } from 'devalue'; 5 | import type { inferRouterOutputs, inferRouterInputs } from '@trpc/server'; 6 | 7 | export const transformer = { 8 | input: { 9 | serialize: (object: unknown) => stringify(object), 10 | deserialize: (object: string) => parse(object), 11 | }, 12 | output: { 13 | serialize: (object: unknown) => uneval(object), 14 | deserialize: (object: string) => (0, eval)(`(${object})`), 15 | }, 16 | }; 17 | 18 | export const trpc = createTRPCSvelte({ 19 | links: [ 20 | httpBatchLink({ 21 | url: '/api/trpc', 22 | transformer, 23 | }), 24 | ], 25 | }); 26 | 27 | export type RouterOutput = inferRouterOutputs; 28 | export type RouterInput = inferRouterInputs; 29 | -------------------------------------------------------------------------------- /template_builder/templates/extras/src/routes/api/auth/callback/{Auth}+server.ts: -------------------------------------------------------------------------------- 1 | import { OAuth2Tokens } from 'arctic'; 2 | import { db } from '$lib/db'; 3 | import { CLIENT_ID } from '$env/static/private'; 4 | import { error, redirect } from '@sveltejs/kit'; 5 | import { 6 | createSession, 7 | generateId, 8 | generateSessionToken, 9 | twitch, 10 | } from '$lib/auth'; 11 | import { dev } from '$app/environment'; 12 | 13 | export const GET = async (event) => { 14 | const code = event.url.searchParams.get('code'); 15 | const state = event.url.searchParams.get('state'); 16 | const storedState = event.cookies.get('oauth_state') ?? null; 17 | 18 | if (!code || !state || !storedState || state !== storedState) { 19 | return new Response(null, { 20 | status: 400, 21 | }); 22 | } 23 | let tokens: OAuth2Tokens; 24 | try { 25 | tokens = await twitch.validateAuthorizationCode(code); 26 | } catch (err) { 27 | console.error('Invalid code or client ID', err); 28 | // Invalid code or client ID 29 | return error(400, 'Authentication failed'); 30 | } 31 | 32 | const twitchUser = await fetch('https://api.twitch.tv/helix/users', { 33 | headers: { 34 | Authorization: `Bearer ${tokens.accessToken()}`, 35 | 'Client-ID': CLIENT_ID, 36 | }, 37 | }) 38 | .then((r) => r.json() as Promise) 39 | .then((u) => u.data[0]); 40 | 41 | let user = await db 42 | .selectFrom('User') 43 | .select('id') 44 | .where('twitch_id', '=', twitchUser.id) 45 | .executeTakeFirst(); 46 | 47 | if (!user) { 48 | user = { 49 | id: generateId(15), 50 | }; 51 | await db 52 | .insertInto('User') 53 | .values({ 54 | id: user.id, 55 | twitch_id: twitchUser.id, 56 | username: twitchUser.display_name, 57 | }) 58 | .execute(); 59 | } 60 | 61 | const sessionToken = generateSessionToken(); 62 | const session = await createSession(sessionToken, user.id); 63 | 64 | event.cookies.set('session', sessionToken, { 65 | path: '/', 66 | httpOnly: true, 67 | sameSite: 'lax', 68 | expires: session.expiresAt, 69 | secure: !dev, 70 | }); 71 | redirect(302, '/'); 72 | }; 73 | 74 | interface TwitchUser { 75 | data: [ 76 | { 77 | id: string; 78 | login: string; 79 | display_name: string; 80 | type: 'admin' | 'global_mod' | 'staff' | ''; 81 | broadcaster_type: 'partner' | 'affiliate' | ''; 82 | description: string; 83 | profile_image_url: string; 84 | offline_image_url: string; 85 | created_at: string; 86 | }, 87 | ]; 88 | } 89 | -------------------------------------------------------------------------------- /template_builder/templates/extras/src/routes/api/auth/login/{Auth}+server.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from '@sveltejs/kit'; 2 | import { generateState } from 'arctic'; 3 | import { twitch } from '$lib/auth'; 4 | import { dev } from '$app/environment'; 5 | 6 | export const GET = async (event) => { 7 | const state = generateState(); 8 | const url = twitch.createAuthorizationURL(state, []); 9 | 10 | event.cookies.set('oauth_state', state, { 11 | path: '/', 12 | secure: !dev, 13 | httpOnly: true, 14 | maxAge: 60 * 10, 15 | sameSite: 'lax', 16 | }); 17 | 18 | redirect(302, url); 19 | }; 20 | -------------------------------------------------------------------------------- /template_builder/templates/extras/src/routes/api/auth/logout/{Auth}+server.ts: -------------------------------------------------------------------------------- 1 | import { invalidateSession } from '$lib/auth'; 2 | import { redirect } from '@sveltejs/kit'; 3 | 4 | export const GET = async ({ locals }) => { 5 | if (locals.session) await invalidateSession(locals.session.id); 6 | redirect(302, '/'); 7 | }; 8 | -------------------------------------------------------------------------------- /template_builder/templates/extras/src/routes/api/trpc/[...trpc]/{Trpc}+server.ts: -------------------------------------------------------------------------------- 1 | import { trpcServer } from '$lib/server/server'; 2 | 3 | export const GET = trpcServer.handler; 4 | export const POST = trpcServer.handler; 5 | -------------------------------------------------------------------------------- /template_builder/templates/extras/src/routes/{D1|Planetscale|Sqlite}+page.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 | o7 Logo 7 |

Welcome to the o7 stack!

8 |

Next Steps:

9 |
10 | 14 |

15 | Edit src/routes/+page.svelte to see your 16 | changes live. 17 |

18 |

19 | The source for these cards is in src/lib/components/NextStep.svelte. 22 |

23 |

24 | There's some global styling in src/app.css. 27 |

28 |
29 | 33 |

34 | Write a Prisma schema in prisma/schema.prisma. 37 |

38 |

39 | Insert your database connection details into .env. 42 |

43 |

44 | Run pnpm db:push to update your database 45 | typings. 46 |

47 |

48 | Remember that you're using Kysely 53 | instead of Prisma to make your queries, and 54 | import {'{ db }'} from '$lib/db'! 55 |

56 |
57 |
58 |
59 | 60 | 66 | -------------------------------------------------------------------------------- /template_builder/templates/extras/src/routes/{Tailwind4,D1|Planetscale|Sqlite}+page.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 | o7 Logo 7 |

Welcome to the o7 stack!

8 |

Next Steps:

9 |
10 | 14 |

15 | Edit src/routes/+page.svelte to see your 16 | changes live. 17 |

18 |

19 | The source for these cards is in src/lib/components/NextStep.svelte. 22 |

23 |

24 | There's some global styling in src/app.css. 27 |

28 |
29 | 33 |

34 | Write a Prisma schema in prisma/schema.prisma. 37 |

38 |

39 | Insert your database connection details into .env. 42 |

43 |

44 | Run pnpm db:push to update your database 45 | typings. 46 |

47 |

48 | Remember that you're using Kysely 53 | instead of Prisma to make your queries, and 54 | import {'{ db }'} from '$lib/db'! 55 |

56 |
57 |
58 |
59 | 60 | 66 | -------------------------------------------------------------------------------- /template_builder/templates/extras/src/routes/{Tailwind4,Trpc,D1|Planetscale|Sqlite|Turso}+page.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 |
9 | o7 Logo 10 | 14 |

{$greeting.data}

15 |

Next Steps:

16 |
17 | 21 |

22 | Edit src/routes/+page.svelte to see your 23 | changes live. 24 |

25 |

26 | The source for these cards is in src/lib/components/NextStep.svelte. 29 |

30 |

31 | There's some global styling in src/app.css. 34 |

35 |
36 | 40 |

41 | Write a Prisma schema in prisma/schema.prisma. 44 |

45 |

46 | Insert your database connection details into .env. 49 |

50 |

51 | Run pnpm db:push to update your database 52 | typings. 53 |

54 |

55 | Remember that you're using Kysely 60 | instead of Prisma to make your queries, and 61 | import {'{ db }'} from '$lib/db'! 62 |

63 |
64 | 65 |

66 | There's an example query in src/lib/server/routes/_app.ts. 69 |

70 |

71 | Also take a look at 72 | src/routes/+page.server.ts to see how server-side rendering works! 74 |

75 |
76 |
77 |
78 | 79 | 85 | -------------------------------------------------------------------------------- /template_builder/templates/extras/src/routes/{Tailwind4,Trpc}+page.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 |
9 | o7 Logo 10 | 14 |

{$greeting.data}

15 |

Next Steps:

16 |
17 | 21 |

22 | Edit src/routes/+page.svelte to see your 23 | changes live. 24 |

25 |

26 | The source for these cards is in src/lib/components/NextStep.svelte. 29 |

30 |

31 | There's some global styling in src/app.css. 34 |

35 |
36 | 37 |

38 | There's an example query in src/lib/server/routes/_app.ts. 41 |

42 |

43 | Also take a look at 44 | src/routes/+page.server.ts to see how server-side rendering works! 46 |

47 |
48 |
49 |
50 | 51 | 57 | -------------------------------------------------------------------------------- /template_builder/templates/extras/src/routes/{Tailwind4}+page.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 | o7 Logo 7 |

Welcome to the o7 stack!

8 |

Next Steps:

9 |
10 | 14 |

15 | Edit src/routes/+page.svelte to see your 16 | changes live. 17 |

18 |

19 | The source for these cards is in src/lib/components/NextStep.svelte. 22 |

23 |

24 | There's some global styling in src/app.css. 27 |

28 |
29 |
30 |
31 | 32 | 38 | -------------------------------------------------------------------------------- /template_builder/templates/extras/src/routes/{Trpc,D1|Planetscale|Sqlite|Turso}+page.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 |
9 | o7 Logo 10 | 14 |

{$greeting.data}

15 |

Next Steps:

16 |
17 | 21 |

22 | Edit src/routes/+page.svelte to see your 23 | changes live. 24 |

25 |

26 | The source for these cards is in src/lib/components/NextStep.svelte. 29 |

30 |

31 | There's some global styling in src/app.css. 34 |

35 |
36 | 40 |

41 | Write a Prisma schema in prisma/schema.prisma. 44 |

45 |

46 | Insert your database connection details into .env. 49 |

50 |

51 | Run pnpm db:push to update your database 52 | typings. 53 |

54 |

55 | Remember that you're using Kysely 60 | instead of Prisma to make your queries, and 61 | import {'{ db }'} from '$lib/db'! 62 |

63 |
64 | 65 |

66 | There's an example query in src/lib/server/routes/_app.ts. 69 |

70 |

71 | Also take a look at 72 | src/routes/+page.server.ts to see how server-side rendering works! 74 |

75 |
76 |
77 |
78 | 79 | 85 | -------------------------------------------------------------------------------- /template_builder/templates/extras/src/routes/{Trpc}+layout.server.ts: -------------------------------------------------------------------------------- 1 | import { trpcServer } from '$lib/server/server'; 2 | import type { LayoutServerLoad } from './$types'; 3 | 4 | export const load: LayoutServerLoad = async (event) => { 5 | return { 6 | trpc: trpcServer.hydrateToClient(event), 7 | }; 8 | }; 9 | -------------------------------------------------------------------------------- /template_builder/templates/extras/src/routes/{Trpc}+layout.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | {@render children()} 11 | -------------------------------------------------------------------------------- /template_builder/templates/extras/src/routes/{Trpc}+page.server.ts: -------------------------------------------------------------------------------- 1 | import { trpcServer } from '$lib/server/server'; 2 | import type { PageServerLoad } from './$types'; 3 | 4 | export const load: PageServerLoad = async () => { 5 | // You don't need to return the result of this function, 6 | // just call it and your data will be hydrated! 7 | await trpcServer.greeting.ssr({ name: 'the o7 stack' }); 8 | }; 9 | -------------------------------------------------------------------------------- /template_builder/templates/extras/src/routes/{Trpc}+page.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 |
9 | o7 Logo 10 | 14 |

{$greeting.data}

15 |

Next Steps:

16 |
17 | 21 |

22 | Edit src/routes/+page.svelte to see your 23 | changes live. 24 |

25 |

26 | The source for these cards is in src/lib/components/NextStep.svelte. 29 |

30 |

31 | There's some global styling in src/app.css. 34 |

35 |
36 | 37 |

38 | There's an example query in src/lib/server/routes/_app.ts. 41 |

42 |

43 | Also take a look at 44 | src/routes/+page.server.ts to see how server-side rendering works! 46 |

47 |
48 |
49 |
50 | 51 | 57 | -------------------------------------------------------------------------------- /template_builder/templates/extras/src/{Auth,D1}hooks.server.ts: -------------------------------------------------------------------------------- 1 | import { initDb } from '$lib/db'; 2 | import { initAuth, validateSessionToken } from '$lib/auth'; 3 | import { dev } from '$app/environment'; 4 | 5 | export async function handle({ event, resolve }) { 6 | const db = event.platform!.env.DB; 7 | initAuth(event.url.origin); 8 | initDb(db); 9 | const sessionToken = event.cookies.get('session'); 10 | if (!sessionToken) { 11 | event.locals.user = null; 12 | event.locals.session = null; 13 | return resolve(event); 14 | } 15 | 16 | const { session, user } = await validateSessionToken(sessionToken); 17 | if (session) { 18 | event.cookies.set('session', sessionToken, { 19 | path: '/', 20 | httpOnly: true, 21 | sameSite: 'lax', 22 | expires: session.expiresAt, 23 | secure: !dev, 24 | }); 25 | } else { 26 | event.cookies.delete('session', { 27 | path: '/', 28 | }); 29 | } 30 | 31 | event.locals.user = user; 32 | event.locals.session = session; 33 | return resolve(event); 34 | } 35 | -------------------------------------------------------------------------------- /template_builder/templates/extras/src/{Auth,Sqlite|Planetscale|Turso}hooks.server.ts: -------------------------------------------------------------------------------- 1 | import { initAuth, validateSessionToken } from '$lib/auth'; 2 | import { dev } from '$app/environment'; 3 | 4 | export async function handle({ event, resolve }) { 5 | initAuth(event.url.origin); 6 | const sessionToken = event.cookies.get('session'); 7 | if (!sessionToken) { 8 | event.locals.user = null; 9 | event.locals.session = null; 10 | return resolve(event); 11 | } 12 | 13 | const { session, user } = await validateSessionToken(sessionToken); 14 | if (session) { 15 | event.cookies.set('session', sessionToken, { 16 | path: '/', 17 | httpOnly: true, 18 | sameSite: 'lax', 19 | expires: session.expiresAt, 20 | secure: !dev, 21 | }); 22 | } else { 23 | event.cookies.delete('session', { 24 | path: '/', 25 | }); 26 | } 27 | 28 | event.locals.user = user; 29 | event.locals.session = session; 30 | return resolve(event); 31 | } 32 | -------------------------------------------------------------------------------- /template_builder/templates/extras/src/{Auth}app.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | namespace App { 3 | // interface Platform {} 4 | 5 | interface Locals { 6 | user: import('$lib/auth').User | null; 7 | session: import('$lib/auth').Session | null; 8 | } 9 | 10 | // interface Error {} 11 | // interface PageData {} 12 | // interface PageState {} 13 | } 14 | } 15 | 16 | export {}; 17 | -------------------------------------------------------------------------------- /template_builder/templates/extras/src/{D1}hooks.server.ts: -------------------------------------------------------------------------------- 1 | import { initDb } from '$lib/db'; 2 | 3 | export async function handle({ event, resolve }) { 4 | const db = event.platform!.env.DB; 5 | initDb(db); 6 | return resolve(event); 7 | } 8 | -------------------------------------------------------------------------------- /template_builder/templates/extras/src/{Edge,Auth,D1,Sidecar}app.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | namespace App { 3 | interface Platform { 4 | env: Cloudflare.Env & { 5 | SOCKET_OBJECT: DurableObjectNamespace< 6 | import('../worker/src/worker').SocketObject 7 | >; 8 | }; 9 | context: ExecutionContext; 10 | } 11 | 12 | interface Locals { 13 | user: import('$lib/auth').User | null; 14 | session: import('$lib/auth').Session | null; 15 | } 16 | 17 | // interface Error {} 18 | // interface PageData {} 19 | // interface PageState {} 20 | } 21 | } 22 | 23 | export {}; 24 | -------------------------------------------------------------------------------- /template_builder/templates/extras/src/{Edge,Auth,D1}app.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | namespace App { 3 | interface Platform { 4 | env: Cloudflare.Env; 5 | context: ExecutionContext; 6 | } 7 | 8 | interface Locals { 9 | user: import('$lib/auth').User | null; 10 | session: import('$lib/auth').Session | null; 11 | } 12 | 13 | // interface Error {} 14 | // interface PageData {} 15 | // interface PageState {} 16 | } 17 | } 18 | 19 | export {}; 20 | -------------------------------------------------------------------------------- /template_builder/templates/extras/src/{Edge,Auth,Sidecar}app.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | namespace App { 3 | interface Platform { 4 | env: Cloudflare.Env & { 5 | SOCKET_OBJECT: DurableObjectNamespace< 6 | import('../worker/src/worker').SocketObject 7 | >; 8 | }; 9 | context: ExecutionContext; 10 | } 11 | 12 | interface Locals { 13 | user: import('$lib/auth').User | null; 14 | session: import('$lib/auth').Session | null; 15 | } 16 | 17 | // interface Error {} 18 | // interface PageData {} 19 | // interface PageState {} 20 | } 21 | } 22 | 23 | export {}; 24 | -------------------------------------------------------------------------------- /template_builder/templates/extras/src/{Edge,Auth}app.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | namespace App { 3 | interface Platform { 4 | env: Cloudflare.Env; 5 | context: ExecutionContext; 6 | } 7 | 8 | interface Locals { 9 | user: import('$lib/auth').User | null; 10 | session: import('$lib/auth').Session | null; 11 | } 12 | 13 | // interface Error {} 14 | // interface PageData {} 15 | // interface PageState {} 16 | } 17 | } 18 | 19 | export {}; 20 | -------------------------------------------------------------------------------- /template_builder/templates/extras/src/{Edge,D1,Sidecar}app.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | namespace App { 3 | interface Platform { 4 | env: Cloudflare.Env & { 5 | SOCKET_OBJECT: DurableObjectNamespace< 6 | import('../worker/src/worker').SocketObject 7 | >; 8 | }; 9 | context: ExecutionContext; 10 | } 11 | 12 | // interface Locals {} 13 | // interface Error {} 14 | // interface PageData {} 15 | // interface PageState {} 16 | } 17 | } 18 | 19 | export {}; 20 | -------------------------------------------------------------------------------- /template_builder/templates/extras/src/{Edge,D1}app.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | namespace App { 3 | interface Platform { 4 | env: Cloudflare.Env; 5 | context: ExecutionContext; 6 | } 7 | 8 | // interface Locals {} 9 | // interface Error {} 10 | // interface PageData {} 11 | // interface PageState {} 12 | } 13 | } 14 | 15 | export {}; 16 | -------------------------------------------------------------------------------- /template_builder/templates/extras/src/{Edge,Sidecar}app.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | namespace App { 3 | interface Platform { 4 | env: Cloudflare.Env & { 5 | SOCKET_OBJECT: DurableObjectNamespace< 6 | import('../worker/src/worker').SocketObject 7 | >; 8 | }; 9 | context: ExecutionContext; 10 | } 11 | 12 | // interface Locals {} 13 | // interface Error {} 14 | // interface PageData {} 15 | // interface PageState {} 16 | } 17 | } 18 | 19 | export {}; 20 | -------------------------------------------------------------------------------- /template_builder/templates/extras/src/{Edge}app.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | namespace App { 3 | interface Platform { 4 | env: Cloudflare.Env; 5 | context: ExecutionContext; 6 | } 7 | 8 | // interface Locals {} 9 | // interface Error {} 10 | // interface PageData {} 11 | // interface PageState {} 12 | } 13 | } 14 | 15 | export {}; 16 | -------------------------------------------------------------------------------- /template_builder/templates/extras/src/{Tailwind4}app.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | 3 | body { 4 | background: var(--color-zinc-900); 5 | color: white; 6 | } 7 | -------------------------------------------------------------------------------- /template_builder/templates/extras/worker/src/{Sidecar}worker.ts: -------------------------------------------------------------------------------- 1 | import { DurableObject } from 'cloudflare:workers'; 2 | import { Hono } from 'hono'; 3 | import { type ServerToClient, clientToServerSchema } from 'common'; 4 | 5 | export class SocketObject extends DurableObject { 6 | constructor(ctx: DurableObjectState, env: Env) { 7 | super(ctx, env); 8 | } 9 | 10 | webSocketMessage( 11 | _ws: WebSocket, 12 | message: string | ArrayBuffer, 13 | ): void | Promise { 14 | if (typeof message !== 'string') return; 15 | const parsed = clientToServerSchema.safeParse(JSON.parse(message)); 16 | if (!parsed.success) return; 17 | const { type, payload } = parsed.data; 18 | if (type === 'broadcast') { 19 | const message = JSON.stringify({ 20 | type: 'message', 21 | payload, 22 | } as ServerToClient); 23 | for (const ws of this.ctx.getWebSockets()) { 24 | ws.send(message); 25 | } 26 | } 27 | } 28 | 29 | async fetch(_req: Request): Promise { 30 | const { 0: client, 1: server } = new WebSocketPair(); 31 | 32 | this.ctx.acceptWebSocket(server); 33 | return new Response(null, { 34 | status: 101, 35 | webSocket: client, 36 | }); 37 | } 38 | } 39 | 40 | const app = new Hono<{ Bindings: Env }>(); 41 | 42 | app.get('/ws/:room', (c) => { 43 | const upgrade = c.req.header('Upgrade'); 44 | if (upgrade !== 'websocket') { 45 | c.status(426); 46 | return c.body('WebSocket connection required'); 47 | } 48 | const id = c.env.SOCKET_OBJECT.idFromName(c.req.param('room')); 49 | const socket = c.env.SOCKET_OBJECT.get(id); 50 | return socket.fetch(c.req.raw); 51 | }); 52 | 53 | export default app; 54 | -------------------------------------------------------------------------------- /template_builder/templates/extras/worker/{Sidecar,Npm}package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "worker", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "wrangler dev", 7 | "deploy": "wrangler deploy", 8 | "typegen": "wrangler types" 9 | }, 10 | "dependencies": { 11 | "common": "^0.0.0", 12 | "hono": "^4.7.10" 13 | }, 14 | "devDependencies": { 15 | "typescript": "^5.8.3", 16 | "wrangler": "^4.17.0" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /template_builder/templates/extras/worker/{Sidecar}.gitignore: -------------------------------------------------------------------------------- 1 | .dev.vars 2 | .wrangler/ 3 | -------------------------------------------------------------------------------- /template_builder/templates/extras/worker/{Sidecar}package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "worker", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "wrangler dev", 7 | "deploy": "wrangler deploy", 8 | "typegen": "wrangler types" 9 | }, 10 | "dependencies": { 11 | "common": "workspace:*", 12 | "hono": "^4.7.10" 13 | }, 14 | "devDependencies": { 15 | "typescript": "^5.8.3", 16 | "wrangler": "^4.17.0" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /template_builder/templates/extras/worker/{Sidecar}tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2021", 4 | "lib": ["es2021"], 5 | "moduleResolution": "node", 6 | "module": "es2022", 7 | "allowJs": true, 8 | "checkJs": false, 9 | "esModuleInterop": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "resolveJsonModule": true, 12 | "skipLibCheck": true, 13 | "strict": true, 14 | "allowSyntheticDefaultImports": true, 15 | "noUncheckedIndexedAccess": true, 16 | "noEmit": true, 17 | "isolatedModules": true 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /template_builder/templates/extras/worker/{Sidecar}wrangler.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "node_modules/wrangler/config-schema.json", 3 | "name": "__o7__name__-worker", 4 | "main": "src/worker.ts", 5 | "compatibility_date": "2025-05-29", 6 | "durable_objects": { 7 | "bindings": [ 8 | { 9 | "name": "SOCKET_OBJECT", 10 | "class_name": "SocketObject" 11 | } 12 | ] 13 | }, 14 | "migrations": [ 15 | { 16 | "tag": "v1", 17 | "new_sqlite_classes": ["SocketObject"] 18 | } 19 | ], 20 | "observability": { 21 | "enabled": true 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /template_builder/templates/extras/{Auth}package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "arctic": "^3.7.0", 4 | "@oslojs/crypto": "^1.0.1", 5 | "@oslojs/encoding": "^1.1.0" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /template_builder/templates/extras/{D1,Auth}.env: -------------------------------------------------------------------------------- 1 | # The name of your D1 database 2 | DATABASE_NAME= 3 | 4 | # From https://dev.twitch.tv/console 5 | CLIENT_ID= 6 | CLIENT_SECRET= 7 | -------------------------------------------------------------------------------- /template_builder/templates/extras/{D1|Planetscale|Sqlite|Turso}package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "kysely": "^0.28.2" 4 | }, 5 | "devDependencies": { 6 | "prisma-kysely": "^1.8.0", 7 | "prisma": "^6.8.2" 8 | }, 9 | "pnpm": { 10 | "onlyBuiltDependencies": [ 11 | "@prisma/engines", 12 | "prisma" 13 | ] 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /template_builder/templates/extras/{D1|Planetscale|Sqlite}.prettierignore: -------------------------------------------------------------------------------- 1 | src/lib/db/schema.d.ts 2 | -------------------------------------------------------------------------------- /template_builder/templates/extras/{D1}.env: -------------------------------------------------------------------------------- 1 | DATABASE_NAME= 2 | -------------------------------------------------------------------------------- /template_builder/templates/extras/{D1}package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "db:push": "node ./prisma/push.mjs", 4 | "db:push:local": "node ./prisma/push.mjs --local" 5 | }, 6 | "dependencies": { 7 | "kysely-d1": "^0.4.0" 8 | }, 9 | "devDependencies": { 10 | "dotenv": "^16.5.0", 11 | "better-sqlite3": "^11.10.0", 12 | "@types/better-sqlite3": "^7.6.13" 13 | }, 14 | "pnpm": { 15 | "onlyBuiltDependencies": [ 16 | "better-sqlite3" 17 | ] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /template_builder/templates/extras/{Edge,D1,Sidecar}wrangler.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "node_modules/wrangler/config-schema.json", 3 | "name": "__o7__name__", 4 | "main": "./.svelte-kit/cloudflare/_worker.js", 5 | "compatibility_date": "2025-05-29", 6 | "compatibility_flags": ["nodejs_als"], 7 | "observability": { 8 | "enabled": true 9 | }, 10 | "assets": { 11 | "binding": "ASSETS", 12 | "directory": ".svelte-kit/cloudflare" 13 | }, 14 | "d1_databases": [ 15 | /* Run `pnpm wrangler d1 create ` before deploying! */ 16 | { 17 | "binding": "DB", 18 | "database_name": "", 19 | "database_id": "" 20 | } 21 | ], 22 | "durable_objects": { 23 | "bindings": [ 24 | { 25 | "script_name": "__o7__name__-worker", 26 | "class_name": "SocketObject", 27 | "name": "SOCKET_OBJECT" 28 | } 29 | ] 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /template_builder/templates/extras/{Edge,D1}wrangler.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "node_modules/wrangler/config-schema.json", 3 | "name": "__o7__name__", 4 | "main": "./.svelte-kit/cloudflare/_worker.js", 5 | "compatibility_date": "2025-05-29", 6 | "compatibility_flags": ["nodejs_als"], 7 | "observability": { 8 | "enabled": true 9 | }, 10 | "assets": { 11 | "binding": "ASSETS", 12 | "directory": ".svelte-kit/cloudflare" 13 | }, 14 | "d1_databases": [ 15 | /* Run `pnpm wrangler d1 create ` before deploying! */ 16 | { 17 | "binding": "DB", 18 | "database_name": "", 19 | "database_id": "" 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /template_builder/templates/extras/{Edge,Sidecar}wrangler.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "node_modules/wrangler/config-schema.json", 3 | "name": "__o7__name__", 4 | "main": "./.svelte-kit/cloudflare/_worker.js", 5 | "compatibility_date": "2025-05-29", 6 | "compatibility_flags": ["nodejs_als"], 7 | "observability": { 8 | "enabled": true 9 | }, 10 | "assets": { 11 | "binding": "ASSETS", 12 | "directory": ".svelte-kit/cloudflare" 13 | }, 14 | "durable_objects": { 15 | "bindings": [ 16 | { 17 | "script_name": "__o7__name__-worker", 18 | "class_name": "SocketObject", 19 | "name": "SOCKET_OBJECT" 20 | } 21 | ] 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /template_builder/templates/extras/{Edge}package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "typegen": "wrangler types src/worker-configuration.d.ts" 4 | }, 5 | "devDependencies": { 6 | "@sveltejs/adapter-cloudflare": "^7.0.3", 7 | "@types/node": "^22.15.24", 8 | "wrangler": "^4.17.0", 9 | "@sveltejs/adapter-auto": null 10 | }, 11 | "pnpm": { 12 | "onlyBuiltDependencies": [ 13 | "workerd", 14 | "sharp" 15 | ] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /template_builder/templates/extras/{Edge}svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from '@sveltejs/adapter-cloudflare'; 2 | import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; 3 | 4 | /** @type {import('@sveltejs/kit').Config} */ 5 | const config = { 6 | preprocess: [vitePreprocess()], 7 | kit: { 8 | adapter: adapter(), 9 | }, 10 | }; 11 | 12 | export default config; 13 | -------------------------------------------------------------------------------- /template_builder/templates/extras/{Edge}wrangler.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "node_modules/wrangler/config-schema.json", 3 | "name": "__o7__name__", 4 | "main": "./.svelte-kit/cloudflare/_worker.js", 5 | "compatibility_date": "2025-05-29", 6 | "compatibility_flags": ["nodejs_als"], 7 | "observability": { 8 | "enabled": true 9 | }, 10 | "assets": { 11 | "binding": "ASSETS", 12 | "directory": ".svelte-kit/cloudflare" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /template_builder/templates/extras/{Planetscale,Auth}.env: -------------------------------------------------------------------------------- 1 | DATABASE_HOST=aws.connect.psdb.cloud 2 | DATABASE_USERNAME= 3 | DATABASE_PASSWORD= 4 | DATABASE_NAME= 5 | 6 | DATABASE_URL='mysql://${DATABASE_USERNAME}:${DATABASE_PASSWORD}@${DATABASE_HOST}/${DATABASE_NAME}?sslaccept=string' 7 | 8 | # From https://dev.twitch.tv/console 9 | CLIENT_ID= 10 | CLIENT_SECRET= 11 | -------------------------------------------------------------------------------- /template_builder/templates/extras/{Planetscale}.env: -------------------------------------------------------------------------------- 1 | DATABASE_HOST=aws.connect.psdb.cloud 2 | DATABASE_USERNAME= 3 | DATABASE_PASSWORD= 4 | DATABASE_NAME= 5 | 6 | DATABASE_URL='mysql://${DATABASE_USERNAME}:${DATABASE_PASSWORD}@${DATABASE_HOST}/${DATABASE_NAME}?sslaccept=string' 7 | -------------------------------------------------------------------------------- /template_builder/templates/extras/{Planetscale}package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "db:push": "prisma db push" 4 | }, 5 | "dependencies": { 6 | "kysely-planetscale": "^1.7.1", 7 | "@planetscale/database": "^1.19.0" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /template_builder/templates/extras/{Sidecar,Edge}package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "typegen": "wrangler types src/worker-configuration.d.ts && wrangler types --cwd worker" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /template_builder/templates/extras/{Sidecar,Npm|Yarn|Bun}package.json: -------------------------------------------------------------------------------- 1 | { 2 | "workspaces": ["common", "worker", "."] 3 | } 4 | -------------------------------------------------------------------------------- /template_builder/templates/extras/{Sidecar,Npm}package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "common": "^0.0.0" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /template_builder/templates/extras/{Sidecar,Pnpm}pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'common' 3 | - 'worker' 4 | - '.' 5 | -------------------------------------------------------------------------------- /template_builder/templates/extras/{Sidecar}.npmrc: -------------------------------------------------------------------------------- 1 | ignore-workspace-root-check=true 2 | -------------------------------------------------------------------------------- /template_builder/templates/extras/{Sidecar}.prettierignore: -------------------------------------------------------------------------------- 1 | worker/worker-configuration.d.ts 2 | -------------------------------------------------------------------------------- /template_builder/templates/extras/{Sidecar}package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "common": "workspace:*" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /template_builder/templates/extras/{Sqlite,Auth}.env: -------------------------------------------------------------------------------- 1 | DATABASE_URL=file:db.sqlite 2 | 3 | # From https://dev.twitch.tv/console 4 | CLIENT_ID= 5 | CLIENT_SECRET= 6 | -------------------------------------------------------------------------------- /template_builder/templates/extras/{Sqlite}.env: -------------------------------------------------------------------------------- 1 | DATABASE_URL=file:db.sqlite 2 | -------------------------------------------------------------------------------- /template_builder/templates/extras/{Sqlite}package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "db:push": "prisma db push" 4 | }, 5 | "dependencies": { 6 | "better-sqlite3": "^11.10.0" 7 | }, 8 | "devDependencies": { 9 | "@types/better-sqlite3": "^7.6.13" 10 | }, 11 | "pnpm": { 12 | "onlyBuiltDependencies": [ 13 | "better-sqlite3" 14 | ] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /template_builder/templates/extras/{Tailwind4}DELETE:postcss.config.mjs: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ottomated/create-o7-app/13fa87dbd6b14a81bd1d51cd4d00bd6d0bd6ecaf/template_builder/templates/extras/{Tailwind4}DELETE:postcss.config.mjs -------------------------------------------------------------------------------- /template_builder/templates/extras/{Tailwind4}DELETE:tailwind.config.cjs: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ottomated/create-o7-app/13fa87dbd6b14a81bd1d51cd4d00bd6d0bd6ecaf/template_builder/templates/extras/{Tailwind4}DELETE:tailwind.config.cjs -------------------------------------------------------------------------------- /template_builder/templates/extras/{Tailwind4}package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "autoprefixer": null, 4 | "@tailwindcss/vite": "^4.1.8", 5 | "postcss": null, 6 | "postcss-load-config": null, 7 | "tailwindcss": "^4.1.8" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /template_builder/templates/extras/{Tailwind4}vite.config.ts: -------------------------------------------------------------------------------- 1 | import { sveltekit } from '@sveltejs/kit/vite'; 2 | import tailwindcss from '@tailwindcss/vite'; 3 | import { defineConfig } from 'vite'; 4 | 5 | export default defineConfig({ 6 | plugins: [sveltekit(), tailwindcss()], 7 | }); 8 | -------------------------------------------------------------------------------- /template_builder/templates/extras/{Trpc}package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@tanstack/svelte-query": "^5.77.2", 4 | "@trpc/client": "^11.1.4", 5 | "@trpc/server": "^11.1.4", 6 | "devalue": "^5.1.1", 7 | "trpc-svelte-query": "^3.0.3", 8 | "zod": "^3.25.35" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /template_builder/templates/extras/{Turso,Auth}.env: -------------------------------------------------------------------------------- 1 | TURSO_URL=libsql://example.turso.io 2 | TURSO_TOKEN= 3 | 4 | # From https://dev.twitch.tv/console 5 | CLIENT_ID= 6 | CLIENT_SECRET= 7 | -------------------------------------------------------------------------------- /template_builder/templates/extras/{Turso}.env: -------------------------------------------------------------------------------- 1 | TURSO_URL=libsql://example.turso.io 2 | TURSO_TOKEN= 3 | -------------------------------------------------------------------------------- /template_builder/templates/extras/{Turso}package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "db:push": "node ./prisma/push.mjs" 4 | }, 5 | "dependencies": { 6 | "kysely-libsql": "^0.7.1", 7 | "@libsql/client": "^0.15.7" 8 | }, 9 | "devDependencies": { 10 | "dotenv": "^16.5.0", 11 | "better-sqlite3": "^11.10.0", 12 | "@types/better-sqlite3": "^7.6.13" 13 | }, 14 | "pnpm": { 15 | "onlyBuiltDependencies": [ 16 | "better-sqlite3" 17 | ] 18 | } 19 | } 20 | --------------------------------------------------------------------------------