Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
6fdd719
feat(sv-utils): add resolveEnvMode env-mode detection
jycouet Jun 8, 2026
569c14d
feat(sv-utils): defineEnv.reference (import + accessor string)
jycouet Jun 8, 2026
5e02f9c
feat(sv-utils): defineEnv.declare get-or-create src/env.ts
jycouet Jun 8, 2026
e7d5062
style(sv-utils): hoist js import to top of env.ts
jycouet Jun 8, 2026
fb13bd8
feat(sv-utils): export defineEnv from index
jycouet Jun 8, 2026
b385cbf
fix(sv-utils): correct parseStatement cast in env.ts
jycouet Jun 8, 2026
7eab383
refactor(drizzle): emit env access via defineEnv
jycouet Jun 8, 2026
5bc88b2
refactor(better-auth): emit env access via defineEnv
jycouet Jun 8, 2026
436a2c3
test(cli): declared env on kit 2 + explicitEnvironmentVariables
jycouet Jun 8, 2026
d68ced2
refactor(sv-utils): export only defineEnv from package barrel
jycouet Jun 8, 2026
8edd92e
refactor(sv-utils): trim env public API to defineEnv + types
jycouet Jun 8, 2026
33d9a12
refactor(sv-utils): export only defineEnv (types stay internal)
jycouet Jun 8, 2026
b80333e
refactor(sv-utils): defineEnv takes { sv, cwd }; mode/language read f…
jycouet Jun 8, 2026
d81be47
fix(sv-utils): walk up to parent package.json for kit version detection
jycouet Jun 8, 2026
c508c96
refactor(sv-utils): pass kitVersion to defineEnv instead of re-derivi…
jycouet Jun 8, 2026
efb61a2
refactor(sv-utils): defineEnv takes dependencyVersion fn directly
jycouet Jun 8, 2026
29af04b
chore: changeset for env modes
jycouet Jun 8, 2026
f81c34f
chore: minimal changeset for env modes
jycouet Jun 8, 2026
58124b3
chore: split env changesets per package
jycouet Jun 8, 2026
c4a6889
cs
jycouet Jun 8, 2026
b1a92cf
fmt
jycouet Jun 8, 2026
5365eab
ffmmtt
jycouet Jun 8, 2026
1d02141
update location
jycouet Jun 8, 2026
eb47952
Merge branch 'main' of github.com:sveltejs/cli into env-and-more
jycouet Jun 9, 2026
5ae5a33
add desc to env & fix date drift in snap
jycouet Jun 9, 2026
b67ab07
format
jycouet Jun 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/env-sv-utils.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/sv-utils': minor
---

Add `defineEnv` helper for version-aware environment variable access (`$app/env` or legacy `$env`)
5 changes: 5 additions & 0 deletions .changeset/env-sv.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'sv': patch
---

chore: support kit's explicit environment variables in `drizzle` and `better-auth`
26 changes: 26 additions & 0 deletions packages/sv-utils/api-surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -873,6 +898,7 @@ export {
createPrinter,
index_d_exports$1 as css,
dedent,
defineEnv,
detect,
downloadJson,
fileExists,
Expand Down
166 changes: 166 additions & 0 deletions packages/sv-utils/src/env.ts
Original file line number Diff line number Diff line change
@@ -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<string, EnvVarSpec>();

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<string, AstTypes.Expression> = {};
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}`;
}
};
}
3 changes: 3 additions & 0 deletions packages/sv-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
4 changes: 2 additions & 2 deletions packages/sv-utils/src/svelte-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand Down
114 changes: 114 additions & 0 deletions packages/sv-utils/src/tests/env.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {}) {
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<string, string>) => (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();
});
});
Loading
Loading