@penvhq/cli 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.cts CHANGED
@@ -250,14 +250,18 @@ declare function runGet(options: GetOptions): Promise<string>;
250
250
  declare function runExplain(options: GetOptions): Promise<GetExplanation>;
251
251
 
252
252
  /** What init touched, so a caller can report it and a test can assert it. */
253
- type InitTarget = "penv-dir" | "schema" | "config" | "tsconfig" | "gitignore";
253
+ type InitTarget = "penv-dir" | "schema" | "config" | "tsconfig" | "gitignore" | "seam";
254
254
  /**
255
255
  * `conflicted` is the one that is not a success. penv wanted to write something,
256
256
  * found the user's file already saying something else about the same thing, and
257
257
  * left it alone — so the step is reported with a warning rather than a ✓, and the
258
258
  * text says what will not work until the user decides.
259
+ *
260
+ * `info` is a step penv did not perform automatically — a manual instruction (the
261
+ * injection seam for a framework penv cannot scaffold), reported so the user
262
+ * knows the one thing left to do.
259
263
  */
260
- type InitAction = "created" | "kept" | "updated" | "conflicted";
264
+ type InitAction = "created" | "kept" | "updated" | "conflicted" | "info";
261
265
  interface InitStep {
262
266
  readonly target: InitTarget;
263
267
  readonly action: InitAction;
@@ -289,6 +293,13 @@ interface InitDecisions {
289
293
  * offers that.
290
294
  */
291
295
  readonly alias: string;
296
+ /**
297
+ * Whether to inject the validated config into `process.env` for libraries that
298
+ * read it directly, so `env.ts` loads with `{ inject: true }` and penv places
299
+ * the framework's pre-app seam. Off by default and only ever turned on by an
300
+ * explicit yes — a project that reads config only through `@env` gets none.
301
+ */
302
+ readonly inject: boolean;
292
303
  }
293
304
  interface InitResult {
294
305
  readonly root: string;
@@ -299,6 +310,8 @@ interface InitOptions {
299
310
  readonly cwd: string;
300
311
  /** What to write. Omitted means the plan's defaults, as `--yes` takes them. */
301
312
  readonly decisions?: InitDecisions;
313
+ /** The detected framework name, passed by the command so the seam step need not re-detect it. */
314
+ readonly framework?: string;
302
315
  }
303
316
  interface AliasEdit {
304
317
  readonly source: string;
package/dist/index.d.ts CHANGED
@@ -250,14 +250,18 @@ declare function runGet(options: GetOptions): Promise<string>;
250
250
  declare function runExplain(options: GetOptions): Promise<GetExplanation>;
251
251
 
252
252
  /** What init touched, so a caller can report it and a test can assert it. */
253
- type InitTarget = "penv-dir" | "schema" | "config" | "tsconfig" | "gitignore";
253
+ type InitTarget = "penv-dir" | "schema" | "config" | "tsconfig" | "gitignore" | "seam";
254
254
  /**
255
255
  * `conflicted` is the one that is not a success. penv wanted to write something,
256
256
  * found the user's file already saying something else about the same thing, and
257
257
  * left it alone — so the step is reported with a warning rather than a ✓, and the
258
258
  * text says what will not work until the user decides.
259
+ *
260
+ * `info` is a step penv did not perform automatically — a manual instruction (the
261
+ * injection seam for a framework penv cannot scaffold), reported so the user
262
+ * knows the one thing left to do.
259
263
  */
260
- type InitAction = "created" | "kept" | "updated" | "conflicted";
264
+ type InitAction = "created" | "kept" | "updated" | "conflicted" | "info";
261
265
  interface InitStep {
262
266
  readonly target: InitTarget;
263
267
  readonly action: InitAction;
@@ -289,6 +293,13 @@ interface InitDecisions {
289
293
  * offers that.
290
294
  */
291
295
  readonly alias: string;
296
+ /**
297
+ * Whether to inject the validated config into `process.env` for libraries that
298
+ * read it directly, so `env.ts` loads with `{ inject: true }` and penv places
299
+ * the framework's pre-app seam. Off by default and only ever turned on by an
300
+ * explicit yes — a project that reads config only through `@env` gets none.
301
+ */
302
+ readonly inject: boolean;
292
303
  }
293
304
  interface InitResult {
294
305
  readonly root: string;
@@ -299,6 +310,8 @@ interface InitOptions {
299
310
  readonly cwd: string;
300
311
  /** What to write. Omitted means the plan's defaults, as `--yes` takes them. */
301
312
  readonly decisions?: InitDecisions;
313
+ /** The detected framework name, passed by the command so the seam step need not re-detect it. */
314
+ readonly framework?: string;
302
315
  }
303
316
  interface AliasEdit {
304
317
  readonly source: string;
package/dist/index.js CHANGED
@@ -350,19 +350,19 @@ import {
350
350
  refFromAccessPath as refFromAccessPath2,
351
351
  variableName
352
352
  } from "@penvhq/core";
353
- function defOf(node) {
354
- if (typeof node !== "object" || node === null) {
353
+ function defOf(node2) {
354
+ if (typeof node2 !== "object" || node2 === null) {
355
355
  return void 0;
356
356
  }
357
- const def = node.def;
357
+ const def = node2.def;
358
358
  return typeof def === "object" && def !== null ? def : void 0;
359
359
  }
360
- function typeOf(node) {
361
- const type = defOf(node)?.type;
360
+ function typeOf(node2) {
361
+ const type = defOf(node2)?.type;
362
362
  return typeof type === "string" ? type : void 0;
363
363
  }
364
- function unwrap(node) {
365
- let current = node;
364
+ function unwrap(node2) {
365
+ let current = node2;
366
366
  for (let depth = 0; depth < 8; depth += 1) {
367
367
  const inner = defOf(current)?.innerType;
368
368
  if (inner === void 0) {
@@ -372,38 +372,38 @@ function unwrap(node) {
372
372
  }
373
373
  return current;
374
374
  }
375
- function shapeOf(node) {
376
- if (typeOf(node) !== "object") {
375
+ function shapeOf(node2) {
376
+ if (typeOf(node2) !== "object") {
377
377
  return void 0;
378
378
  }
379
- const shape = node.shape;
379
+ const shape = node2.shape;
380
380
  return typeof shape === "object" && shape !== null ? shape : void 0;
381
381
  }
382
382
  function lookup(root, path) {
383
- let node = unwrap(root);
383
+ let node2 = unwrap(root);
384
384
  for (const key of path) {
385
- const shape = shapeOf(node);
385
+ const shape = shapeOf(node2);
386
386
  if (shape === void 0) {
387
387
  return { kind: "unknown" };
388
388
  }
389
389
  if (!Object.hasOwn(shape, key)) {
390
390
  return { kind: "absent" };
391
391
  }
392
- node = unwrap(shape[key]);
392
+ node2 = unwrap(shape[key]);
393
393
  }
394
- return { kind: "found", node };
394
+ return { kind: "found", node: node2 };
395
395
  }
396
- function minLengthOf(node) {
397
- if (typeOf(node) !== "string") {
396
+ function minLengthOf(node2) {
397
+ if (typeOf(node2) !== "string") {
398
398
  return void 0;
399
399
  }
400
- const min = node.minLength;
400
+ const min = node2.minLength;
401
401
  return typeof min === "number" ? min : void 0;
402
402
  }
403
403
  var ABSENCE_PERMITTED = /* @__PURE__ */ new Set(["optional", "default", "catch", "prefault"]);
404
404
  var ABSENCE_REFUSED = /* @__PURE__ */ new Set(["nullable", "nonoptional", "readonly"]);
405
- function permitsAbsence(node) {
406
- let current = node;
405
+ function permitsAbsence(node2) {
406
+ let current = node2;
407
407
  for (let depth = 0; depth < 8; depth += 1) {
408
408
  const type = typeOf(current);
409
409
  if (type !== void 0 && ABSENCE_PERMITTED.has(type)) {
@@ -420,13 +420,13 @@ function permitsAbsence(node) {
420
420
  }
421
421
  return void 0;
422
422
  }
423
- function leaves(node, path, inherited, out2) {
424
- const own = permitsAbsence(node);
423
+ function leaves(node2, path, inherited, out2) {
424
+ const own = permitsAbsence(node2);
425
425
  const absencePermitted = inherited === true || own === true ? true : combine(inherited, own);
426
- const shape = shapeOf(unwrap(node));
426
+ const shape = shapeOf(unwrap(node2));
427
427
  if (shape === void 0) {
428
428
  if (path.length > 0) {
429
- out2.push({ path, absencePermitted, node });
429
+ out2.push({ path, absencePermitted, node: node2 });
430
430
  }
431
431
  return;
432
432
  }
@@ -442,8 +442,8 @@ function declaredLeaves(schema) {
442
442
  leaves(schema, [], false, out2);
443
443
  return out2;
444
444
  }
445
- function defaultValueOf(node) {
446
- let current = node;
445
+ function defaultValueOf(node2) {
446
+ let current = node2;
447
447
  for (let depth = 0; depth < 8; depth += 1) {
448
448
  const def = defOf(current);
449
449
  if (def === void 0) {
@@ -1110,18 +1110,18 @@ function place(root, path, value) {
1110
1110
  if (leaf === void 0) {
1111
1111
  return;
1112
1112
  }
1113
- let node = root;
1113
+ let node2 = root;
1114
1114
  for (const key of path.slice(0, -1)) {
1115
- const existing = node[key];
1115
+ const existing = node2[key];
1116
1116
  if (typeof existing === "object" && existing !== null) {
1117
- node = existing;
1117
+ node2 = existing;
1118
1118
  continue;
1119
1119
  }
1120
1120
  const child = {};
1121
- node[key] = child;
1122
- node = child;
1121
+ node2[key] = child;
1122
+ node2 = child;
1123
1123
  }
1124
- node[leaf] = value;
1124
+ node2[leaf] = value;
1125
1125
  }
1126
1126
  function isZodType(value) {
1127
1127
  return typeof value === "object" && value !== null && "safeParse" in value && typeof value.safeParse === "function";
@@ -2805,6 +2805,8 @@ import { join } from "path";
2805
2805
  import { DEFAULT_SCHEMA_FILE } from "@penvhq/core";
2806
2806
  var SIGNATURES = [
2807
2807
  { name: "Next.js", packages: ["next"], publicPrefixes: ["NEXT_PUBLIC_"] },
2808
+ { name: "SvelteKit", packages: ["@sveltejs/kit"], publicPrefixes: ["PUBLIC_"] },
2809
+ { name: "Nuxt", packages: ["nuxt"], publicPrefixes: ["NUXT_PUBLIC_"] },
2808
2810
  {
2809
2811
  name: "TanStack Start",
2810
2812
  packages: ["@tanstack/react-start", "@tanstack/start"],
@@ -2831,7 +2833,7 @@ function occupied(cwd, relative3) {
2831
2833
  return existsSync(file) && !exportsSchema(file);
2832
2834
  }
2833
2835
  function schemaFileFor(cwd) {
2834
- const dir = existsSync(join(cwd, "src")) ? "src/" : "";
2836
+ const dir = srcPrefix(cwd);
2835
2837
  const preferred = `${dir}env.ts`;
2836
2838
  if (!occupied(cwd, preferred)) {
2837
2839
  return { file: preferred };
@@ -2842,6 +2844,9 @@ function schemaFileFor(cwd) {
2842
2844
  }
2843
2845
  return { file: DEFAULT_SCHEMA_FILE, displaced: preferred };
2844
2846
  }
2847
+ function srcPrefix(cwd) {
2848
+ return existsSync(join(cwd, "src")) ? "src/" : "";
2849
+ }
2845
2850
  function dependenciesOf(cwd) {
2846
2851
  const manifest = manifestOf(cwd);
2847
2852
  if (manifest === void 0) {
@@ -2878,17 +2883,23 @@ function detectFramework(cwd) {
2878
2883
  }
2879
2884
  for (const signature of SIGNATURES) {
2880
2885
  if (signature.packages.some((name) => dependencies.has(name))) {
2881
- const schema = schemaFileFor(cwd);
2882
- return {
2883
- name: signature.name,
2884
- schemaFile: schema.file,
2885
- ...schema.displaced === void 0 ? {} : { displacedFrom: schema.displaced },
2886
- publicPrefixes: signature.publicPrefixes
2887
- };
2886
+ return detectedFrom(cwd, signature.name, signature.publicPrefixes);
2888
2887
  }
2889
2888
  }
2889
+ if (existsSync(join(cwd, "bunfig.toml")) || dependencies.has("bun-types")) {
2890
+ return detectedFrom(cwd, "Bun", []);
2891
+ }
2890
2892
  return void 0;
2891
2893
  }
2894
+ function detectedFrom(cwd, name, publicPrefixes) {
2895
+ const schema = schemaFileFor(cwd);
2896
+ return {
2897
+ name,
2898
+ schemaFile: schema.file,
2899
+ ...schema.displaced === void 0 ? {} : { displacedFrom: schema.displaced },
2900
+ publicPrefixes
2901
+ };
2902
+ }
2892
2903
  var DEFAULT_ALIAS = "@env";
2893
2904
  var IMPORTS_ALIAS = "#env";
2894
2905
  function detectAlias(cwd) {
@@ -2915,6 +2926,145 @@ import {
2915
2926
  validateSchemaFile
2916
2927
  } from "@penvhq/core";
2917
2928
  import { defineCommand as defineCommand9 } from "citty";
2929
+
2930
+ // src/seams.ts
2931
+ function nextjs({ alias, srcDir }) {
2932
+ return {
2933
+ kind: "scaffold",
2934
+ file: `${srcDir}instrumentation.ts`,
2935
+ content: `// This is instrumentation.ts, Next.js's own startup hook \u2014 Next runs it once,
2936
+ // before any of your app code, on every server boot. penv put one line here so
2937
+ // your config is in process.env before the first library reads it. The filename
2938
+ // is Next's requirement (it only finds \`instrumentation.ts\`), so keep it as-is.
2939
+ export async function register() {
2940
+ // Next also calls register on the Edge runtime, where penv cannot read the
2941
+ // filesystem \u2014 so inject runs on the Node runtime only.
2942
+ if (process.env.NEXT_RUNTIME === "nodejs") {
2943
+ await import("${alias}");
2944
+ }
2945
+ }
2946
+ `,
2947
+ ifPresent: `Add to your existing \`${srcDir}instrumentation.ts\`, inside \`register()\` and first:
2948
+ if (process.env.NEXT_RUNTIME === "nodejs") { await import("${alias}"); }`,
2949
+ notes: []
2950
+ };
2951
+ }
2952
+ function sveltekit({ alias }) {
2953
+ return {
2954
+ kind: "scaffold",
2955
+ // SvelteKit's hooks module is always under `src/`, whatever the schema path.
2956
+ file: "src/hooks.server.ts",
2957
+ content: `// This is hooks.server.ts, SvelteKit's server-startup module \u2014 it loads before
2958
+ // your routes handle a request. penv put this import first so your config is in
2959
+ // process.env before any library reads it. Keep it the first line; the name and
2960
+ // location are SvelteKit's, not penv's.
2961
+ import "${alias}";
2962
+ `,
2963
+ ifPresent: `Add \`import "${alias}";\` as the FIRST line of src/hooks.server.ts, above every other import.`,
2964
+ notes: [
2965
+ `SvelteKit resolves app aliases through \`kit.alias\` \u2014 make sure \`${alias}\` is registered in svelte.config.js, not only tsconfig.`
2966
+ ]
2967
+ };
2968
+ }
2969
+ function nuxt({ alias }) {
2970
+ return {
2971
+ kind: "scaffold",
2972
+ // The `0.` prefix is load-bearing: Nitro sorts plugin filenames as strings,
2973
+ // so it must sort first to run before any other plugin reads process.env.
2974
+ file: "server/plugins/0.penv.ts",
2975
+ content: `// This is a Nitro server plugin (Nuxt's server layer runs it once at startup,
2976
+ // before any middleware or request handler). penv put the import here so your
2977
+ // config is in process.env before a library reads it. The \`0.\` prefix keeps this
2978
+ // plugin first \u2014 Nitro sorts plugin filenames as text \u2014 so don't rename it.
2979
+ import "${alias}";
2980
+
2981
+ // Nitro requires every plugin file to default-export a function; the injection
2982
+ // above already ran at import, so this body is intentionally empty.
2983
+ export default defineNitroPlugin(() => {});
2984
+ `,
2985
+ ifPresent: `Create server/plugins/0.penv.ts with \`import "${alias}";\` and \`export default defineNitroPlugin(() => {});\`.`,
2986
+ notes: [
2987
+ `Wire \`${alias}\` for the Nitro (server) build so it never leaks into the client bundle.`
2988
+ ]
2989
+ };
2990
+ }
2991
+ function bun({ alias }) {
2992
+ return {
2993
+ kind: "scaffold",
2994
+ file: ".penv/preload.ts",
2995
+ content: `// Bun evaluates this file before your entry point, because it is listed under
2996
+ // \`preload\` in bunfig.toml. Running it injects your config into process.env
2997
+ // before any library reads it.
2998
+ import "${alias}";
2999
+ `,
3000
+ ifPresent: `Keep .penv/preload.ts as \`import "${alias}";\`.`,
3001
+ notes: [`\`${alias}\` must be a tsconfig \`paths\` alias for Bun to resolve it.`],
3002
+ // The preload file is inert until bunfig.toml points at it — so penv writes
3003
+ // that too, both for the runtime and for `bun test`. Only when there is no
3004
+ // bunfig to overwrite; an existing one is the user's, and penv prints instead.
3005
+ also: {
3006
+ file: "bunfig.toml",
3007
+ content: 'preload = ["./.penv/preload.ts"]\n\n[test]\npreload = ["./.penv/preload.ts"]\n',
3008
+ ifPresent: 'Add "./.penv/preload.ts" to the `preload` array in bunfig.toml \u2014 and mirror it under `[test]` so `bun test` injects too.'
3009
+ }
3010
+ };
3011
+ }
3012
+ function node({ alias, schemaFile }) {
3013
+ const builtSchema = `./${schemaFile.replace(/\.ts$/, ".js")}`;
3014
+ const runtimeSpecifier = alias.startsWith("#") ? alias : builtSchema;
3015
+ return {
3016
+ kind: "instruct",
3017
+ instruction: `Plain Node / Express / Fastify have no framework hook that runs before your
3018
+ code, so place the injection yourself, one of two ways (both run first):
3019
+ \u2022 Preload at launch \u2014 add to your start script: node --import "${runtimeSpecifier}" your-entry.js
3020
+ \u2022 Or make it the FIRST line of your entry file: import "${runtimeSpecifier}";
3021
+ ` + (alias.startsWith("#") ? "" : `Note: \`${alias}\` is a tsconfig alias that Node does not resolve at runtime \u2014 the paths above point at the built schema module instead.
3022
+ `)
3023
+ };
3024
+ }
3025
+ function tanstack({ alias, srcDir }) {
3026
+ return {
3027
+ kind: "instruct",
3028
+ instruction: `Add \`import "${alias}";\` as the FIRST line of ${srcDir}server.ts (TanStack Start's
3029
+ server entry), above the \`@tanstack/react-start/server-entry\` import \u2014 nothing may
3030
+ import before it. If you have no server.ts yet, create it from TanStack's server-entry
3031
+ template and put that import first.`
3032
+ };
3033
+ }
3034
+ function astro({ alias }) {
3035
+ return {
3036
+ kind: "instruct",
3037
+ instruction: `Astro has no single pre-app hook. Add a small integration to astro.config that
3038
+ injects the import into every SSR page, then cover the routes it cannot reach:
3039
+ \u2022 In astro.config integrations, add:
3040
+ { name: "penv", hooks: { "astro:config:setup": ({ injectScript }) =>
3041
+ injectScript("page-ssr", 'import "${alias}";') } }
3042
+ \u2022 Also add \`import "${alias}";\` as the first line of src/middleware.ts and of any
3043
+ endpoint route (src/pages/*.ts) that imports a library reading process.env.
3044
+ Injection only applies under a server (on-demand) adapter, not a static build.`
3045
+ };
3046
+ }
3047
+ function viteSpa(_context) {
3048
+ return {
3049
+ kind: "none",
3050
+ reason: "This is a client-only app \u2014 nothing reads process.env at runtime, so injection does not apply. Read client config through import.meta.env (only VITE_-prefixed vars, which ship to the browser and must not hold secrets). If you add a server later, place the injection there."
3051
+ };
3052
+ }
3053
+ var SEAMS = {
3054
+ "Next.js": nextjs,
3055
+ SvelteKit: sveltekit,
3056
+ Nuxt: nuxt,
3057
+ Bun: bun,
3058
+ "TanStack Start": tanstack,
3059
+ Astro: astro,
3060
+ Vite: viteSpa
3061
+ };
3062
+ function seamFor(framework, context) {
3063
+ const build = framework === void 0 ? node : SEAMS[framework] ?? node;
3064
+ return build(context);
3065
+ }
3066
+
3067
+ // src/commands/init.ts
2918
3068
  var CONFIG_FILE = "penv.config.ts";
2919
3069
  var TSCONFIG_FILE = "tsconfig.json";
2920
3070
  var GITIGNORE_FILE = ".gitignore";
@@ -2924,6 +3074,7 @@ var IMPORTS_PREFIX = "#";
2924
3074
  var PACKAGE_FILE = "package.json";
2925
3075
  var DEFAULT_DECISIONS = {
2926
3076
  environments: [],
3077
+ inject: false,
2927
3078
  schemaFile: DEFAULT_SCHEMA_FILE2,
2928
3079
  publicPrefixes: [],
2929
3080
  alias: DEFAULT_ALIAS
@@ -3042,6 +3193,9 @@ function planInit(root, flags = {}) {
3042
3193
  detected,
3043
3194
  decisions: {
3044
3195
  environments,
3196
+ // Injection is a choice about the app's needs, not something on disk — so
3197
+ // the plan defaults it off, and only the interactive prompt turns it on.
3198
+ inject: false,
3045
3199
  schemaFile,
3046
3200
  publicPrefixes: declared?.publicPrefixes ?? detected?.publicPrefixes ?? [],
3047
3201
  alias
@@ -3092,12 +3246,30 @@ async function promptForDecisions(plan2, io) {
3092
3246
  if (proceed.length > 0 && proceed !== "y" && proceed !== "yes") {
3093
3247
  return void 0;
3094
3248
  }
3095
- return { ...plan2.decisions, environments };
3249
+ const inject = seamKindFor(plan2) === "none" ? false : await askInject(io);
3250
+ return { ...plan2.decisions, environments, inject };
3251
+ }
3252
+ function seamKindFor(plan2) {
3253
+ return seamFor(plan2.detected?.name, {
3254
+ alias: plan2.decisions.alias,
3255
+ srcDir: plan2.decisions.schemaFile.startsWith("src/") ? "src/" : "",
3256
+ schemaFile: plan2.decisions.schemaFile
3257
+ }).kind;
3258
+ }
3259
+ async function askInject(io) {
3260
+ io.write("");
3261
+ io.write("Also inject your validated config into process.env?");
3262
+ io.write(" y libraries that read process.env directly (WorkOS, Prisma\u2026) just work");
3263
+ io.write(' n config stays available only through import { env } from "@env"');
3264
+ const answer = (await io.ask(prompt("inject", "y/N"))).trim().toLowerCase();
3265
+ return answer === "y" || answer === "yes";
3096
3266
  }
3097
3267
  var EMPTY_SCHEMA_BODY = " // One key per parameter, e.g. `databaseUrl: z.url(),`. Nesting a key nests\n // the parameter: `redis: z.object({ password: z.string() })` is redis/password.";
3098
3268
  var DRAFT_HEADER = "// DRAFT \u2014 generated by `penv import` from one sample of each value, and yours\n// to correct. Single-sample inference cannot know that a boolean seen as `true`\n// must also accept `1`/`0`, or that a string is really a URL. penv scaffolds\n// this file once and never regenerates it, so edits here are safe.\n";
3099
- function renderSchemaModule(fields, draft) {
3269
+ function renderSchemaModule(fields, draft, inject = false) {
3100
3270
  const body = fields.length === 0 ? EMPTY_SCHEMA_BODY : fields.map((field) => ` ${field.key}: ${field.type},`).join("\n");
3271
+ const loadComment = inject ? "// The loaded, validated values for the current environment. Import this in app\n// code. `inject: true` also copies the values into process.env, for libraries\n// that read process.env directly (WorkOS, Prisma) instead of importing @env.\n" : "// The loaded, validated values for the current environment. Import this in app\n// code. Importing it loads configuration and throws (naming the parameter and\n// environment) if anything required is missing or invalid.\n";
3272
+ const loadCall = inject ? "export const env = load(schema, { inject: true });\n" : "export const env = load(schema);\n";
3101
3273
  return `${draft ? DRAFT_HEADER : ""}import { z } from "zod";
3102
3274
  import { load } from "@penvhq/penv";
3103
3275
 
@@ -3107,11 +3279,7 @@ export const schema = z.object({
3107
3279
  ${body}
3108
3280
  });
3109
3281
 
3110
- // The loaded, validated values for the current environment. Import this in app
3111
- // code. Importing it loads configuration and throws (naming the parameter and
3112
- // environment) if anything required is missing or invalid.
3113
- export const env = load(schema);
3114
-
3282
+ ` + loadComment + loadCall + `
3115
3283
  // Registers the schema's shape with penv's types (erased at runtime, so
3116
3284
  // nothing cycles). This is what makes \`override\` keys in penv.config.ts
3117
3285
  // autocomplete from this schema \u2014 a typo'd parameter id is a compile error.
@@ -3403,7 +3571,7 @@ function writeSchemaFile(root, fields, draft, decisions = DEFAULT_DECISIONS) {
3403
3571
  };
3404
3572
  }
3405
3573
  mkdirSync(dirname3(file), { recursive: true });
3406
- writeFileSync2(file, renderSchemaModule(fields, draft), "utf8");
3574
+ writeFileSync2(file, renderSchemaModule(fields, draft, decisions.inject), "utf8");
3407
3575
  return {
3408
3576
  target: "schema",
3409
3577
  action: "created",
@@ -3480,23 +3648,119 @@ function writeGitignore(root, decisions = DEFAULT_DECISIONS) {
3480
3648
  text: `${existing === void 0 ? "Created" : "Updated"} ${relative3}`
3481
3649
  };
3482
3650
  }
3483
- function scaffold(root, fields, draft, decisions = DEFAULT_DECISIONS) {
3484
- return [
3651
+ var INJECT_MIN_VERSION = "0.6.0";
3652
+ function outdatedRuntimeWarning(root) {
3653
+ const version = installedPenvVersion(root);
3654
+ if (version === void 0 || !isBelow(version, INJECT_MIN_VERSION)) {
3655
+ return void 0;
3656
+ }
3657
+ return `Injection needs @penvhq/penv ${INJECT_MIN_VERSION}+ \u2014 this project has ${version}, whose \`load\` ignores \`{ inject: true }\`. Upgrade, or process.env stays empty.`;
3658
+ }
3659
+ function installedPenvVersion(root) {
3660
+ const file = join2(root, "node_modules", "@penvhq", "penv", "package.json");
3661
+ if (!existsSync2(file)) {
3662
+ return void 0;
3663
+ }
3664
+ try {
3665
+ const version = JSON.parse(readFileSync2(file, "utf8")).version;
3666
+ return typeof version === "string" ? version : void 0;
3667
+ } catch {
3668
+ return void 0;
3669
+ }
3670
+ }
3671
+ function releaseTriple(version) {
3672
+ const parts = (version.split("-")[0] ?? "").split(".");
3673
+ const at = (i) => Number.parseInt(parts[i] ?? "0", 10) || 0;
3674
+ return [at(0), at(1), at(2)];
3675
+ }
3676
+ function isBelow(a, b) {
3677
+ const [am, an, ap] = releaseTriple(a);
3678
+ const [bm, bn, bp] = releaseTriple(b);
3679
+ if (am !== bm) return am < bm;
3680
+ if (an !== bn) return an < bn;
3681
+ return ap < bp;
3682
+ }
3683
+ function writeSeam(root, decisions = DEFAULT_DECISIONS, framework = detectFramework(root)?.name) {
3684
+ if (!decisions.inject) {
3685
+ return void 0;
3686
+ }
3687
+ const seam = seamFor(framework, {
3688
+ alias: decisions.alias,
3689
+ srcDir: srcPrefix(root),
3690
+ schemaFile: decisions.schemaFile
3691
+ });
3692
+ const outdated = outdatedRuntimeWarning(root);
3693
+ if (seam.kind === "none") {
3694
+ return { target: "seam", action: "info", text: "No injection seam needed", note: seam.reason };
3695
+ }
3696
+ if (seam.kind === "instruct") {
3697
+ return {
3698
+ target: "seam",
3699
+ action: "info",
3700
+ text: "Place the injection seam",
3701
+ note: withWarning(seam.instruction, outdated)
3702
+ };
3703
+ }
3704
+ const alsoNote = writeAlso(root, seam.also);
3705
+ const file = join2(root, ...seam.file.split("/"));
3706
+ const baseNotes = [...seam.notes, ...alsoNote === void 0 ? [] : [alsoNote]];
3707
+ const notes = baseNotes.length === 0 ? "" : `
3708
+ ${baseNotes.map((n) => ` ${n}`).join("\n")}`;
3709
+ if (existsSync2(file)) {
3710
+ return {
3711
+ target: "seam",
3712
+ action: "info",
3713
+ text: `Add the injection seam to ${seam.file}`,
3714
+ note: withWarning(`${seam.ifPresent}${notes}`, outdated)
3715
+ };
3716
+ }
3717
+ mkdirSync(dirname3(file), { recursive: true });
3718
+ writeFileSync2(file, seam.content, "utf8");
3719
+ return {
3720
+ target: "seam",
3721
+ action: outdated === void 0 ? "created" : "info",
3722
+ text: `Wrote ${seam.file} (runs the injection before your app)`,
3723
+ ...notes === "" && outdated === void 0 ? {} : { note: withWarning(notes.trimStart(), outdated) }
3724
+ };
3725
+ }
3726
+ function writeAlso(root, also) {
3727
+ if (also === void 0) {
3728
+ return void 0;
3729
+ }
3730
+ const file = join2(root, ...also.file.split("/"));
3731
+ if (existsSync2(file)) {
3732
+ return also.ifPresent;
3733
+ }
3734
+ mkdirSync(dirname3(file), { recursive: true });
3735
+ writeFileSync2(file, also.content, "utf8");
3736
+ return `Wrote ${also.file} to register it.`;
3737
+ }
3738
+ function withWarning(note, warning) {
3739
+ if (warning === void 0) {
3740
+ return note;
3741
+ }
3742
+ return note === "" ? warning : `${note}
3743
+ \u26A0 ${warning}`;
3744
+ }
3745
+ function scaffold(root, fields, draft, decisions = DEFAULT_DECISIONS, framework = detectFramework(root)?.name) {
3746
+ const steps = [
3485
3747
  ensurePenvDir(root),
3486
3748
  writeSchemaFile(root, fields, draft, decisions),
3487
3749
  writeConfigFile(root, decisions),
3488
3750
  writeTsconfigAlias(root, decisions),
3489
3751
  writeGitignore(root, decisions)
3490
3752
  ];
3753
+ const seam = writeSeam(root, decisions, framework);
3754
+ return seam === void 0 ? steps : [...steps, seam];
3491
3755
  }
3492
3756
  function runInit(options) {
3493
3757
  const root = resolve4(options.cwd);
3494
3758
  const decisions = options.decisions ?? planInit(root).decisions;
3495
- return { root, decisions, steps: scaffold(root, [], false, decisions) };
3759
+ return { root, decisions, steps: scaffold(root, [], false, decisions, options.framework) };
3496
3760
  }
3497
3761
  function renderInit(result2) {
3498
3762
  const steps = result2.steps.map((step) => {
3499
- const glyph = step.action === "conflicted" ? WARN : CHECK;
3763
+ const glyph = step.action === "conflicted" ? WARN : step.action === "info" ? "\u2192" : CHECK;
3500
3764
  return step.note === void 0 ? { glyph, text: step.text } : { glyph, text: step.text, note: step.note };
3501
3765
  });
3502
3766
  return [
@@ -3559,7 +3823,15 @@ var initCommand = defineCommand9({
3559
3823
  if (!asked) {
3560
3824
  write([...plan2.notes, ""]);
3561
3825
  }
3562
- write(renderInit(runInit({ cwd: root, decisions })));
3826
+ write(
3827
+ renderInit(
3828
+ runInit({
3829
+ cwd: root,
3830
+ decisions,
3831
+ ...plan2.detected && { framework: plan2.detected.name }
3832
+ })
3833
+ )
3834
+ );
3563
3835
  });
3564
3836
  }
3565
3837
  });
@@ -3729,6 +4001,9 @@ function environmentNamed(file, explicit) {
3729
4001
  function decisionsOf(config, cwd) {
3730
4002
  return {
3731
4003
  environments: config.environments,
4004
+ // `import` re-scaffolds env.ts for an existing project; injection is an init
4005
+ // choice, so it is not turned on here.
4006
+ inject: false,
3732
4007
  schemaFile: schemaFileOf3(config),
3733
4008
  publicPrefixes: config.publicPrefixes ?? [],
3734
4009
  alias: detectAlias(cwd)