@penvhq/cli 0.5.0 → 0.6.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.cjs CHANGED
@@ -368,19 +368,19 @@ function refsFrom(files) {
368
368
 
369
369
  // src/schema.ts
370
370
  var import_core4 = require("@penvhq/core");
371
- function defOf(node) {
372
- if (typeof node !== "object" || node === null) {
371
+ function defOf(node2) {
372
+ if (typeof node2 !== "object" || node2 === null) {
373
373
  return void 0;
374
374
  }
375
- const def = node.def;
375
+ const def = node2.def;
376
376
  return typeof def === "object" && def !== null ? def : void 0;
377
377
  }
378
- function typeOf(node) {
379
- const type = defOf(node)?.type;
378
+ function typeOf(node2) {
379
+ const type = defOf(node2)?.type;
380
380
  return typeof type === "string" ? type : void 0;
381
381
  }
382
- function unwrap(node) {
383
- let current = node;
382
+ function unwrap(node2) {
383
+ let current = node2;
384
384
  for (let depth = 0; depth < 8; depth += 1) {
385
385
  const inner = defOf(current)?.innerType;
386
386
  if (inner === void 0) {
@@ -390,38 +390,38 @@ function unwrap(node) {
390
390
  }
391
391
  return current;
392
392
  }
393
- function shapeOf(node) {
394
- if (typeOf(node) !== "object") {
393
+ function shapeOf(node2) {
394
+ if (typeOf(node2) !== "object") {
395
395
  return void 0;
396
396
  }
397
- const shape = node.shape;
397
+ const shape = node2.shape;
398
398
  return typeof shape === "object" && shape !== null ? shape : void 0;
399
399
  }
400
400
  function lookup(root, path) {
401
- let node = unwrap(root);
401
+ let node2 = unwrap(root);
402
402
  for (const key of path) {
403
- const shape = shapeOf(node);
403
+ const shape = shapeOf(node2);
404
404
  if (shape === void 0) {
405
405
  return { kind: "unknown" };
406
406
  }
407
407
  if (!Object.hasOwn(shape, key)) {
408
408
  return { kind: "absent" };
409
409
  }
410
- node = unwrap(shape[key]);
410
+ node2 = unwrap(shape[key]);
411
411
  }
412
- return { kind: "found", node };
412
+ return { kind: "found", node: node2 };
413
413
  }
414
- function minLengthOf(node) {
415
- if (typeOf(node) !== "string") {
414
+ function minLengthOf(node2) {
415
+ if (typeOf(node2) !== "string") {
416
416
  return void 0;
417
417
  }
418
- const min = node.minLength;
418
+ const min = node2.minLength;
419
419
  return typeof min === "number" ? min : void 0;
420
420
  }
421
421
  var ABSENCE_PERMITTED = /* @__PURE__ */ new Set(["optional", "default", "catch", "prefault"]);
422
422
  var ABSENCE_REFUSED = /* @__PURE__ */ new Set(["nullable", "nonoptional", "readonly"]);
423
- function permitsAbsence(node) {
424
- let current = node;
423
+ function permitsAbsence(node2) {
424
+ let current = node2;
425
425
  for (let depth = 0; depth < 8; depth += 1) {
426
426
  const type = typeOf(current);
427
427
  if (type !== void 0 && ABSENCE_PERMITTED.has(type)) {
@@ -438,13 +438,13 @@ function permitsAbsence(node) {
438
438
  }
439
439
  return void 0;
440
440
  }
441
- function leaves(node, path, inherited, out2) {
442
- const own = permitsAbsence(node);
441
+ function leaves(node2, path, inherited, out2) {
442
+ const own = permitsAbsence(node2);
443
443
  const absencePermitted = inherited === true || own === true ? true : combine(inherited, own);
444
- const shape = shapeOf(unwrap(node));
444
+ const shape = shapeOf(unwrap(node2));
445
445
  if (shape === void 0) {
446
446
  if (path.length > 0) {
447
- out2.push({ path, absencePermitted, node });
447
+ out2.push({ path, absencePermitted, node: node2 });
448
448
  }
449
449
  return;
450
450
  }
@@ -460,8 +460,8 @@ function declaredLeaves(schema) {
460
460
  leaves(schema, [], false, out2);
461
461
  return out2;
462
462
  }
463
- function defaultValueOf(node) {
464
- let current = node;
463
+ function defaultValueOf(node2) {
464
+ let current = node2;
465
465
  for (let depth = 0; depth < 8; depth += 1) {
466
466
  const def = defOf(current);
467
467
  if (def === void 0) {
@@ -1111,18 +1111,18 @@ function place(root, path, value) {
1111
1111
  if (leaf === void 0) {
1112
1112
  return;
1113
1113
  }
1114
- let node = root;
1114
+ let node2 = root;
1115
1115
  for (const key of path.slice(0, -1)) {
1116
- const existing = node[key];
1116
+ const existing = node2[key];
1117
1117
  if (typeof existing === "object" && existing !== null) {
1118
- node = existing;
1118
+ node2 = existing;
1119
1119
  continue;
1120
1120
  }
1121
1121
  const child = {};
1122
- node[key] = child;
1123
- node = child;
1122
+ node2[key] = child;
1123
+ node2 = child;
1124
1124
  }
1125
- node[leaf] = value;
1125
+ node2[leaf] = value;
1126
1126
  }
1127
1127
  function isZodType(value) {
1128
1128
  return typeof value === "object" && value !== null && "safeParse" in value && typeof value.safeParse === "function";
@@ -2775,6 +2775,8 @@ var import_node_path5 = require("path");
2775
2775
  var import_core14 = require("@penvhq/core");
2776
2776
  var SIGNATURES = [
2777
2777
  { name: "Next.js", packages: ["next"], publicPrefixes: ["NEXT_PUBLIC_"] },
2778
+ { name: "SvelteKit", packages: ["@sveltejs/kit"], publicPrefixes: ["PUBLIC_"] },
2779
+ { name: "Nuxt", packages: ["nuxt"], publicPrefixes: ["NUXT_PUBLIC_"] },
2778
2780
  {
2779
2781
  name: "TanStack Start",
2780
2782
  packages: ["@tanstack/react-start", "@tanstack/start"],
@@ -2848,17 +2850,23 @@ function detectFramework(cwd) {
2848
2850
  }
2849
2851
  for (const signature of SIGNATURES) {
2850
2852
  if (signature.packages.some((name) => dependencies.has(name))) {
2851
- const schema = schemaFileFor(cwd);
2852
- return {
2853
- name: signature.name,
2854
- schemaFile: schema.file,
2855
- ...schema.displaced === void 0 ? {} : { displacedFrom: schema.displaced },
2856
- publicPrefixes: signature.publicPrefixes
2857
- };
2853
+ return detectedFrom(cwd, signature.name, signature.publicPrefixes);
2858
2854
  }
2859
2855
  }
2856
+ if ((0, import_node_fs2.existsSync)((0, import_node_path5.join)(cwd, "bunfig.toml")) || dependencies.has("bun-types")) {
2857
+ return detectedFrom(cwd, "Bun", []);
2858
+ }
2860
2859
  return void 0;
2861
2860
  }
2861
+ function detectedFrom(cwd, name, publicPrefixes) {
2862
+ const schema = schemaFileFor(cwd);
2863
+ return {
2864
+ name,
2865
+ schemaFile: schema.file,
2866
+ ...schema.displaced === void 0 ? {} : { displacedFrom: schema.displaced },
2867
+ publicPrefixes
2868
+ };
2869
+ }
2862
2870
  var DEFAULT_ALIAS = "@env";
2863
2871
  var IMPORTS_ALIAS = "#env";
2864
2872
  function detectAlias(cwd) {
@@ -2876,6 +2884,132 @@ var import_node_path6 = require("path");
2876
2884
  var import_promises = require("readline/promises");
2877
2885
  var import_core15 = require("@penvhq/core");
2878
2886
  var import_citty9 = require("citty");
2887
+
2888
+ // src/seams.ts
2889
+ function nextjs({ alias, srcDir }) {
2890
+ return {
2891
+ kind: "scaffold",
2892
+ file: `${srcDir}instrumentation.ts`,
2893
+ content: `// This is instrumentation.ts, Next.js's own startup hook \u2014 Next runs it once,
2894
+ // before any of your app code, on every server boot. penv put one line here so
2895
+ // your config is in process.env before the first library reads it. The filename
2896
+ // is Next's requirement (it only finds \`instrumentation.ts\`), so keep it as-is.
2897
+ export async function register() {
2898
+ // Next also calls register on the Edge runtime, where penv cannot read the
2899
+ // filesystem \u2014 so inject runs on the Node runtime only.
2900
+ if (process.env.NEXT_RUNTIME === "nodejs") {
2901
+ await import("${alias}");
2902
+ }
2903
+ }
2904
+ `,
2905
+ ifPresent: `Add to your existing \`${srcDir}instrumentation.ts\`, inside \`register()\` and first:
2906
+ if (process.env.NEXT_RUNTIME === "nodejs") { await import("${alias}"); }`,
2907
+ notes: []
2908
+ };
2909
+ }
2910
+ function sveltekit({ alias }) {
2911
+ return {
2912
+ kind: "scaffold",
2913
+ // SvelteKit's hooks module is always under `src/`, whatever the schema path.
2914
+ file: "src/hooks.server.ts",
2915
+ content: `// This is hooks.server.ts, SvelteKit's server-startup module \u2014 it loads before
2916
+ // your routes handle a request. penv put this import first so your config is in
2917
+ // process.env before any library reads it. Keep it the first line; the name and
2918
+ // location are SvelteKit's, not penv's.
2919
+ import "${alias}";
2920
+ `,
2921
+ ifPresent: `Add \`import "${alias}";\` as the FIRST line of src/hooks.server.ts, above every other import.`,
2922
+ notes: [
2923
+ `SvelteKit resolves app aliases through \`kit.alias\` \u2014 make sure \`${alias}\` is registered in svelte.config.js, not only tsconfig.`
2924
+ ]
2925
+ };
2926
+ }
2927
+ function nuxt({ alias }) {
2928
+ return {
2929
+ kind: "scaffold",
2930
+ // The `0.` prefix is load-bearing: Nitro sorts plugin filenames as strings,
2931
+ // so it must sort first to run before any other plugin reads process.env.
2932
+ file: "server/plugins/0.penv.ts",
2933
+ content: `// This is a Nitro server plugin (Nuxt's server layer runs it once at startup,
2934
+ // before any middleware or request handler). penv put the import here so your
2935
+ // config is in process.env before a library reads it. The \`0.\` prefix keeps this
2936
+ // plugin first \u2014 Nitro sorts plugin filenames as text \u2014 so don't rename it.
2937
+ import "${alias}";
2938
+
2939
+ // Nitro requires every plugin file to default-export a function; the injection
2940
+ // above already ran at import, so this body is intentionally empty.
2941
+ export default defineNitroPlugin(() => {});
2942
+ `,
2943
+ ifPresent: `Create server/plugins/0.penv.ts with \`import "${alias}";\` and \`export default defineNitroPlugin(() => {});\`.`,
2944
+ notes: [
2945
+ `Wire \`${alias}\` for the Nitro (server) build so it never leaks into the client bundle.`
2946
+ ]
2947
+ };
2948
+ }
2949
+ function bun({ alias }) {
2950
+ return {
2951
+ kind: "scaffold",
2952
+ file: ".penv/preload.ts",
2953
+ content: `// Bun evaluates this file before your entry point, because it is listed under
2954
+ // \`preload\` in bunfig.toml (see the note penv printed). Running it injects your
2955
+ // config into process.env before any library reads it.
2956
+ import "${alias}";
2957
+ `,
2958
+ ifPresent: `Keep .penv/preload.ts as \`import "${alias}";\`.`,
2959
+ notes: [
2960
+ 'Register it in bunfig.toml so Bun runs it: `preload = ["./.penv/preload.ts"]` (and the same under `[test]`).',
2961
+ `\`${alias}\` must be a tsconfig \`paths\` alias for Bun to resolve it.`
2962
+ ]
2963
+ };
2964
+ }
2965
+ function node({ alias, srcDir }) {
2966
+ const runtimeSpecifier = alias.startsWith("#") ? alias : `./${srcDir}.penv/env.js`;
2967
+ return {
2968
+ kind: "instruct",
2969
+ instruction: `Plain Node / Express / Fastify have no framework hook that runs before your
2970
+ code, so place the injection yourself, one of two ways (both run first):
2971
+ \u2022 Preload at launch \u2014 add to your start script: node --import "${runtimeSpecifier}" your-entry.js
2972
+ \u2022 Or make it the FIRST line of your entry file: import "${runtimeSpecifier}";
2973
+ ` + (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.
2974
+ `)
2975
+ };
2976
+ }
2977
+ function tanstack({ alias, srcDir }) {
2978
+ return {
2979
+ kind: "instruct",
2980
+ instruction: `Add \`import "${alias}";\` as the FIRST line of ${srcDir}server.ts (TanStack Start's
2981
+ server entry), above the \`@tanstack/react-start/server-entry\` import \u2014 nothing may
2982
+ import before it. If you have no server.ts yet, create it from TanStack's server-entry
2983
+ template and put that import first.`
2984
+ };
2985
+ }
2986
+ function astro(_context) {
2987
+ return {
2988
+ kind: "instruct",
2989
+ instruction: 'Astro has no single pre-app hook. Add a small integration to astro.config that\ninjects the import into every SSR page, then cover the routes it cannot reach:\n \u2022 In astro.config integrations, add:\n { name: "penv", hooks: { "astro:config:setup": ({ injectScript }) =>\n injectScript("page-ssr", \'import "@env";\') } }\n \u2022 Also add `import "@env";` as the first line of src/middleware.ts and of any\n endpoint route (src/pages/*.ts) that imports a library reading process.env.\nInjection only applies under a server (on-demand) adapter, not a static build.'
2990
+ };
2991
+ }
2992
+ function viteSpa(_context) {
2993
+ return {
2994
+ kind: "none",
2995
+ 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."
2996
+ };
2997
+ }
2998
+ var SEAMS = {
2999
+ "Next.js": nextjs,
3000
+ SvelteKit: sveltekit,
3001
+ Nuxt: nuxt,
3002
+ Bun: bun,
3003
+ "TanStack Start": tanstack,
3004
+ Astro: astro,
3005
+ Vite: viteSpa
3006
+ };
3007
+ function seamFor(framework, context) {
3008
+ const build = framework === void 0 ? node : SEAMS[framework] ?? node;
3009
+ return build(context);
3010
+ }
3011
+
3012
+ // src/commands/init.ts
2879
3013
  var CONFIG_FILE = "penv.config.ts";
2880
3014
  var TSCONFIG_FILE = "tsconfig.json";
2881
3015
  var GITIGNORE_FILE = ".gitignore";
@@ -2885,6 +3019,7 @@ var IMPORTS_PREFIX = "#";
2885
3019
  var PACKAGE_FILE = "package.json";
2886
3020
  var DEFAULT_DECISIONS = {
2887
3021
  environments: [],
3022
+ inject: false,
2888
3023
  schemaFile: import_core15.DEFAULT_SCHEMA_FILE,
2889
3024
  publicPrefixes: [],
2890
3025
  alias: DEFAULT_ALIAS
@@ -3003,6 +3138,9 @@ function planInit(root, flags = {}) {
3003
3138
  detected,
3004
3139
  decisions: {
3005
3140
  environments,
3141
+ // Injection is a choice about the app's needs, not something on disk — so
3142
+ // the plan defaults it off, and only the interactive prompt turns it on.
3143
+ inject: false,
3006
3144
  schemaFile,
3007
3145
  publicPrefixes: declared?.publicPrefixes ?? detected?.publicPrefixes ?? [],
3008
3146
  alias
@@ -3049,16 +3187,33 @@ async function promptForDecisions(plan2, io) {
3049
3187
  );
3050
3188
  io.write("");
3051
3189
  }
3190
+ const inject = seamKindFor(plan2) === "none" ? false : await askInject(io);
3052
3191
  const proceed = (await io.ask(prompt("Proceed?", "Y/n"))).trim().toLowerCase();
3053
3192
  if (proceed.length > 0 && proceed !== "y" && proceed !== "yes") {
3054
3193
  return void 0;
3055
3194
  }
3056
- return { ...plan2.decisions, environments };
3195
+ return { ...plan2.decisions, environments, inject };
3196
+ }
3197
+ function seamKindFor(plan2) {
3198
+ return seamFor(plan2.detected?.name, {
3199
+ alias: plan2.decisions.alias,
3200
+ srcDir: plan2.decisions.schemaFile.startsWith("src/") ? "src/" : ""
3201
+ }).kind;
3202
+ }
3203
+ async function askInject(io) {
3204
+ io.write("");
3205
+ io.write("Also inject your validated config into process.env?");
3206
+ io.write(" y libraries that read process.env directly (WorkOS, Prisma\u2026) just work");
3207
+ io.write(' n config stays available only through import { env } from "@env"');
3208
+ const answer = (await io.ask(prompt("inject", "y/N"))).trim().toLowerCase();
3209
+ return answer === "y" || answer === "yes";
3057
3210
  }
3058
3211
  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.";
3059
3212
  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";
3060
- function renderSchemaModule(fields, draft) {
3213
+ function renderSchemaModule(fields, draft, inject = false) {
3061
3214
  const body = fields.length === 0 ? EMPTY_SCHEMA_BODY : fields.map((field) => ` ${field.key}: ${field.type},`).join("\n");
3215
+ 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";
3216
+ const loadCall = inject ? "export const env = load(schema, { inject: true });\n" : "export const env = load(schema);\n";
3062
3217
  return `${draft ? DRAFT_HEADER : ""}import { z } from "zod";
3063
3218
  import { load } from "@penvhq/penv";
3064
3219
 
@@ -3068,11 +3223,7 @@ export const schema = z.object({
3068
3223
  ${body}
3069
3224
  });
3070
3225
 
3071
- // The loaded, validated values for the current environment. Import this in app
3072
- // code. Importing it loads configuration and throws (naming the parameter and
3073
- // environment) if anything required is missing or invalid.
3074
- export const env = load(schema);
3075
-
3226
+ ` + loadComment + loadCall + `
3076
3227
  // Registers the schema's shape with penv's types (erased at runtime, so
3077
3228
  // nothing cycles). This is what makes \`override\` keys in penv.config.ts
3078
3229
  // autocomplete from this schema \u2014 a typo'd parameter id is a compile error.
@@ -3364,7 +3515,7 @@ function writeSchemaFile(root, fields, draft, decisions = DEFAULT_DECISIONS) {
3364
3515
  };
3365
3516
  }
3366
3517
  (0, import_node_fs3.mkdirSync)((0, import_node_path6.dirname)(file), { recursive: true });
3367
- (0, import_node_fs3.writeFileSync)(file, renderSchemaModule(fields, draft), "utf8");
3518
+ (0, import_node_fs3.writeFileSync)(file, renderSchemaModule(fields, draft, decisions.inject), "utf8");
3368
3519
  return {
3369
3520
  target: "schema",
3370
3521
  action: "created",
@@ -3441,14 +3592,56 @@ function writeGitignore(root, decisions = DEFAULT_DECISIONS) {
3441
3592
  text: `${existing === void 0 ? "Created" : "Updated"} ${relative3}`
3442
3593
  };
3443
3594
  }
3595
+ function srcDirOf(root) {
3596
+ return (0, import_node_fs3.existsSync)((0, import_node_path6.join)(root, "src")) ? "src/" : "";
3597
+ }
3598
+ function writeSeam(root, decisions = DEFAULT_DECISIONS) {
3599
+ if (!decisions.inject) {
3600
+ return void 0;
3601
+ }
3602
+ const framework = detectFramework(root)?.name;
3603
+ const seam = seamFor(framework, { alias: decisions.alias, srcDir: srcDirOf(root) });
3604
+ if (seam.kind === "none") {
3605
+ return { target: "seam", action: "info", text: "No injection seam needed", note: seam.reason };
3606
+ }
3607
+ if (seam.kind === "instruct") {
3608
+ return {
3609
+ target: "seam",
3610
+ action: "info",
3611
+ text: "Place the injection seam",
3612
+ note: seam.instruction
3613
+ };
3614
+ }
3615
+ const file = (0, import_node_path6.join)(root, ...seam.file.split("/"));
3616
+ const notes = seam.notes.length === 0 ? "" : `
3617
+ ${seam.notes.map((n) => ` ${n}`).join("\n")}`;
3618
+ if ((0, import_node_fs3.existsSync)(file)) {
3619
+ return {
3620
+ target: "seam",
3621
+ action: "info",
3622
+ text: `Add the injection seam to ${seam.file}`,
3623
+ note: `${seam.ifPresent}${notes}`
3624
+ };
3625
+ }
3626
+ (0, import_node_fs3.mkdirSync)((0, import_node_path6.dirname)(file), { recursive: true });
3627
+ (0, import_node_fs3.writeFileSync)(file, seam.content, "utf8");
3628
+ return {
3629
+ target: "seam",
3630
+ action: "created",
3631
+ text: `Wrote ${seam.file} (runs the injection before your app)`,
3632
+ ...notes === "" ? {} : { note: notes.trimStart() }
3633
+ };
3634
+ }
3444
3635
  function scaffold(root, fields, draft, decisions = DEFAULT_DECISIONS) {
3445
- return [
3636
+ const steps = [
3446
3637
  ensurePenvDir(root),
3447
3638
  writeSchemaFile(root, fields, draft, decisions),
3448
3639
  writeConfigFile(root, decisions),
3449
3640
  writeTsconfigAlias(root, decisions),
3450
3641
  writeGitignore(root, decisions)
3451
3642
  ];
3643
+ const seam = writeSeam(root, decisions);
3644
+ return seam === void 0 ? steps : [...steps, seam];
3452
3645
  }
3453
3646
  function runInit(options) {
3454
3647
  const root = (0, import_node_path6.resolve)(options.cwd);
@@ -3457,7 +3650,7 @@ function runInit(options) {
3457
3650
  }
3458
3651
  function renderInit(result2) {
3459
3652
  const steps = result2.steps.map((step) => {
3460
- const glyph = step.action === "conflicted" ? WARN : CHECK;
3653
+ const glyph = step.action === "conflicted" ? WARN : step.action === "info" ? "\u2192" : CHECK;
3461
3654
  return step.note === void 0 ? { glyph, text: step.text } : { glyph, text: step.text, note: step.note };
3462
3655
  });
3463
3656
  return [
@@ -3690,6 +3883,9 @@ function environmentNamed(file, explicit) {
3690
3883
  function decisionsOf(config, cwd) {
3691
3884
  return {
3692
3885
  environments: config.environments,
3886
+ // `import` re-scaffolds env.ts for an existing project; injection is an init
3887
+ // choice, so it is not turned on here.
3888
+ inject: false,
3693
3889
  schemaFile: (0, import_core16.schemaFileOf)(config),
3694
3890
  publicPrefixes: config.publicPrefixes ?? [],
3695
3891
  alias: detectAlias(cwd)