diff --git a/.changeset/env-sv-utils.md b/.changeset/env-sv-utils.md new file mode 100644 index 000000000..bffc93f82 --- /dev/null +++ b/.changeset/env-sv-utils.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/sv-utils': minor +--- + +Add `defineEnv` helper for version-aware environment variable access (`$app/env` or legacy `$env`) diff --git a/.changeset/env-sv.md b/.changeset/env-sv.md new file mode 100644 index 000000000..5a839413b --- /dev/null +++ b/.changeset/env-sv.md @@ -0,0 +1,5 @@ +--- +'sv': patch +--- + +chore: support kit's explicit environment variables in `drizzle` and `better-auth` diff --git a/packages/sv-utils/api-surface.md b/packages/sv-utils/api-surface.md index 0107fc460..8eaaec176 100644 --- a/packages/sv-utils/api-surface.md +++ b/packages/sv-utils/api-surface.md @@ -827,6 +827,31 @@ declare const svelteConfig: { find: (source: ConfigSource) => SvelteConfigLocation | null; read: (source: ConfigSource) => SvelteConfigObjects | null; }; +type EnvMode = 'declared' | 'legacy'; +type EnvScope = 'private' | 'public'; +type EnvVarSpec = { + name: string; + description?: string; + public?: boolean; + static?: boolean; +}; +type DefineEnvContext = { + sv: SvFileApi; + cwd: string; + dependencyVersion: (pkg: string) => string | undefined; +}; +type ReferenceOpts = { + name: string; + scope?: EnvScope; + static?: boolean; +}; +type DefineEnv = { + mode: EnvMode; + define: (spec: EnvVarSpec) => void; + reference: (ast: estree.Program, js: typeof index_d_exports$3, opts: ReferenceOpts) => string; +}; + +declare function defineEnv({ sv, cwd, dependencyVersion }: DefineEnvContext): DefineEnv; type ColorInput = string | string[]; declare const color: { addon: (str: ColorInput) => string; @@ -873,6 +898,7 @@ export { createPrinter, index_d_exports$1 as css, dedent, + defineEnv, detect, downloadJson, fileExists, diff --git a/packages/sv-utils/src/env.ts b/packages/sv-utils/src/env.ts new file mode 100644 index 000000000..3c18a1d59 --- /dev/null +++ b/packages/sv-utils/src/env.ts @@ -0,0 +1,166 @@ +import { fileExists } from './files.ts'; +import { coerceVersion } from './semver.ts'; +import { svelteConfig, type ConfigFileReader, type SvFileApi } from './svelte-config.ts'; +import type { AstTypes } from './tooling/index.ts'; +import * as jsNs from './tooling/js/index.ts'; +import { transforms } from './tooling/transforms.ts'; + +export type EnvMode = 'declared' | 'legacy'; + +export function resolveEnvMode({ + kitRange, + explicitEnvFlag +}: { + kitRange: string | undefined; + explicitEnvFlag: boolean; +}): EnvMode { + if (!kitRange) return 'legacy'; + if (kitRange === 'next') return 'declared'; + const { major } = coerceVersion(kitRange); + if (major !== undefined && major >= 3) return 'declared'; + if (major === 2 && explicitEnvFlag) return 'declared'; + return 'legacy'; +} + +export type EnvScope = 'private' | 'public'; + +export type EnvVarSpec = { + name: string; + description?: string; + public?: boolean; + static?: boolean; +}; + +export type DefineEnvContext = { + sv: SvFileApi; + cwd: string; + /** Resolves a dependency's range (the workspace's authoritative, walk-up-aware lookup). */ + dependencyVersion: (pkg: string) => string | undefined; +}; + +export type ReferenceOpts = { name: string; scope?: EnvScope; static?: boolean }; + +export type DefineEnv = { + /** Resolved once from the project; add-ons don't need to consult it. */ + mode: EnvMode; + define: (spec: EnvVarSpec) => void; + reference: (ast: AstTypes.Program, js: typeof jsNs, opts: ReferenceOpts) => string; +}; + +function findProp(obj: AstTypes.ObjectExpression, name: string): AstTypes.Property | undefined { + return obj.properties.find( + (p): p is AstTypes.Property => + p.type === 'Property' && p.key.type === 'Identifier' && p.key.name === name + ); +} + +export function readExplicitEnvFlag(source: string | ConfigFileReader): boolean { + let objs; + try { + objs = svelteConfig.read(source); + } catch { + return false; + } + if (!objs) return false; + const experimental = findProp(objs.kit, 'experimental'); + if (!experimental || experimental.value.type !== 'ObjectExpression') return false; + const flag = findProp(experimental.value, 'explicitEnvironmentVariables'); + return !!flag && flag.value.type === 'Literal' && flag.value.value === true; +} + +function getOrCreateVariablesObject( + ast: AstTypes.Program, + js: typeof jsNs +): AstTypes.ObjectExpression { + for (const node of ast.body) { + if (node.type !== 'ExportNamedDeclaration') continue; + const decl = node.declaration; + if (decl?.type !== 'VariableDeclaration') continue; + const d = decl.declarations[0]; + if ( + d?.type === 'VariableDeclarator' && + d.id.type === 'Identifier' && + d.id.name === 'variables' && + d.init?.type === 'CallExpression' && + d.init.callee.type === 'Identifier' && + d.init.callee.name === 'defineEnvVars' && + d.init.arguments[0]?.type === 'ObjectExpression' + ) { + return d.init.arguments[0] as AstTypes.ObjectExpression; + } + } + const stmt = js.common.parseStatement( + 'export const variables = defineEnvVars({});' + ) as unknown as AstTypes.ExportNamedDeclaration; + ast.body.push(stmt); + const decl = stmt.declaration as AstTypes.VariableDeclaration; + const call = decl.declarations[0].init as AstTypes.CallExpression; + return call.arguments[0] as AstTypes.ObjectExpression; +} + +/** + * Detects the env mode from the project (the `@sveltejs/kit` range via `dependencyVersion`, or the + * kit-2 `explicitEnvironmentVariables` flag read from the config at `cwd`) and binds the context. Add-ons + * just call `define`/`reference` and never deal with the legacy-vs-declared distinction themselves. + */ +export function defineEnv({ sv, cwd, dependencyVersion }: DefineEnvContext): DefineEnv { + const mode = resolveEnvMode({ + kitRange: dependencyVersion('@sveltejs/kit'), + explicitEnvFlag: readExplicitEnvFlag(cwd) + }); + const language = fileExists(cwd, 'tsconfig.json') ? 'ts' : 'js'; + return _bindEnv({ sv, mode, language }); +} + +/** @internal The mode-resolved core, exported for filesystem-free tests. */ +export function _bindEnv({ + sv, + mode, + language +}: { + sv: SvFileApi; + mode: EnvMode; + language: 'ts' | 'js'; +}): DefineEnv { + const declared = new Map(); + + return { + mode, + define(spec) { + declared.set(spec.name, spec); + if (mode !== 'declared') return; + const envPath = `src/env.${language}`; + sv.file(envPath, (content) => + transforms.script(({ ast, js }) => { + js.imports.addNamed(ast, { from: '@sveltejs/kit/hooks', imports: ['defineEnvVars'] }); + const variables = getOrCreateVariablesObject(ast, js); + const entry = js.object.property(variables, { + name: spec.name, + fallback: js.object.create({}) + }) as AstTypes.ObjectExpression; + const props: Record = {}; + if (spec.description) props.description = js.common.createLiteral(spec.description); + if (spec.public) props.public = js.common.createLiteral(true); + if (spec.static) props.static = js.common.createLiteral(true); + if (Object.keys(props).length) js.object.overrideProperties(entry, props); + })(content) + ); + }, + reference(ast, js, opts) { + const spec = declared.get(opts.name); + const scope: EnvScope = opts.scope ?? (spec?.public ? 'public' : 'private'); + const isStatic = opts.static ?? spec?.static ?? false; + + if (mode === 'declared') { + js.imports.addNamed(ast, { from: `$app/env/${scope}`, imports: [opts.name] }); + return opts.name; + } + if (isStatic) { + js.imports.addNamed(ast, { from: `$env/static/${scope}`, imports: [opts.name] }); + return opts.name; + } + js.imports.addNamed(ast, { from: `$env/dynamic/${scope}`, imports: ['env'] }); + return `env.${opts.name}`; + } + }; +} diff --git a/packages/sv-utils/src/index.ts b/packages/sv-utils/src/index.ts index b43817a8a..b91c7b553 100644 --- a/packages/sv-utils/src/index.ts +++ b/packages/sv-utils/src/index.ts @@ -84,6 +84,9 @@ export { type SvelteConfigObjects } from './svelte-config.ts'; +// Env access (abstracts over legacy `$env/dynamic/*` vs declared `$app/env/*` + `src/env.ts`) +export { defineEnv } from './env.ts'; + // Terminal styling export { color } from './color.ts'; diff --git a/packages/sv-utils/src/svelte-config.ts b/packages/sv-utils/src/svelte-config.ts index 309623652..f62641824 100644 --- a/packages/sv-utils/src/svelte-config.ts +++ b/packages/sv-utils/src/svelte-config.ts @@ -133,8 +133,8 @@ export type SvelteConfEdit = (file: { override: (props: ObjectMap, opts?: { dropLeadingComments?: string[] }) => void; }) => void | false; -/** Minimal shape of the `sv` api needed to write the config file. */ -type SvFileApi = { +/** Minimal shape of the `sv` api needed to write a file. */ +export type SvFileApi = { file: (path: string, edit: (content: string) => string | false) => void; }; diff --git a/packages/sv-utils/src/tests/env.ts b/packages/sv-utils/src/tests/env.ts new file mode 100644 index 000000000..14f290c62 --- /dev/null +++ b/packages/sv-utils/src/tests/env.ts @@ -0,0 +1,114 @@ +import { describe, expect, test } from 'vitest'; +import { _bindEnv, readExplicitEnvFlag, resolveEnvMode, type EnvMode } from '../env.ts'; +import * as js from '../tooling/js/index.ts'; +import { parseScript } from '../tooling/parsers.ts'; + +describe('resolveEnvMode', () => { + test('no kit -> legacy', () => { + expect(resolveEnvMode({ kitRange: undefined, explicitEnvFlag: false })).toBe('legacy'); + }); + test('kit 2 default -> legacy', () => { + expect(resolveEnvMode({ kitRange: '^2.0.0', explicitEnvFlag: false })).toBe('legacy'); + }); + test('kit 2 + explicit flag -> declared', () => { + expect(resolveEnvMode({ kitRange: '^2.0.0', explicitEnvFlag: true })).toBe('declared'); + }); + test('kit 3 range -> declared', () => { + expect(resolveEnvMode({ kitRange: '^3.0.0-next.1', explicitEnvFlag: false })).toBe('declared'); + }); + test('next dist-tag -> declared', () => { + expect(resolveEnvMode({ kitRange: 'next', explicitEnvFlag: false })).toBe('declared'); + }); +}); + +/** A fake `sv` capturing file writes in-memory. */ +function fakeSv(files: Record = {}) { + return { + files, + sv: { + file(path: string, edit: (content: string) => string | false) { + const out = edit(files[path] ?? ''); + if (out !== false) files[path] = out; + } + } + }; +} + +function envFor(mode: EnvMode) { + const { sv, files } = fakeSv(); + const env = _bindEnv({ sv, mode, language: 'ts' }); + return { env, files }; +} + +describe('defineEnv.reference', () => { + test('declared: named import from $app/env/private + bare accessor', () => { + const { env } = envFor('declared'); + const { ast, generateCode } = parseScript(''); + const access = env.reference(ast, js, { name: 'DATABASE_URL' }); + expect(access).toBe('DATABASE_URL'); + expect(generateCode()).toContain("import { DATABASE_URL } from '$app/env/private';"); + }); + + test('legacy dynamic: env import + env.DATABASE_URL accessor', () => { + const { env } = envFor('legacy'); + const { ast, generateCode } = parseScript(''); + const access = env.reference(ast, js, { name: 'DATABASE_URL' }); + expect(access).toBe('env.DATABASE_URL'); + expect(generateCode()).toContain("import { env } from '$env/dynamic/private';"); + }); + + test('legacy static: named import from $env/static/public + bare accessor', () => { + const { env } = envFor('legacy'); + const { ast, generateCode } = parseScript(''); + const access = env.reference(ast, js, { name: 'PUBLIC_X', scope: 'public', static: true }); + expect(access).toBe('PUBLIC_X'); + expect(generateCode()).toContain("import { PUBLIC_X } from '$env/static/public';"); + }); +}); + +const reader = (files: Record) => (path: string) => files[path] ?? null; + +describe('readExplicitEnvFlag', () => { + test('true when set in svelte.config', () => { + const files = { + 'svelte.config.js': + 'export default { kit: { experimental: { explicitEnvironmentVariables: true } } };\n' + }; + expect(readExplicitEnvFlag(reader(files))).toBe(true); + }); + test('false when absent', () => { + const files = { 'svelte.config.js': 'export default { kit: {} };\n' }; + expect(readExplicitEnvFlag(reader(files))).toBe(false); + }); + test('false when no config', () => { + expect(readExplicitEnvFlag(reader({}))).toBe(false); + }); +}); + +describe('defineEnv.define', () => { + test('declared: creates src/env.ts with defineEnvVars + description', () => { + const { env, files } = envFor('declared'); + env.define({ name: 'DATABASE_URL', description: 'db url' }); + const out = files['src/env.ts']; + expect(out).toContain("import { defineEnvVars } from '@sveltejs/kit/hooks';"); + expect(out).toContain('export const variables = defineEnvVars({'); + expect(out).toContain('DATABASE_URL'); + expect(out).toContain("description: 'db url'"); + }); + + test('declared: second define merges into the same variables object', () => { + const { env, files } = envFor('declared'); + env.define({ name: 'DATABASE_URL' }); + env.define({ name: 'DATABASE_AUTH_TOKEN' }); + const out = files['src/env.ts']; + expect(out).toContain('DATABASE_URL'); + expect(out).toContain('DATABASE_AUTH_TOKEN'); + expect(out.match(/defineEnvVars/g)?.length).toBe(2); // import + one call, no duplicate object + }); + + test('legacy: define is a no-op (no env file written)', () => { + const { env, files } = envFor('legacy'); + env.define({ name: 'DATABASE_URL' }); + expect(files['src/env.ts']).toBeUndefined(); + }); +}); diff --git a/packages/sv/src/addons/better-auth.ts b/packages/sv/src/addons/better-auth.ts index 82cea8e25..6549fd5a3 100644 --- a/packages/sv/src/addons/better-auth.ts +++ b/packages/sv/src/addons/better-auth.ts @@ -8,7 +8,8 @@ import { resolveCommandArray, createPrinter, type TransformFn, - coerceVersion + coerceVersion, + defineEnv } from '@sveltejs/sv-utils'; import crypto from 'node:crypto'; import { defineAddon, defineAddonOptions } from '../core/config.ts'; @@ -40,8 +41,9 @@ export default defineAddon({ runsAfter('sveltekitAdapter'); runsAfter('tailwindcss'); + runsAfter('experimental'); }, - run: ({ sv, language, options, directory, dependencyVersion, file }) => { + run: ({ sv, cwd, language, options, directory, dependencyVersion, file }) => { const svelteVersion = dependencyVersion('svelte'); const svelte5 = !!svelteVersion && coerceVersion(svelteVersion).major === 5; const [ts, s5] = createPrinter(language === 'ts', svelte5); @@ -94,12 +96,34 @@ export default defineAddon({ sv.file('.env', generateEnv(demoGithub, false)); sv.file('.env.example', generateEnv(demoGithub, true)); + const env = defineEnv({ sv, cwd, dependencyVersion }); + env.define({ + name: 'ORIGIN', + description: 'The app origin (base URL), e.g. `http://localhost:5173`.' + }); + env.define({ + name: 'BETTER_AUTH_SECRET', + description: + 'Secret used to sign tokens. For production use 32 characters generated with high entropy. See [Better Auth installation](https://www.better-auth.com/docs/installation).' + }); + if (demoGithub) { + env.define({ + name: 'GITHUB_CLIENT_ID', + description: + 'GitHub OAuth client ID. See [Better Auth GitHub provider](https://www.better-auth.com/docs/authentication/github).' + }); + env.define({ + name: 'GITHUB_CLIENT_SECRET', + description: + 'GitHub OAuth client secret. See [Better Auth GitHub provider](https://www.better-auth.com/docs/authentication/github).' + }); + } + sv.file( `${directory.lib}/server/auth.${language}`, transforms.script(({ ast, comments, js }) => { js.imports.addNamed(ast, { from: '$lib/server/db', imports: [d1 ? 'getDb' : 'db'] }); js.imports.addNamed(ast, { from: '$app/server', imports: ['getRequestEvent'] }); - js.imports.addNamed(ast, { from: '$env/dynamic/private', imports: ['env'] }); js.imports.addNamed(ast, { from: 'better-auth/svelte-kit', imports: ['sveltekitCookies'] @@ -110,6 +134,13 @@ export default defineAddon({ }); js.imports.addNamed(ast, { from: 'better-auth/minimal', imports: ['betterAuth'] }); + const origin = env.reference(ast, js, { name: 'ORIGIN' }); + const secret = env.reference(ast, js, { name: 'BETTER_AUTH_SECRET' }); + const githubId = demoGithub ? env.reference(ast, js, { name: 'GITHUB_CLIENT_ID' }) : ''; + const githubSecret = demoGithub + ? env.reference(ast, js, { name: 'GITHUB_CLIENT_SECRET' }) + : ''; + const dialectMap: Record = { mysql: 'mysql', postgresql: 'pg', @@ -122,8 +153,8 @@ export default defineAddon({ ? ` socialProviders: { github: { - clientId: env.GITHUB_CLIENT_ID, - clientSecret: env.GITHUB_CLIENT_SECRET, + clientId: ${githubId}, + clientSecret: ${githubSecret}, }, },` : ''; @@ -132,8 +163,8 @@ export default defineAddon({ if (d1) { authConfig = dedent` const authConfig = { - baseURL: env.ORIGIN, - secret: env.BETTER_AUTH_SECRET, + baseURL: ${origin}, + secret: ${secret}, emailAndPassword: { enabled: true },${githubProvider} @@ -157,8 +188,8 @@ export default defineAddon({ } else { authConfig = dedent` export const auth = betterAuth({ - baseURL: env.ORIGIN, - secret: env.BETTER_AUTH_SECRET, + baseURL: ${origin}, + secret: ${secret}, database: drizzleAdapter(db, { provider: '${provider}' }), emailAndPassword: { enabled: true diff --git a/packages/sv/src/addons/drizzle.ts b/packages/sv/src/addons/drizzle.ts index 15dd4bbd1..ee0d7a605 100644 --- a/packages/sv/src/addons/drizzle.ts +++ b/packages/sv/src/addons/drizzle.ts @@ -7,7 +7,8 @@ import { resolveCommandArray, fileExists, createPrinter, - svelteConfig + svelteConfig, + defineEnv } from '@sveltejs/sv-utils'; import crypto from 'node:crypto'; import fs from 'node:fs'; @@ -88,6 +89,7 @@ export default defineAddon({ setup: ({ isKit, unsupported, runsAfter }) => { runsAfter('prettier'); runsAfter('sveltekitAdapter'); + runsAfter('experimental'); if (!isKit) return unsupported('Requires SvelteKit'); }, @@ -362,6 +364,17 @@ export default defineAddon({ }) ); + const env = defineEnv({ sv, cwd, dependencyVersion }); + if (options.database !== 'd1') { + env.define({ name: 'DATABASE_URL', description: 'The database connection string.' }); + if (options.sqlite === 'turso') { + env.define({ + name: 'DATABASE_AUTH_TOKEN', + description: 'Auth token for the [Turso](https://turso.tech) database.' + }); + } + } + sv.file( paths.database, transforms.script(({ ast, js }) => { @@ -378,14 +391,12 @@ export default defineAddon({ return; } - js.imports.addNamed(ast, { from: '$env/dynamic/private', imports: ['env'] }); + const dbUrl = env.reference(ast, js, { name: 'DATABASE_URL' }); js.imports.addNamespace(ast, { from: './schema', as: 'schema' }); - // env var checks - const dbURLCheck = js.common.parseStatement( - "if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set');" + ast.body.push( + js.common.parseStatement(`if (!${dbUrl}) throw new Error('DATABASE_URL is not set');`) ); - ast.body.push(dbURLCheck); let clientExpression; // SQLite @@ -396,7 +407,7 @@ export default defineAddon({ imports: ['drizzle'] }); - clientExpression = js.common.parseExpression('new Database(env.DATABASE_URL)'); + clientExpression = js.common.parseExpression(`new Database(${dbUrl})`); } if (options.sqlite === 'libsql' || options.sqlite === 'turso') { js.imports.addNamed(ast, { @@ -409,16 +420,17 @@ export default defineAddon({ }); if (options.sqlite === 'turso') { + const dbToken = env.reference(ast, js, { name: 'DATABASE_AUTH_TOKEN' }); ast.body.push( js.common.parseStatement( - "if (!env.DATABASE_AUTH_TOKEN) throw new Error('DATABASE_AUTH_TOKEN is not set');" + `if (!${dbToken}) throw new Error('DATABASE_AUTH_TOKEN is not set');` ) ); clientExpression = js.common.parseExpression( - 'createClient({ url: env.DATABASE_URL, authToken: env.DATABASE_AUTH_TOKEN })' + `createClient({ url: ${dbUrl}, authToken: ${dbToken} })` ); } else { - clientExpression = js.common.parseExpression('createClient({ url: env.DATABASE_URL })'); + clientExpression = js.common.parseExpression(`createClient({ url: ${dbUrl} })`); } } // MySQL @@ -429,7 +441,7 @@ export default defineAddon({ imports: ['drizzle'] }); - clientExpression = js.common.parseExpression('mysql.createPool(env.DATABASE_URL)'); + clientExpression = js.common.parseExpression(`mysql.createPool(${dbUrl})`); } // PostgreSQL if (options.postgresql === 'neon') { @@ -442,7 +454,7 @@ export default defineAddon({ imports: ['drizzle'] }); - clientExpression = js.common.parseExpression('neon(env.DATABASE_URL)'); + clientExpression = js.common.parseExpression(`neon(${dbUrl})`); } if (options.postgresql === 'postgres.js') { js.imports.addDefault(ast, { from: 'postgres', as: 'postgres' }); @@ -451,7 +463,7 @@ export default defineAddon({ imports: ['drizzle'] }); - clientExpression = js.common.parseExpression('postgres(env.DATABASE_URL)'); + clientExpression = js.common.parseExpression(`postgres(${dbUrl})`); } if (!clientExpression) throw new Error('unreachable state...'); diff --git a/packages/sv/src/cli/tests/cli.ts b/packages/sv/src/cli/tests/cli.ts index 8908fd040..dca2412a4 100644 --- a/packages/sv/src/cli/tests/cli.ts +++ b/packages/sv/src/cli/tests/cli.ts @@ -36,6 +36,16 @@ describe('cli', () => { // 'storybook' // No storybook addon during tests! ] }, + { + projectName: 'create-experimental', + args: [ + '--add', + 'sveltekit-adapter=adapter:cloudflare+cfTarget:workers', + 'drizzle=database:sqlite+sqlite:libsql', + 'better-auth=demo:password,github', + 'experimental=versions:+features:explicitEnvironmentVariables' + ] + }, { projectName: '@my-org/sv', template: 'addon', @@ -122,6 +132,14 @@ describe('cli', () => { generated = generated.replace(/sv@\d+\.\d+\.\d+/g, 'sv@0.0.0'); } + // Normalize the cloudflare adapter's `compatibility_date` (set to today) to avoid daily drift + if (relativeFile === 'wrangler.jsonc') { + generated = generated.replace( + /"compatibility_date": "\d{4}-\d{2}-\d{2}"/, + '"compatibility_date": "2020-01-01"' + ); + } + await expect(generated).toMatchFileSnapshot( path.resolve(snapPath, relativeFile), `file "${relativeFile}" does not match snapshot` @@ -151,6 +169,16 @@ describe('cli', () => { ).toBe(0); } + if (projectName === 'create-experimental') { + const read = (p: string) => fs.readFileSync(path.resolve(testOutputPath, p), 'utf-8'); + const envFile = read('src/env.ts'); + expect(envFile).toContain('defineEnvVars'); + expect(envFile).toContain('DATABASE_URL'); + expect(read('src/lib/server/db/index.ts')).toContain("from '$app/env/private'"); + expect(read('src/lib/server/auth.ts')).toContain("from '$app/env/private'"); + expect(read('src/lib/server/db/index.ts')).not.toContain('$env/dynamic/private'); + } + if (template === 'addon') { // replace sv and sv-utils versions in package.json for tests const packageJsonPath = path.resolve(testOutputPath, 'package.json'); diff --git a/packages/sv/src/cli/tests/snapshots/create-experimental/.env.example b/packages/sv/src/cli/tests/snapshots/create-experimental/.env.example new file mode 100644 index 000000000..34a24e9fe --- /dev/null +++ b/packages/sv/src/cli/tests/snapshots/create-experimental/.env.example @@ -0,0 +1,13 @@ +# Drizzle +DATABASE_URL=file:local.db + +ORIGIN="" + +# Better Auth +# For production use 32 characters and generated with high entropy +# https://www.better-auth.com/docs/installation +BETTER_AUTH_SECRET="" +# GitHub OAuth +# https://www.better-auth.com/docs/authentication/github +GITHUB_CLIENT_ID="" +GITHUB_CLIENT_SECRET="" diff --git a/packages/sv/src/cli/tests/snapshots/create-experimental/.gitignore b/packages/sv/src/cli/tests/snapshots/create-experimental/.gitignore new file mode 100644 index 000000000..23be53486 --- /dev/null +++ b/packages/sv/src/cli/tests/snapshots/create-experimental/.gitignore @@ -0,0 +1,25 @@ +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* +# SQLite +*.db diff --git a/packages/sv/src/cli/tests/snapshots/create-experimental/.npmrc b/packages/sv/src/cli/tests/snapshots/create-experimental/.npmrc new file mode 100644 index 000000000..b6f27f135 --- /dev/null +++ b/packages/sv/src/cli/tests/snapshots/create-experimental/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/packages/sv/src/cli/tests/snapshots/create-experimental/.vscode/extensions.json b/packages/sv/src/cli/tests/snapshots/create-experimental/.vscode/extensions.json new file mode 100644 index 000000000..28d1e67ad --- /dev/null +++ b/packages/sv/src/cli/tests/snapshots/create-experimental/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["svelte.svelte-vscode"] +} diff --git a/packages/sv/src/cli/tests/snapshots/create-experimental/README.md b/packages/sv/src/cli/tests/snapshots/create-experimental/README.md new file mode 100644 index 000000000..3f51a1b4d --- /dev/null +++ b/packages/sv/src/cli/tests/snapshots/create-experimental/README.md @@ -0,0 +1,42 @@ +# sv + +Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). + +## Creating a project + +If you're seeing this, you've probably already done this step. Congrats! + +```sh +# create a new project +npx sv create my-app +``` + +To recreate this project with the same configuration: + +```sh +# recreate this project +npx sv@0.0.0 create --template minimal --types ts --add sveltekit-adapter="adapter:cloudflare+cfTarget:workers" drizzle="database:sqlite+sqlite:libsql" better-auth="demo:password,github" experimental="versions:none+features:explicitEnvironmentVariables" --no-install packages/sv/.test-output/cli/create-experimental +``` + +## Developing + +Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: + +```sh +npm run dev + +# or start the server and open the app in a new browser tab +npm run dev -- --open +``` + +## Building + +To create a production version of your app: + +```sh +npm run build +``` + +You can preview the production build with `npm run preview`. + +> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. diff --git a/packages/sv/src/cli/tests/snapshots/create-experimental/drizzle.config.ts b/packages/sv/src/cli/tests/snapshots/create-experimental/drizzle.config.ts new file mode 100644 index 000000000..317f310ed --- /dev/null +++ b/packages/sv/src/cli/tests/snapshots/create-experimental/drizzle.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'drizzle-kit'; + +if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is not set'); + +export default defineConfig({ + schema: './src/lib/server/db/schema.ts', + dialect: 'sqlite', + dbCredentials: { url: process.env.DATABASE_URL }, + verbose: true, + strict: true +}); diff --git a/packages/sv/src/cli/tests/snapshots/create-experimental/package.json b/packages/sv/src/cli/tests/snapshots/create-experimental/package.json new file mode 100644 index 000000000..0236f1082 --- /dev/null +++ b/packages/sv/src/cli/tests/snapshots/create-experimental/package.json @@ -0,0 +1,35 @@ +{ + "name": "create-experimental", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "wrangler types --check && vite build", + "preview": "wrangler dev .svelte-kit/cloudflare/_worker.js --port 4173", + "prepare": "svelte-kit sync || echo ''", + "check": "wrangler types --check && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "gen": "wrangler types", + "db:push": "drizzle-kit push", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate", + "db:studio": "drizzle-kit studio", + "auth:schema": "better-auth generate --config src/lib/server/auth.ts --output src/lib/server/db/auth.schema.ts --yes" + }, + "devDependencies": { + "@better-auth/cli": "~1.4.21", + "@libsql/client": "^0.17.3", + "@sveltejs/adapter-cloudflare": "^7.2.8", + "@sveltejs/kit": "^2.63.0", + "@sveltejs/vite-plugin-svelte": "^7.1.2", + "better-auth": "~1.4.21", + "drizzle-kit": "^0.31.10", + "drizzle-orm": "^0.45.2", + "svelte": "^5.56.1", + "svelte-check": "^4.6.0", + "typescript": "^6.0.3", + "vite": "^8.0.16", + "wrangler": "^4.97.0" + } +} diff --git a/packages/sv/src/cli/tests/snapshots/create-experimental/pnpm-workspace.yaml b/packages/sv/src/cli/tests/snapshots/create-experimental/pnpm-workspace.yaml new file mode 100644 index 000000000..8dbdc836d --- /dev/null +++ b/packages/sv/src/cli/tests/snapshots/create-experimental/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +onlyBuiltDependencies: + - workerd + - sharp diff --git a/packages/sv/src/cli/tests/snapshots/create-experimental/src/app.d.ts b/packages/sv/src/cli/tests/snapshots/create-experimental/src/app.d.ts new file mode 100644 index 000000000..16910ac28 --- /dev/null +++ b/packages/sv/src/cli/tests/snapshots/create-experimental/src/app.d.ts @@ -0,0 +1,22 @@ +import type { User, Session } from 'better-auth'; + +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + interface Platform { + env: Env; + ctx: ExecutionContext; + caches: CacheStorage; + cf?: IncomingRequestCfProperties + } + + interface Locals { user?: User; session?: Session } + + // interface Error {} + // interface PageData {} + // interface PageState {} + } +} + +export {}; diff --git a/packages/sv/src/cli/tests/snapshots/create-experimental/src/app.html b/packages/sv/src/cli/tests/snapshots/create-experimental/src/app.html new file mode 100644 index 000000000..6a2bb5822 --- /dev/null +++ b/packages/sv/src/cli/tests/snapshots/create-experimental/src/app.html @@ -0,0 +1,12 @@ + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/packages/sv/src/cli/tests/snapshots/create-experimental/src/env.ts b/packages/sv/src/cli/tests/snapshots/create-experimental/src/env.ts new file mode 100644 index 000000000..ff20235e9 --- /dev/null +++ b/packages/sv/src/cli/tests/snapshots/create-experimental/src/env.ts @@ -0,0 +1,17 @@ +import { defineEnvVars } from '@sveltejs/kit/hooks'; + +export const variables = defineEnvVars({ + DATABASE_URL: { description: 'The database connection string.' }, + ORIGIN: { + description: 'The app origin (base URL), e.g. `http://localhost:5173`.' + }, + BETTER_AUTH_SECRET: { + description: 'Secret used to sign tokens. For production use 32 characters generated with high entropy. See [Better Auth installation](https://www.better-auth.com/docs/installation).' + }, + GITHUB_CLIENT_ID: { + description: 'GitHub OAuth client ID. See [Better Auth GitHub provider](https://www.better-auth.com/docs/authentication/github).' + }, + GITHUB_CLIENT_SECRET: { + description: 'GitHub OAuth client secret. See [Better Auth GitHub provider](https://www.better-auth.com/docs/authentication/github).' + } +}); diff --git a/packages/sv/src/cli/tests/snapshots/create-experimental/src/hooks.server.ts b/packages/sv/src/cli/tests/snapshots/create-experimental/src/hooks.server.ts new file mode 100644 index 000000000..22e93f9c4 --- /dev/null +++ b/packages/sv/src/cli/tests/snapshots/create-experimental/src/hooks.server.ts @@ -0,0 +1,17 @@ +import type { Handle } from '@sveltejs/kit'; +import { building } from '$app/environment'; +import { auth } from '$lib/server/auth'; +import { svelteKitHandler } from 'better-auth/svelte-kit'; + +const handleBetterAuth: Handle = async ({ event, resolve }) => { + const session = await auth.api.getSession({ headers: event.request.headers }); + + if (session) { + event.locals.session = session.session; + event.locals.user = session.user; + } + + return svelteKitHandler({ event, resolve, auth, building }); +}; + +export const handle: Handle = handleBetterAuth; diff --git a/packages/sv/src/cli/tests/snapshots/create-experimental/src/lib/index.ts b/packages/sv/src/cli/tests/snapshots/create-experimental/src/lib/index.ts new file mode 100644 index 000000000..856f2b6c3 --- /dev/null +++ b/packages/sv/src/cli/tests/snapshots/create-experimental/src/lib/index.ts @@ -0,0 +1 @@ +// place files you want to import through the `$lib` alias in this folder. diff --git a/packages/sv/src/cli/tests/snapshots/create-experimental/src/lib/server/auth.ts b/packages/sv/src/cli/tests/snapshots/create-experimental/src/lib/server/auth.ts new file mode 100644 index 000000000..c9857fab5 --- /dev/null +++ b/packages/sv/src/cli/tests/snapshots/create-experimental/src/lib/server/auth.ts @@ -0,0 +1,28 @@ +import { + ORIGIN, + BETTER_AUTH_SECRET, + GITHUB_CLIENT_ID, + GITHUB_CLIENT_SECRET +} from '$app/env/private'; + +import { betterAuth } from 'better-auth/minimal'; +import { drizzleAdapter } from 'better-auth/adapters/drizzle'; +import { sveltekitCookies } from 'better-auth/svelte-kit'; +import { getRequestEvent } from '$app/server'; +import { db } from '$lib/server/db'; + +export const auth = betterAuth({ + baseURL: ORIGIN, + secret: BETTER_AUTH_SECRET, + database: drizzleAdapter(db, { provider: 'sqlite' }), + emailAndPassword: { enabled: true }, + socialProviders: { + github: { + clientId: GITHUB_CLIENT_ID, + clientSecret: GITHUB_CLIENT_SECRET + } + }, + plugins: [ + sveltekitCookies(getRequestEvent) // make sure this is the last plugin in the array + ] +}); diff --git a/packages/sv/src/cli/tests/snapshots/create-experimental/src/lib/server/db/auth.schema.ts b/packages/sv/src/cli/tests/snapshots/create-experimental/src/lib/server/db/auth.schema.ts new file mode 100644 index 000000000..952b9b328 --- /dev/null +++ b/packages/sv/src/cli/tests/snapshots/create-experimental/src/lib/server/db/auth.schema.ts @@ -0,0 +1 @@ +// If you see this file, you have not run the auth:schema script yet, but you should! diff --git a/packages/sv/src/cli/tests/snapshots/create-experimental/src/lib/server/db/index.ts b/packages/sv/src/cli/tests/snapshots/create-experimental/src/lib/server/db/index.ts new file mode 100644 index 000000000..2f0cd7aa7 --- /dev/null +++ b/packages/sv/src/cli/tests/snapshots/create-experimental/src/lib/server/db/index.ts @@ -0,0 +1,10 @@ +import { drizzle } from 'drizzle-orm/libsql'; +import { createClient } from '@libsql/client'; +import * as schema from './schema'; +import { DATABASE_URL } from '$app/env/private'; + +if (!DATABASE_URL) throw new Error('DATABASE_URL is not set'); + +const client = createClient({ url: DATABASE_URL }); + +export const db = drizzle(client, { schema }); diff --git a/packages/sv/src/cli/tests/snapshots/create-experimental/src/lib/server/db/schema.ts b/packages/sv/src/cli/tests/snapshots/create-experimental/src/lib/server/db/schema.ts new file mode 100644 index 000000000..35de29be6 --- /dev/null +++ b/packages/sv/src/cli/tests/snapshots/create-experimental/src/lib/server/db/schema.ts @@ -0,0 +1,9 @@ +import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; + +export const task = sqliteTable('task', { + id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()), + title: text('title').notNull(), + priority: integer('priority').notNull().default(1) +}); + +export * from './auth.schema'; diff --git a/packages/sv/src/cli/tests/snapshots/create-experimental/src/routes/+layout.svelte b/packages/sv/src/cli/tests/snapshots/create-experimental/src/routes/+layout.svelte new file mode 100644 index 000000000..9cebde545 --- /dev/null +++ b/packages/sv/src/cli/tests/snapshots/create-experimental/src/routes/+layout.svelte @@ -0,0 +1,11 @@ + + + + + + +{@render children()} diff --git a/packages/sv/src/cli/tests/snapshots/create-experimental/src/routes/+page.svelte b/packages/sv/src/cli/tests/snapshots/create-experimental/src/routes/+page.svelte new file mode 100644 index 000000000..cc88df0ea --- /dev/null +++ b/packages/sv/src/cli/tests/snapshots/create-experimental/src/routes/+page.svelte @@ -0,0 +1,2 @@ +

Welcome to SvelteKit

+

Visit svelte.dev/docs/kit to read the documentation

diff --git a/packages/sv/src/cli/tests/snapshots/create-experimental/src/routes/demo/+page.svelte b/packages/sv/src/cli/tests/snapshots/create-experimental/src/routes/demo/+page.svelte new file mode 100644 index 000000000..948d26f74 --- /dev/null +++ b/packages/sv/src/cli/tests/snapshots/create-experimental/src/routes/demo/+page.svelte @@ -0,0 +1,5 @@ + + +better-auth diff --git a/packages/sv/src/cli/tests/snapshots/create-experimental/src/routes/demo/better-auth/+page.server.ts b/packages/sv/src/cli/tests/snapshots/create-experimental/src/routes/demo/better-auth/+page.server.ts new file mode 100644 index 000000000..92a1f2ec3 --- /dev/null +++ b/packages/sv/src/cli/tests/snapshots/create-experimental/src/routes/demo/better-auth/+page.server.ts @@ -0,0 +1,20 @@ +import { redirect } from '@sveltejs/kit'; +import type { Actions } from './$types'; +import type { PageServerLoad } from './$types'; +import { auth } from '$lib/server/auth'; + +export const load: PageServerLoad = (event) => { + if (!event.locals.user) { + return redirect(302, '/demo/better-auth/login'); + } + return { user: event.locals.user }; +}; + +export const actions: Actions = { + signOut: async (event) => { + await auth.api.signOut({ + headers: event.request.headers + }); + return redirect(302, '/demo/better-auth/login'); + } +}; diff --git a/packages/sv/src/cli/tests/snapshots/create-experimental/src/routes/demo/better-auth/+page.svelte b/packages/sv/src/cli/tests/snapshots/create-experimental/src/routes/demo/better-auth/+page.svelte new file mode 100644 index 000000000..b05d0e463 --- /dev/null +++ b/packages/sv/src/cli/tests/snapshots/create-experimental/src/routes/demo/better-auth/+page.svelte @@ -0,0 +1,12 @@ + + +

Hi, {data.user.name}!

+

Your user ID is {data.user.id}.

+
+ +
diff --git a/packages/sv/src/cli/tests/snapshots/create-experimental/src/routes/demo/better-auth/login/+page.server.ts b/packages/sv/src/cli/tests/snapshots/create-experimental/src/routes/demo/better-auth/login/+page.server.ts new file mode 100644 index 000000000..d97509ba9 --- /dev/null +++ b/packages/sv/src/cli/tests/snapshots/create-experimental/src/routes/demo/better-auth/login/+page.server.ts @@ -0,0 +1,78 @@ +import { fail, redirect } from '@sveltejs/kit'; +import type { Actions } from './$types'; +import type { PageServerLoad } from './$types'; +import { auth } from '$lib/server/auth'; +import { APIError } from 'better-auth/api'; + +export const load: PageServerLoad = (event) => { + if (event.locals.user) { + return redirect(302, '/demo/better-auth'); + } + return {}; +}; + +export const actions: Actions = { + signInEmail: async (event) => { + const formData = await event.request.formData(); + const email = formData.get('email')?.toString() ?? ''; + const password = formData.get('password')?.toString() ?? ''; + + try { + await auth.api.signInEmail({ + body: { + email, + password, + callbackURL: '/auth/verification-success' + } + }); + } catch (error) { + if (error instanceof APIError) { + return fail(400, { message: error.message || 'Signin failed' }); + } + return fail(500, { message: 'Unexpected error' }); + } + + return redirect(302, '/demo/better-auth'); + }, + signUpEmail: async (event) => { + const formData = await event.request.formData(); + const email = formData.get('email')?.toString() ?? ''; + const password = formData.get('password')?.toString() ?? ''; + const name = formData.get('name')?.toString() ?? ''; + + try { + await auth.api.signUpEmail({ + body: { + email, + password, + name, + callbackURL: '/auth/verification-success' + } + }); + } catch (error) { + if (error instanceof APIError) { + return fail(400, { message: error.message || 'Registration failed' }); + } + return fail(500, { message: 'Unexpected error' }); + } + + return redirect(302, '/demo/better-auth'); + }, + signInSocial: async (event) => { + const formData = await event.request.formData(); + const provider = formData.get('provider')?.toString() ?? 'github'; + const callbackURL = formData.get('callbackURL')?.toString() ?? '/demo/better-auth'; + + const result = await auth.api.signInSocial({ + body: { + provider: provider as "github", + callbackURL + } + }); + + if (result.url) { + return redirect(302, result.url); + } + return fail(400, { message: 'Social sign-in failed' }); + }, +}; diff --git a/packages/sv/src/cli/tests/snapshots/create-experimental/src/routes/demo/better-auth/login/+page.svelte b/packages/sv/src/cli/tests/snapshots/create-experimental/src/routes/demo/better-auth/login/+page.svelte new file mode 100644 index 000000000..b20008d6a --- /dev/null +++ b/packages/sv/src/cli/tests/snapshots/create-experimental/src/routes/demo/better-auth/login/+page.svelte @@ -0,0 +1,33 @@ + + +

Login

+
+ + + + + +
+

{form?.message ?? ''}

+ +
+ +
+ + + +
diff --git a/packages/sv/src/cli/tests/snapshots/create-experimental/static/robots.txt b/packages/sv/src/cli/tests/snapshots/create-experimental/static/robots.txt new file mode 100644 index 000000000..b6dd6670c --- /dev/null +++ b/packages/sv/src/cli/tests/snapshots/create-experimental/static/robots.txt @@ -0,0 +1,3 @@ +# allow crawling everything by default +User-agent: * +Disallow: diff --git a/packages/sv/src/cli/tests/snapshots/create-experimental/tsconfig.json b/packages/sv/src/cli/tests/snapshots/create-experimental/tsconfig.json new file mode 100644 index 000000000..333f76845 --- /dev/null +++ b/packages/sv/src/cli/tests/snapshots/create-experimental/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "rewriteRelativeImportExtensions": true, + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler", + "types": [ + "./worker-configuration.d.ts" + ] + } +} diff --git a/packages/sv/src/cli/tests/snapshots/create-experimental/vite.config.ts b/packages/sv/src/cli/tests/snapshots/create-experimental/vite.config.ts new file mode 100644 index 000000000..a6b70ef19 --- /dev/null +++ b/packages/sv/src/cli/tests/snapshots/create-experimental/vite.config.ts @@ -0,0 +1,22 @@ +import adapter from '@sveltejs/adapter-cloudflare'; +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [ + sveltekit({ + compilerOptions: { + // Force runes mode for the project, except for libraries. Can be removed in svelte 6. + runes: ({ filename }) => filename.split(/[/\\]/).includes('node_modules') ? undefined : true + }, + adapter: adapter(), + experimental: { explicitEnvironmentVariables: true }, + typescript: { + config: (config) => ({ + ...config, + include: [...config.include, '../drizzle.config.ts'] + }) + } + }) + ] +}); diff --git a/packages/sv/src/cli/tests/snapshots/create-experimental/wrangler.jsonc b/packages/sv/src/cli/tests/snapshots/create-experimental/wrangler.jsonc new file mode 100644 index 000000000..0dd99a5f3 --- /dev/null +++ b/packages/sv/src/cli/tests/snapshots/create-experimental/wrangler.jsonc @@ -0,0 +1,15 @@ +{ + "$schema": "./node_modules/wrangler/config-schema.json", + "name": "create-experimental", + "compatibility_date": "2020-01-01", + "compatibility_flags": [ + "nodejs_als" + ], + "main": ".svelte-kit/cloudflare/_worker.js", + "assets": { + "binding": "ASSETS", + "directory": ".svelte-kit/cloudflare" + }, + "workers_dev": true, + "preview_urls": true +} diff --git a/packages/sv/src/cli/tests/snapshots/create-with-all-addons/src/lib/server/auth.ts b/packages/sv/src/cli/tests/snapshots/create-with-all-addons/src/lib/server/auth.ts index b05416873..fc0fdf8fe 100644 --- a/packages/sv/src/cli/tests/snapshots/create-with-all-addons/src/lib/server/auth.ts +++ b/packages/sv/src/cli/tests/snapshots/create-with-all-addons/src/lib/server/auth.ts @@ -1,7 +1,7 @@ +import { env } from '$env/dynamic/private'; import { betterAuth } from 'better-auth/minimal'; import { drizzleAdapter } from 'better-auth/adapters/drizzle'; import { sveltekitCookies } from 'better-auth/svelte-kit'; -import { env } from '$env/dynamic/private'; import { getRequestEvent } from '$app/server'; import { db } from '$lib/server/db';