@penvhq/cli 0.6.0 → 0.8.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.js CHANGED
@@ -1,13 +1,15 @@
1
1
  // src/index.ts
2
2
  import { setKeychain } from "@penvhq/core";
3
- import { runMain as cittyRunMain, defineCommand as defineCommand18 } from "citty";
3
+ import { runMain as cittyRunMain, defineCommand as defineCommand19 } from "citty";
4
4
 
5
5
  // src/commands/doctor.ts
6
+ import { readFileSync as readFileSync3 } from "fs";
7
+ import { join as join3 } from "path";
6
8
  import {
7
9
  accessPath as accessPath3,
8
10
  assertNever,
9
11
  effectiveMeta,
10
- formatValueFile as formatValueFile2,
12
+ formatValueFile as formatValueFile3,
11
13
  holdsProjection as holdsProjection3,
12
14
  holdsRecords,
13
15
  isPublicVariable,
@@ -81,7 +83,8 @@ function environmentFromShorthand(config, candidates, explicit) {
81
83
  }
82
84
 
83
85
  // src/project.ts
84
- import { dirname as dirname2, resolve as resolve2 } from "path";
86
+ import { existsSync, readFileSync } from "fs";
87
+ import { dirname as dirname2, join, resolve as resolve2 } from "path";
85
88
  import {
86
89
  candidatesFor,
87
90
  formatValueFile,
@@ -94,7 +97,9 @@ import {
94
97
  ReservedTokenError,
95
98
  refFromAccessPath,
96
99
  resolveEnvironment,
97
- resolveKeySource
100
+ resolveKeySource,
101
+ SCHEMA_SHAPE_FILE,
102
+ schemaFileOf
98
103
  } from "@penvhq/core";
99
104
  import { FilesystemProvider } from "@penvhq/provider-filesystem";
100
105
 
@@ -248,6 +253,18 @@ function localTree(project) {
248
253
  }
249
254
  return project.provider;
250
255
  }
256
+ function selfContainedSchemaModule(root, schemaFile) {
257
+ const file = join(root, ...schemaFile.split("/"));
258
+ if (!existsSync(file)) {
259
+ return void 0;
260
+ }
261
+ const source = readFileSync(file, "utf8");
262
+ return source.includes("PenvSchemaShape") || source.includes("z.object") ? schemaFile : void 0;
263
+ }
264
+ function schemaShapeFileOf(project) {
265
+ const schemaFile = schemaFileOf(project.config);
266
+ return selfContainedSchemaModule(project.root, schemaFile) ?? SCHEMA_SHAPE_FILE;
267
+ }
251
268
  async function sourceProviderFor(project, environment) {
252
269
  const providerConfig = project.config.providers[environment];
253
270
  if (providerConfig === void 0) {
@@ -479,7 +496,7 @@ function hasValue(resolution) {
479
496
  return resolution.winner !== void 0;
480
497
  }
481
498
  function computeDrift(input) {
482
- const { schema, resolutions, config, environment } = input;
499
+ const { schema, resolutions, config, environment, schemaShapeFile } = input;
483
500
  const valued = new Set(resolutions.filter(hasValue).map((resolution) => resolution.parameter));
484
501
  const declared = [];
485
502
  const optional = [];
@@ -489,7 +506,7 @@ function computeDrift(input) {
489
506
  }
490
507
  const path = leaf.path.join(".");
491
508
  const ref = refFromAccessPath2(leaf.path);
492
- const renameRemedy = `Rename the \`${path}\` key in .penv/env.ts \u2014 a parameter name is lower-case, hyphenated, and never a reserved token, so no value file reaches this key.`;
509
+ const renameRemedy = `Rename the \`${path}\` key in ${schemaShapeFile} \u2014 a parameter name is lower-case, hyphenated, and never a reserved token, so no value file reaches this key.`;
493
510
  if (ref === void 0 || isReservedToken2(ref.name, config)) {
494
511
  if (leaf.absencePermitted) {
495
512
  optional.push({ subject: path, remedy: renameRemedy });
@@ -519,7 +536,7 @@ function computeDrift(input) {
519
536
  subject: parameterId2(ref),
520
537
  ref,
521
538
  remedy: `penv set ${[...ref.namespace, ref.name].join("/")} --env ${environment}`,
522
- detail: `declared in .penv/env.ts, no value for ${environment}`
539
+ detail: `declared in ${schemaShapeFile}, no value for ${environment}`
523
540
  });
524
541
  }
525
542
  const undeclared = [];
@@ -535,6 +552,133 @@ function computeDrift(input) {
535
552
  return { declared, undeclared, optional };
536
553
  }
537
554
 
555
+ // src/snapshot.ts
556
+ import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync } from "fs";
557
+ import { join as join2 } from "path";
558
+ import { formatValueFile as formatValueFile2, schemaFileOf as schemaFileOf2 } from "@penvhq/core";
559
+ import { createFilesystemProvider as createFilesystemProvider2 } from "@penvhq/provider-filesystem";
560
+ var SNAPSHOT_FILE = "penv.snapshot.ts";
561
+ function sealedValues(tree) {
562
+ const collected = {};
563
+ for (const file of tree.listSync()) {
564
+ if (!file.encrypted) {
565
+ continue;
566
+ }
567
+ if (file.scope.kind === "local" || file.scope.kind === "environment-local") {
568
+ continue;
569
+ }
570
+ const stored = tree.readSync(file);
571
+ if (stored === void 0) {
572
+ continue;
573
+ }
574
+ collected[formatValueFile2(file)] = stored;
575
+ }
576
+ const values = {};
577
+ for (const key of Object.keys(collected).sort()) {
578
+ values[key] = collected[key];
579
+ }
580
+ return values;
581
+ }
582
+ function buildSnapshotFrom(root, config) {
583
+ const tree = createFilesystemProvider2({ root: join2(root, PENV_DIR), config });
584
+ return { v: 1, config, values: sealedValues(tree) };
585
+ }
586
+ function buildSnapshot(project) {
587
+ return { v: 1, config: project.config, values: sealedValues(localTree(project)) };
588
+ }
589
+ function renderSnapshotModule(snapshot) {
590
+ const body = JSON.stringify(snapshot, null, 2);
591
+ return `// penv.snapshot.ts \u2014 GENERATED by penv. Do not edit; run \`penv snapshot\` to refresh.
592
+ // It embeds the evaluated config and every committed sealed (.enc) value, so \`load()\`
593
+ // resolves in a bundled or serverless runtime where no penv.config.ts is on disk.
594
+ import type { PenvSnapshot } from "@penvhq/penv";
595
+
596
+ export const snapshot = ${body} satisfies PenvSnapshot;
597
+ `;
598
+ }
599
+ function equalsIgnoringEol(a, b) {
600
+ return a.replace(/\r\n/g, "\n") === b.replace(/\r\n/g, "\n");
601
+ }
602
+ function writeSnapshotFile(project) {
603
+ const path = join2(project.root, SNAPSHOT_FILE);
604
+ const wanted = renderSnapshotModule(buildSnapshot(project));
605
+ const existing = existsSync2(path) ? readFileSync2(path, "utf8") : void 0;
606
+ if (existing !== void 0 && equalsIgnoringEol(existing, wanted)) {
607
+ return { file: SNAPSHOT_FILE, action: "unchanged" };
608
+ }
609
+ writeFileSync(path, wanted, "utf8");
610
+ return { file: SNAPSHOT_FILE, action: existing === void 0 ? "created" : "updated" };
611
+ }
612
+ function snapshotExists(root) {
613
+ return existsSync2(join2(root, SNAPSHOT_FILE));
614
+ }
615
+ function refreshSnapshot(project) {
616
+ if (snapshotExists(project.root)) {
617
+ writeSnapshotFile(project);
618
+ }
619
+ }
620
+ function snapshotSpecifier(schemaFile) {
621
+ const depth = schemaFile.split("/").length - 1;
622
+ const climb = depth === 0 ? "./" : "../".repeat(depth);
623
+ return `${climb}penv.snapshot.js`;
624
+ }
625
+ function insertSnapshotImport(source, importLine) {
626
+ const afterSchema = /^import\s*\{\s*schema\s*\}\s*from\s*["'][^"']*penv\.schema\.js["'];?[ \t]*\r?\n/m;
627
+ const schemaMatch = afterSchema.exec(source);
628
+ if (schemaMatch !== null) {
629
+ const index = schemaMatch.index + schemaMatch[0].length;
630
+ return `${source.slice(0, index)}${importLine}
631
+ ${source.slice(index)}`;
632
+ }
633
+ const afterLoad = /^import\s*\{[^}]*\}\s*from\s*["']@penvhq\/penv["'];?[ \t]*\r?\n/m;
634
+ const loadMatch = afterLoad.exec(source);
635
+ if (loadMatch !== null) {
636
+ const index = loadMatch.index + loadMatch[0].length;
637
+ return `${source.slice(0, index)}${importLine}
638
+ ${source.slice(index)}`;
639
+ }
640
+ return `${importLine}
641
+ ${source}`;
642
+ }
643
+ function wireEnvModule(project) {
644
+ const schemaFile = schemaFileOf2(project.config);
645
+ const specifier = snapshotSpecifier(schemaFile);
646
+ const importLine = `import { snapshot } from "${specifier}";`;
647
+ const loadHint = "pass it to load: `load(schema, { snapshot })`";
648
+ const manual = { file: schemaFile, action: "manual", importLine, loadHint };
649
+ const path = join2(project.root, ...schemaFile.split("/"));
650
+ if (!existsSync2(path)) {
651
+ return manual;
652
+ }
653
+ const source = readFileSync2(path, "utf8");
654
+ const loadCall = /load\(\s*schema\s*(\)|,\s*\{([\s\S]*?)\}\s*\))/;
655
+ const match = loadCall.exec(source);
656
+ if (match === null) {
657
+ return manual;
658
+ }
659
+ const options = match[2];
660
+ const snapshotProperty = /(?:^|[,{\s])snapshot\s*(?=[,:}]|$)/;
661
+ const snapshotInOptions = options !== void 0 && snapshotProperty.test(options);
662
+ const importPresent = source.includes(importLine);
663
+ if (snapshotInOptions && importPresent) {
664
+ return { file: schemaFile, action: "kept", importLine, loadHint };
665
+ }
666
+ let updated = source;
667
+ if (!snapshotInOptions) {
668
+ const trimmed = (options ?? "").trim();
669
+ const replacement = match[1] === ")" || trimmed.length === 0 ? "load(schema, { snapshot })" : `load(schema, { ${trimmed}, snapshot })`;
670
+ updated = updated.replace(loadCall, replacement);
671
+ }
672
+ if (!importPresent) {
673
+ updated = insertSnapshotImport(updated, importLine);
674
+ }
675
+ if (updated === source) {
676
+ return { file: schemaFile, action: "kept", importLine, loadHint };
677
+ }
678
+ writeFileSync(path, updated, "utf8");
679
+ return { file: schemaFile, action: "wired", importLine, loadHint };
680
+ }
681
+
538
682
  // src/style.ts
539
683
  function supportsColor(stream) {
540
684
  const env = process.env;
@@ -1077,7 +1221,7 @@ import {
1077
1221
  ReservedTokenError as ReservedTokenError2,
1078
1222
  resolveAll,
1079
1223
  SCHEMA_HARVEST_ENV,
1080
- schemaFileOf,
1224
+ schemaFileOf as schemaFileOf3,
1081
1225
  validateConfig
1082
1226
  } from "@penvhq/core";
1083
1227
  import { defineCommand as defineCommand2 } from "citty";
@@ -1126,7 +1270,7 @@ function place(root, path, value) {
1126
1270
  function isZodType(value) {
1127
1271
  return typeof value === "object" && value !== null && "safeParse" in value && typeof value.safeParse === "function";
1128
1272
  }
1129
- function validationIssuesOf(cause, schemaPath) {
1273
+ function validationIssuesOf(cause, schemaPath, schemaShapeFile) {
1130
1274
  if (typeof cause !== "object" || cause === null) {
1131
1275
  return void 0;
1132
1276
  }
@@ -1140,7 +1284,10 @@ function validationIssuesOf(cause, schemaPath) {
1140
1284
  kind: "schema",
1141
1285
  subject: typeof parameter === "string" && parameter !== "" ? parameter : schemaPath,
1142
1286
  message: typeof message === "string" ? message : String(issue),
1143
- remedy: `Fix the value, or adjust the schema in ${schemaPath} if the shape is wrong.`
1287
+ // `schemaPath` is the loader penv evaluated; the shape to adjust lives in
1288
+ // `schemaShapeFile`, which is that same file for an old-layout project and
1289
+ // `penv.schema.ts` for one on the split.
1290
+ remedy: `Fix the value, or adjust the schema in ${schemaShapeFile} if the shape is wrong.`
1144
1291
  };
1145
1292
  });
1146
1293
  }
@@ -1154,11 +1301,12 @@ function exclusively(work) {
1154
1301
  return result2;
1155
1302
  }
1156
1303
  function loadSchema(project, environment) {
1157
- const schemaPath = schemaFileOf(project.config);
1304
+ const schemaPath = schemaFileOf3(project.config);
1305
+ const schemaShapeFile = schemaShapeFileOf(project);
1158
1306
  const file = pathToFileURL2(resolvePath(project.root, schemaPath)).href;
1159
- return exclusively(() => loadSchemaExclusively(file, schemaPath, environment));
1307
+ return exclusively(() => loadSchemaExclusively(file, schemaPath, schemaShapeFile, environment));
1160
1308
  }
1161
- async function loadSchemaExclusively(file, schemaPath, environment) {
1309
+ async function loadSchemaExclusively(file, schemaPath, schemaShapeFile, environment) {
1162
1310
  const jiti = jitiFor(file);
1163
1311
  const previous = process.env.PENV_ENV;
1164
1312
  const previousHarvest = process.env[SCHEMA_HARVEST_ENV];
@@ -1168,7 +1316,7 @@ async function loadSchemaExclusively(file, schemaPath, environment) {
1168
1316
  try {
1169
1317
  loaded = await jiti.import(file);
1170
1318
  } catch (cause) {
1171
- const issues = validationIssuesOf(cause, schemaPath);
1319
+ const issues = validationIssuesOf(cause, schemaPath, schemaShapeFile);
1172
1320
  if (issues !== void 0) {
1173
1321
  return { issues };
1174
1322
  }
@@ -1213,7 +1361,8 @@ async function loadSchemaExclusively(file, schemaPath, environment) {
1213
1361
  async function runValidate(options) {
1214
1362
  const project = openProject(options.cwd);
1215
1363
  const environment = targetEnvironment(project, options.environment, options.envFlags);
1216
- const schemaPath = schemaFileOf(project.config);
1364
+ const schemaPath = schemaFileOf3(project.config);
1365
+ const schemaShapeFile = schemaShapeFileOf(project);
1217
1366
  const issues = [];
1218
1367
  let files;
1219
1368
  try {
@@ -1263,7 +1412,13 @@ async function runValidate(options) {
1263
1412
  place(object, accessPath2(resolution.ref), resolution.value);
1264
1413
  }
1265
1414
  }
1266
- drift = computeDrift({ schema, resolutions, config: project.config, environment });
1415
+ drift = computeDrift({
1416
+ schema,
1417
+ resolutions,
1418
+ config: project.config,
1419
+ environment,
1420
+ schemaShapeFile
1421
+ });
1267
1422
  const result2 = schema.safeParse(object);
1268
1423
  if (!result2.success) {
1269
1424
  for (const problem of result2.error.issues) {
@@ -1275,7 +1430,7 @@ async function runValidate(options) {
1275
1430
  kind: "schema",
1276
1431
  subject: path || schemaPath,
1277
1432
  message: problem.message,
1278
- remedy: `Fix the value, or adjust the schema in ${schemaPath} if the shape is wrong.`
1433
+ remedy: `Fix the value, or adjust the schema in ${schemaShapeFile} if the shape is wrong.`
1279
1434
  });
1280
1435
  }
1281
1436
  }
@@ -1398,13 +1553,15 @@ async function runDoctor(options) {
1398
1553
  skipped("unused", "Value coverage")
1399
1554
  );
1400
1555
  } else {
1556
+ const schemaShapeFile = schemaShapeFileOf(project);
1401
1557
  const drift = computeDrift({
1402
1558
  schema,
1403
1559
  resolutions: subjects.map(({ resolution }) => resolution),
1404
1560
  config: project.config,
1405
- environment
1561
+ environment,
1562
+ schemaShapeFile
1406
1563
  });
1407
- findings.push(...declaredFindings(drift, missing, environment));
1564
+ findings.push(...declaredFindings(drift, missing, environment, schemaShapeFile));
1408
1565
  findings.push(...weakFindings(subjects, schema));
1409
1566
  findings.push(...unusedFindings(drift));
1410
1567
  }
@@ -1420,6 +1577,8 @@ async function runDoctor(options) {
1420
1577
  findings.push(...stuckFindings(rotation.subjects, environment, now, stuckThresholdMs));
1421
1578
  }
1422
1579
  findings.push(...await providerDriftFindings(project, environment, options.source));
1580
+ findings.push(...snapshotStaleFindings(project));
1581
+ findings.push(...bundleInvisibleFindings(project, subjects, environment));
1423
1582
  findings.push(...await projectionFindings(project, environment, options.projection));
1424
1583
  findings.push({
1425
1584
  check: "provider",
@@ -1455,7 +1614,7 @@ function missingFindings(subjects, environment) {
1455
1614
  }
1456
1615
  ];
1457
1616
  }
1458
- function declaredFindings(drift, missing, environment) {
1617
+ function declaredFindings(drift, missing, environment, schemaShapeFile) {
1459
1618
  const reported = new Set(missing.filter((f) => f.severity !== "pass").map((f) => f.subject));
1460
1619
  const findings = drift.declared.filter((item) => !reported.has(item.subject)).map((item) => ({
1461
1620
  check: "declared",
@@ -1473,7 +1632,7 @@ function declaredFindings(drift, missing, environment) {
1473
1632
  check: "declared",
1474
1633
  severity: "pass",
1475
1634
  label: "Schema coverage",
1476
- subject: `every parameter .penv/env.ts requires has a value for ${environment}`
1635
+ subject: `every parameter ${schemaShapeFile} requires has a value for ${environment}`
1477
1636
  }
1478
1637
  ];
1479
1638
  }
@@ -2045,7 +2204,7 @@ async function providerDriftFindings(project, environment, override) {
2045
2204
  check: "provider-value-drift",
2046
2205
  severity: "unknown",
2047
2206
  label: "Provider value unreadable",
2048
- subject: formatValueFile2(here.file),
2207
+ subject: formatValueFile3(here.file),
2049
2208
  detail: `the local value is sealed and did not open, so it cannot be compared against the ${providerConfig.type} source of truth`
2050
2209
  });
2051
2210
  continue;
@@ -2055,7 +2214,7 @@ async function providerDriftFindings(project, environment, override) {
2055
2214
  check: "provider-value-drift",
2056
2215
  severity: "failure",
2057
2216
  label: "Provider value drift",
2058
- subject: formatValueFile2(here.file),
2217
+ subject: formatValueFile3(here.file),
2059
2218
  detail: `the local tree and the ${providerConfig.type} source of truth hold different values`
2060
2219
  });
2061
2220
  }
@@ -2069,7 +2228,7 @@ async function providerDriftFindings(project, environment, override) {
2069
2228
  check: "provider-value-drift",
2070
2229
  severity: "warning",
2071
2230
  label: here !== void 0 ? "Only in the local tree" : "Only in the source",
2072
- subject: formatValueFile2(present.file),
2231
+ subject: formatValueFile3(present.file),
2073
2232
  detail: here !== void 0 ? `present locally, absent from the ${providerConfig.type} source of truth` : `present in the ${providerConfig.type} source of truth, absent from the local tree`
2074
2233
  });
2075
2234
  }
@@ -2085,6 +2244,68 @@ async function providerDriftFindings(project, environment, override) {
2085
2244
  }
2086
2245
  ];
2087
2246
  }
2247
+ function snapshotStaleFindings(project) {
2248
+ if (!snapshotExists(project.root)) {
2249
+ return [];
2250
+ }
2251
+ const wanted = renderSnapshotModule(buildSnapshot(project));
2252
+ const committed = readFileSync3(join3(project.root, SNAPSHOT_FILE), "utf8");
2253
+ if (equalsIgnoringEol(committed, wanted)) {
2254
+ return [
2255
+ {
2256
+ check: "snapshot-stale",
2257
+ severity: "pass",
2258
+ label: "Snapshot",
2259
+ subject: `${SNAPSHOT_FILE} matches the committed sealed values and config`
2260
+ }
2261
+ ];
2262
+ }
2263
+ return [
2264
+ {
2265
+ check: "snapshot-stale",
2266
+ severity: "failure",
2267
+ label: "Snapshot stale",
2268
+ subject: SNAPSHOT_FILE,
2269
+ detail: "does not match the current sealed values or config, so a bundle resolves stale data",
2270
+ remedy: "penv snapshot"
2271
+ }
2272
+ ];
2273
+ }
2274
+ function bundleInvisibleFindings(project, subjects, environment) {
2275
+ if (!snapshotExists(project.root)) {
2276
+ return [];
2277
+ }
2278
+ const findings = [];
2279
+ for (const { resolution } of subjects) {
2280
+ const winner = resolution.winner;
2281
+ if (winner === void 0 || winner.file.encrypted) {
2282
+ continue;
2283
+ }
2284
+ const scope = winner.file.scope;
2285
+ if (scope.kind === "local" || scope.kind === "environment-local") {
2286
+ continue;
2287
+ }
2288
+ findings.push({
2289
+ check: "bundle-invisible-plaintext",
2290
+ severity: "warning",
2291
+ label: "Invisible to bundles",
2292
+ subject: winner.location,
2293
+ detail: "plaintext is gitignored and not embedded in the snapshot, so a bundle cannot see it",
2294
+ remedy: `penv encrypt ${refPathOf(resolution.ref)} --env ${environment}`
2295
+ });
2296
+ }
2297
+ if (findings.length > 0) {
2298
+ return findings;
2299
+ }
2300
+ return [
2301
+ {
2302
+ check: "bundle-invisible-plaintext",
2303
+ severity: "pass",
2304
+ label: "Bundle coverage",
2305
+ subject: `every value resolving for ${environment} is embeddable in ${SNAPSHOT_FILE}`
2306
+ }
2307
+ ];
2308
+ }
2088
2309
  var GLYPHS = {
2089
2310
  pass: CHECK,
2090
2311
  warning: WARN,
@@ -2157,7 +2378,7 @@ var doctorCommand = defineCommand3({
2157
2378
 
2158
2379
  // src/commands/encrypt.ts
2159
2380
  import {
2160
- formatValueFile as formatValueFile4,
2381
+ formatValueFile as formatValueFile5,
2161
2382
  isSecret as isSecret3,
2162
2383
  openValue as openValue3,
2163
2384
  PenvError as PenvError8,
@@ -2167,7 +2388,7 @@ import {
2167
2388
  import { defineCommand as defineCommand5 } from "citty";
2168
2389
 
2169
2390
  // src/commands/set.ts
2170
- import { formatValueFile as formatValueFile3, isSecret as isSecret2, PenvError as PenvError7, parameterId as parameterId3, sealValue } from "@penvhq/core";
2391
+ import { formatValueFile as formatValueFile4, isSecret as isSecret2, PenvError as PenvError7, parameterId as parameterId3, sealValue } from "@penvhq/core";
2171
2392
  import { defineCommand as defineCommand4 } from "citty";
2172
2393
  function scopeFrom(options) {
2173
2394
  if (options.local === true) {
@@ -2202,7 +2423,7 @@ function sealFor(project, file, value, parameter, environment) {
2202
2423
  if (environment === void 0) {
2203
2424
  throw new PenvError7(
2204
2425
  "SECRET_SCOPE_AMBIGUOUS",
2205
- `Parameter ${parameter} is a secret, and ${PENV_DIR}/${formatValueFile3(file)} names no environment`,
2426
+ `Parameter ${parameter} is a secret, and ${PENV_DIR}/${formatValueFile4(file)} names no environment`,
2206
2427
  "Keys are declared per environment in the `keys` block of penv.config.ts, so penv cannot tell which key should seal a file that every environment reads. Write it at an environment scope \u2014 add `--env <environment>` \u2014 or drop `secret` from the parameter's meta."
2207
2428
  );
2208
2429
  }
@@ -2220,7 +2441,7 @@ async function sealAwareWrite(options) {
2220
2441
  const stored = secret ? sealFor(project, file, value, parameterId3(ref), environment) : value;
2221
2442
  await provider.write(file, stored);
2222
2443
  await provider.remove({ ...file, encrypted: !secret });
2223
- return { encrypted: secret, location: formatValueFile3(file) };
2444
+ return { encrypted: secret, location: formatValueFile4(file) };
2224
2445
  }
2225
2446
  async function runSet(options) {
2226
2447
  const project = openProject(options.cwd);
@@ -2236,6 +2457,7 @@ async function runSet(options) {
2236
2457
  value: options.value,
2237
2458
  environment
2238
2459
  });
2460
+ refreshSnapshot(project);
2239
2461
  return { parameter: options.key, location, encrypted };
2240
2462
  }
2241
2463
  function renderSet(result2) {
@@ -2323,17 +2545,18 @@ async function runEncrypt(options) {
2323
2545
  const already = await readOne(project, sealed);
2324
2546
  throw new PenvError8(
2325
2547
  "PARAMETER_ABSENT",
2326
- already === void 0 ? `Parameter ${parameter} has no value file at ${PENV_DIR}/${formatValueFile4(plain)}` : `Parameter ${parameter} is already encrypted at ${PENV_DIR}/${formatValueFile4(sealed)}`,
2548
+ already === void 0 ? `Parameter ${parameter} has no value file at ${PENV_DIR}/${formatValueFile5(plain)}` : `Parameter ${parameter} is already encrypted at ${PENV_DIR}/${formatValueFile5(sealed)}`,
2327
2549
  already === void 0 ? `Write it first with \`penv set ${options.key} --env ${environment}\`, which seals it automatically when the parameter's meta declares it a secret.` : "Nothing to do."
2328
2550
  );
2329
2551
  }
2330
2552
  const text = sealValue2(sealed, value, keySourceFor(project, environment), parameter, environment);
2331
2553
  await project.provider.write(sealed, text);
2332
2554
  await project.provider.remove(plain);
2555
+ refreshSnapshot(project);
2333
2556
  return {
2334
2557
  parameter,
2335
- location: formatValueFile4(sealed),
2336
- removed: formatValueFile4(plain)
2558
+ location: formatValueFile5(sealed),
2559
+ removed: formatValueFile5(plain)
2337
2560
  };
2338
2561
  }
2339
2562
  async function runDecrypt(options) {
@@ -2352,7 +2575,7 @@ async function runDecrypt(options) {
2352
2575
  if (stored === void 0) {
2353
2576
  throw new PenvError8(
2354
2577
  "PARAMETER_ABSENT",
2355
- `Parameter ${parameter} has no encrypted value file at ${PENV_DIR}/${formatValueFile4(sealed)}`,
2578
+ `Parameter ${parameter} has no encrypted value file at ${PENV_DIR}/${formatValueFile5(sealed)}`,
2356
2579
  `Nothing to decrypt. \`penv get ${options.key} --env ${environment} --explain\` shows every file penv looked at.`
2357
2580
  );
2358
2581
  }
@@ -2361,16 +2584,17 @@ async function runDecrypt(options) {
2361
2584
  throw new UndecryptableAt(
2362
2585
  parameter,
2363
2586
  environment,
2364
- formatValueFile4(sealed),
2587
+ formatValueFile5(sealed),
2365
2588
  opened.failure.detail
2366
2589
  );
2367
2590
  }
2368
2591
  await project.provider.write(plain, opened.value);
2369
2592
  await project.provider.remove(sealed);
2593
+ refreshSnapshot(project);
2370
2594
  return {
2371
2595
  parameter,
2372
- location: formatValueFile4(plain),
2373
- removed: formatValueFile4(sealed)
2596
+ location: formatValueFile5(plain),
2597
+ removed: formatValueFile5(sealed)
2374
2598
  };
2375
2599
  }
2376
2600
  var UndecryptableAt = class extends PenvError8 {
@@ -2578,7 +2802,7 @@ var fillCommand = defineCommand6({
2578
2802
  });
2579
2803
 
2580
2804
  // src/commands/generate.ts
2581
- import { writeFileSync } from "fs";
2805
+ import { writeFileSync as writeFileSync2 } from "fs";
2582
2806
  import { isAbsolute, relative, resolve as resolve3 } from "path";
2583
2807
  import {
2584
2808
  checkNameCollisions as checkNameCollisions3,
@@ -2639,7 +2863,7 @@ function runGenerate(options) {
2639
2863
  const environment = targetEnvironment(project, options.environment, options.envFlags);
2640
2864
  const { entries, decrypted } = entriesFor(project, environment, options.allowDecrypt === true);
2641
2865
  const file = options.out === void 0 ? resolve3(project.root, DEFAULT_OUTPUT) : isAbsolute(options.out) ? options.out : resolve3(options.cwd, options.out);
2642
- writeFileSync(file, serializeDotenv(entries), "utf8");
2866
+ writeFileSync2(file, serializeDotenv(entries), "utf8");
2643
2867
  return { file, environment, entries: entries.length, decrypted };
2644
2868
  }
2645
2869
  function displayPath(cwd, file) {
@@ -2777,7 +3001,7 @@ var getCommand = defineCommand8({
2777
3001
  });
2778
3002
 
2779
3003
  // src/commands/import.ts
2780
- import { copyFileSync, existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
3004
+ import { copyFileSync, existsSync as existsSync5, readFileSync as readFileSync6 } from "fs";
2781
3005
  import { basename, isAbsolute as isAbsolute2, relative as relative2, resolve as resolve5 } from "path";
2782
3006
  import {
2783
3007
  accessPath as accessPath4,
@@ -2793,15 +3017,15 @@ import {
2793
3017
  ReservedTokenError as ReservedTokenError3,
2794
3018
  refFromVariable,
2795
3019
  roundTripsCleanly,
2796
- schemaFileOf as schemaFileOf3,
3020
+ schemaFileOf as schemaFileOf5,
2797
3021
  UnknownEnvironmentError,
2798
3022
  variableName as variableName5
2799
3023
  } from "@penvhq/core";
2800
3024
  import { defineCommand as defineCommand10 } from "citty";
2801
3025
 
2802
3026
  // src/detect.ts
2803
- import { existsSync, readFileSync } from "fs";
2804
- import { join } from "path";
3027
+ import { existsSync as existsSync3, readFileSync as readFileSync4 } from "fs";
3028
+ import { join as join4 } from "path";
2805
3029
  import { DEFAULT_SCHEMA_FILE } from "@penvhq/core";
2806
3030
  var SIGNATURES = [
2807
3031
  { name: "Next.js", packages: ["next"], publicPrefixes: ["NEXT_PUBLIC_"] },
@@ -2818,7 +3042,7 @@ var SIGNATURES = [
2818
3042
  function exportsSchema(file) {
2819
3043
  let source;
2820
3044
  try {
2821
- source = readFileSync(file, "utf8");
3045
+ source = readFileSync4(file, "utf8");
2822
3046
  } catch {
2823
3047
  return false;
2824
3048
  }
@@ -2829,11 +3053,11 @@ function exportsSchema(file) {
2829
3053
  );
2830
3054
  }
2831
3055
  function occupied(cwd, relative3) {
2832
- const file = join(cwd, ...relative3.split("/"));
2833
- return existsSync(file) && !exportsSchema(file);
3056
+ const file = join4(cwd, ...relative3.split("/"));
3057
+ return existsSync3(file) && !exportsSchema(file);
2834
3058
  }
2835
3059
  function schemaFileFor(cwd) {
2836
- const dir = existsSync(join(cwd, "src")) ? "src/" : "";
3060
+ const dir = srcPrefix(cwd);
2837
3061
  const preferred = `${dir}env.ts`;
2838
3062
  if (!occupied(cwd, preferred)) {
2839
3063
  return { file: preferred };
@@ -2844,6 +3068,9 @@ function schemaFileFor(cwd) {
2844
3068
  }
2845
3069
  return { file: DEFAULT_SCHEMA_FILE, displaced: preferred };
2846
3070
  }
3071
+ function srcPrefix(cwd) {
3072
+ return existsSync3(join4(cwd, "src")) ? "src/" : "";
3073
+ }
2847
3074
  function dependenciesOf(cwd) {
2848
3075
  const manifest = manifestOf(cwd);
2849
3076
  if (manifest === void 0) {
@@ -2861,13 +3088,13 @@ function dependenciesOf(cwd) {
2861
3088
  return names;
2862
3089
  }
2863
3090
  function manifestOf(cwd) {
2864
- const file = join(cwd, "package.json");
2865
- if (!existsSync(file)) {
3091
+ const file = join4(cwd, "package.json");
3092
+ if (!existsSync3(file)) {
2866
3093
  return void 0;
2867
3094
  }
2868
3095
  let manifest;
2869
3096
  try {
2870
- manifest = JSON.parse(readFileSync(file, "utf8"));
3097
+ manifest = JSON.parse(readFileSync4(file, "utf8"));
2871
3098
  } catch {
2872
3099
  return void 0;
2873
3100
  }
@@ -2883,7 +3110,7 @@ function detectFramework(cwd) {
2883
3110
  return detectedFrom(cwd, signature.name, signature.publicPrefixes);
2884
3111
  }
2885
3112
  }
2886
- if (existsSync(join(cwd, "bunfig.toml")) || dependencies.has("bun-types")) {
3113
+ if (existsSync3(join4(cwd, "bunfig.toml")) || dependencies.has("bun-types")) {
2887
3114
  return detectedFrom(cwd, "Bun", []);
2888
3115
  }
2889
3116
  return void 0;
@@ -2909,8 +3136,8 @@ function hasImportsBlock(cwd) {
2909
3136
  }
2910
3137
 
2911
3138
  // src/commands/init.ts
2912
- import { existsSync as existsSync2, mkdirSync, readdirSync, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
2913
- import { dirname as dirname3, join as join2, resolve as resolve4 } from "path";
3139
+ import { existsSync as existsSync4, mkdirSync, readdirSync, readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "fs";
3140
+ import { dirname as dirname3, join as join5, resolve as resolve4 } from "path";
2914
3141
  import { createInterface as createInterface2 } from "readline/promises";
2915
3142
  import {
2916
3143
  DEFAULT_SCHEMA_FILE as DEFAULT_SCHEMA_FILE2,
@@ -2918,7 +3145,8 @@ import {
2918
3145
  loadConfigFrom,
2919
3146
  PenvError as PenvError12,
2920
3147
  RESERVED_TOKENS,
2921
- schemaFileOf as schemaFileOf2,
3148
+ SCHEMA_SHAPE_FILE as SCHEMA_SHAPE_FILE2,
3149
+ schemaFileOf as schemaFileOf4,
2922
3150
  schemaInsideTree,
2923
3151
  validateSchemaFile
2924
3152
  } from "@penvhq/core";
@@ -2990,19 +3218,25 @@ function bun({ alias }) {
2990
3218
  kind: "scaffold",
2991
3219
  file: ".penv/preload.ts",
2992
3220
  content: `// Bun evaluates this file before your entry point, because it is listed under
2993
- // \`preload\` in bunfig.toml (see the note penv printed). Running it injects your
2994
- // config into process.env before any library reads it.
3221
+ // \`preload\` in bunfig.toml. Running it injects your config into process.env
3222
+ // before any library reads it.
2995
3223
  import "${alias}";
2996
3224
  `,
2997
3225
  ifPresent: `Keep .penv/preload.ts as \`import "${alias}";\`.`,
2998
- notes: [
2999
- 'Register it in bunfig.toml so Bun runs it: `preload = ["./.penv/preload.ts"]` (and the same under `[test]`).',
3000
- `\`${alias}\` must be a tsconfig \`paths\` alias for Bun to resolve it.`
3001
- ]
3226
+ notes: [`\`${alias}\` must be a tsconfig \`paths\` alias for Bun to resolve it.`],
3227
+ // The preload file is inert until bunfig.toml points at it — so penv writes
3228
+ // that too, both for the runtime and for `bun test`. Only when there is no
3229
+ // bunfig to overwrite; an existing one is the user's, and penv prints instead.
3230
+ also: {
3231
+ file: "bunfig.toml",
3232
+ content: 'preload = ["./.penv/preload.ts"]\n\n[test]\npreload = ["./.penv/preload.ts"]\n',
3233
+ ifPresent: 'Add "./.penv/preload.ts" to the `preload` array in bunfig.toml \u2014 and mirror it under `[test]` so `bun test` injects too.'
3234
+ }
3002
3235
  };
3003
3236
  }
3004
- function node({ alias, srcDir }) {
3005
- const runtimeSpecifier = alias.startsWith("#") ? alias : `./${srcDir}.penv/env.js`;
3237
+ function node({ alias, schemaFile }) {
3238
+ const builtSchema = `./${schemaFile.replace(/\.ts$/, ".js")}`;
3239
+ const runtimeSpecifier = alias.startsWith("#") ? alias : builtSchema;
3006
3240
  return {
3007
3241
  kind: "instruct",
3008
3242
  instruction: `Plain Node / Express / Fastify have no framework hook that runs before your
@@ -3022,10 +3256,17 @@ import before it. If you have no server.ts yet, create it from TanStack's server
3022
3256
  template and put that import first.`
3023
3257
  };
3024
3258
  }
3025
- function astro(_context) {
3259
+ function astro({ alias }) {
3026
3260
  return {
3027
3261
  kind: "instruct",
3028
- 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.'
3262
+ instruction: `Astro has no single pre-app hook. Add a small integration to astro.config that
3263
+ injects the import into every SSR page, then cover the routes it cannot reach:
3264
+ \u2022 In astro.config integrations, add:
3265
+ { name: "penv", hooks: { "astro:config:setup": ({ injectScript }) =>
3266
+ injectScript("page-ssr", 'import "${alias}";') } }
3267
+ \u2022 Also add \`import "${alias}";\` as the first line of src/middleware.ts and of any
3268
+ endpoint route (src/pages/*.ts) that imports a library reading process.env.
3269
+ Injection only applies under a server (on-demand) adapter, not a static build.`
3029
3270
  };
3030
3271
  }
3031
3272
  function viteSpa(_context) {
@@ -3049,6 +3290,7 @@ function seamFor(framework, context) {
3049
3290
  }
3050
3291
 
3051
3292
  // src/commands/init.ts
3293
+ var SCHEMA_SHAPE_BASENAME = "penv.schema";
3052
3294
  var CONFIG_FILE = "penv.config.ts";
3053
3295
  var TSCONFIG_FILE = "tsconfig.json";
3054
3296
  var GITIGNORE_FILE = ".gitignore";
@@ -3111,8 +3353,8 @@ function configOf(decisions) {
3111
3353
  return { environments: decisions.environments, providers: {}, schemaFile: decisions.schemaFile };
3112
3354
  }
3113
3355
  function declaredIn(root) {
3114
- const file = join2(root, CONFIG_FILE);
3115
- if (!existsSync2(file)) {
3356
+ const file = join5(root, CONFIG_FILE);
3357
+ if (!existsSync4(file)) {
3116
3358
  return void 0;
3117
3359
  }
3118
3360
  return loadConfigFrom(file);
@@ -3135,7 +3377,7 @@ function planInit(root, flags = {}) {
3135
3377
  );
3136
3378
  }
3137
3379
  }
3138
- const schemaFile = flags.schema === void 0 ? declared !== void 0 ? schemaFileOf2(declared) : detected?.schemaFile ?? DEFAULT_SCHEMA_FILE2 : flags.schema.trim();
3380
+ const schemaFile = flags.schema === void 0 ? declared !== void 0 ? schemaFileOf4(declared) : detected?.schemaFile ?? DEFAULT_SCHEMA_FILE2 : flags.schema.trim();
3139
3381
  if (flags.schema !== void 0) {
3140
3382
  if (schemaFile.length === 0) {
3141
3383
  throw emptyFlag("schema");
@@ -3226,17 +3468,18 @@ async function promptForDecisions(plan2, io) {
3226
3468
  );
3227
3469
  io.write("");
3228
3470
  }
3229
- const inject = seamKindFor(plan2) === "none" ? false : await askInject(io);
3230
3471
  const proceed = (await io.ask(prompt("Proceed?", "Y/n"))).trim().toLowerCase();
3231
3472
  if (proceed.length > 0 && proceed !== "y" && proceed !== "yes") {
3232
3473
  return void 0;
3233
3474
  }
3475
+ const inject = seamKindFor(plan2) === "none" ? false : await askInject(io);
3234
3476
  return { ...plan2.decisions, environments, inject };
3235
3477
  }
3236
3478
  function seamKindFor(plan2) {
3237
3479
  return seamFor(plan2.detected?.name, {
3238
3480
  alias: plan2.decisions.alias,
3239
- srcDir: plan2.decisions.schemaFile.startsWith("src/") ? "src/" : ""
3481
+ srcDir: plan2.decisions.schemaFile.startsWith("src/") ? "src/" : "",
3482
+ schemaFile: plan2.decisions.schemaFile
3240
3483
  }).kind;
3241
3484
  }
3242
3485
  async function askInject(io) {
@@ -3249,20 +3492,17 @@ async function askInject(io) {
3249
3492
  }
3250
3493
  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.";
3251
3494
  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";
3252
- function renderSchemaModule(fields, draft, inject = false) {
3495
+ function renderSchemaShapeModule(fields, draft) {
3253
3496
  const body = fields.length === 0 ? EMPTY_SCHEMA_BODY : fields.map((field) => ` ${field.key}: ${field.type},`).join("\n");
3254
- 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";
3255
- const loadCall = inject ? "export const env = load(schema, { inject: true });\n" : "export const env = load(schema);\n";
3256
3497
  return `${draft ? DRAFT_HEADER : ""}import { z } from "zod";
3257
- import { load } from "@penvhq/penv";
3258
3498
 
3259
- // The shape. Import this (or z.infer<typeof schema>) when you only need the
3260
- // type \u2014 tests, tooling \u2014 so you don't trigger config loading.
3499
+ // The shape \u2014 the one definition every consumer derives from. Import this (or
3500
+ // z.infer<typeof schema>) when you only need the type \u2014 tests, tooling \u2014 so you
3501
+ // don't trigger config loading. .penv/env.ts wraps it with load().
3261
3502
  export const schema = z.object({
3262
3503
  ${body}
3263
3504
  });
3264
3505
 
3265
- ` + loadComment + loadCall + `
3266
3506
  // Registers the schema's shape with penv's types (erased at runtime, so
3267
3507
  // nothing cycles). This is what makes \`override\` keys in penv.config.ts
3268
3508
  // autocomplete from this schema \u2014 a typo'd parameter id is a compile error.
@@ -3273,6 +3513,24 @@ declare module "@penvhq/core" {
3273
3513
  }
3274
3514
  `;
3275
3515
  }
3516
+ function shapeSpecifier(schemaFile) {
3517
+ const depth = schemaFile.split("/").length - 1;
3518
+ const climb = depth === 0 ? "./" : "../".repeat(depth);
3519
+ return `${climb}${SCHEMA_SHAPE_BASENAME}.js`;
3520
+ }
3521
+ function renderEnvModule(schemaFile, inject = false) {
3522
+ 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. `snapshot` lets load() resolve in a bundled or\n// serverless runtime where no penv.config.ts is on disk.\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. `snapshot` lets load()\n// resolve in a bundled or serverless runtime where no penv.config.ts is on disk.\n";
3523
+ const loadCall = inject ? "export const env = load(schema, { inject: true, snapshot });\n" : "export const env = load(schema, { snapshot });\n";
3524
+ return `import { load } from "@penvhq/penv";
3525
+ import { schema } from "${shapeSpecifier(schemaFile)}";
3526
+ import { snapshot } from "${snapshotSpecifier(schemaFile)}";
3527
+
3528
+ // Re-exported so type-only consumers can import the shape through @env without
3529
+ // triggering config loading \u2014 the shape itself lives in ${SCHEMA_SHAPE_FILE2}.
3530
+ export { schema };
3531
+
3532
+ ` + loadComment + loadCall;
3533
+ }
3276
3534
  function renderEnvironments(decisions) {
3277
3535
  const shared = " // Environments are a whitelist. A filename segment is an environment only if\n // it is declared here \u2014 penv never infers one from a folder or a filename.\n";
3278
3536
  if (decisions.environments.length === 0) {
@@ -3280,7 +3538,7 @@ function renderEnvironments(decisions) {
3280
3538
  // can read off your codebase, and an environment you do not have is worse
3281
3539
  // than one you have not declared yet. Name yours, and give each a provider:
3282
3540
  // environments: ["development", "production"],
3283
- // providers: { development: { type: "@penvhq/provider-filesystem" }, production: { type: "@penvhq/provider-filesystem" } },
3541
+ // providers: { development: { type: "@penvhq/provider-filesystem" }, production: { type: "@penvhq/provider-vault" } },
3284
3542
  environments: [],
3285
3543
 
3286
3544
  providers: {},
@@ -3537,45 +3795,101 @@ function renderTsconfig(target, alias) {
3537
3795
  }
3538
3796
  function ensurePenvDir(root) {
3539
3797
  const dir = resolve4(root, PENV_DIR2);
3540
- if (existsSync2(dir)) {
3798
+ if (existsSync4(dir)) {
3541
3799
  return { target: "penv-dir", action: "kept", text: `Found ${PENV_DIR2}/` };
3542
3800
  }
3543
3801
  mkdirSync(dir, { recursive: true });
3544
3802
  return { target: "penv-dir", action: "created", text: `Created ${PENV_DIR2}/` };
3545
3803
  }
3546
- function writeSchemaFile(root, fields, draft, decisions = DEFAULT_DECISIONS) {
3547
- const file = join2(root, ...decisions.schemaFile.split("/"));
3548
- if (existsSync2(file)) {
3804
+ function writeSchemaShapeFile(root, fields, draft, decisions = DEFAULT_DECISIONS) {
3805
+ const file = join5(root, SCHEMA_SHAPE_FILE2);
3806
+ if (existsSync4(file)) {
3807
+ return {
3808
+ target: "schema",
3809
+ action: "kept",
3810
+ text: `Kept ${SCHEMA_SHAPE_FILE2}`,
3811
+ note: "(yours \u2014 penv never regenerates it)"
3812
+ };
3813
+ }
3814
+ const oldLayout = selfContainedSchemaModule(root, decisions.schemaFile);
3815
+ if (oldLayout !== void 0) {
3549
3816
  return {
3550
3817
  target: "schema",
3818
+ action: "conflicted",
3819
+ text: `Kept your schema in ${oldLayout}`,
3820
+ note: `(the shape lives there, from before the ${SCHEMA_SHAPE_FILE2} split \u2014 penv did not add a second one. To adopt the split: move the \`z.object\` (and any \`declare module\` block) into ${SCHEMA_SHAPE_FILE2}, leaving ${oldLayout} importing \`schema\` from it and calling \`load\`.)`
3821
+ };
3822
+ }
3823
+ writeFileSync3(file, renderSchemaShapeModule(fields, draft), "utf8");
3824
+ return {
3825
+ target: "schema",
3826
+ action: "created",
3827
+ text: `Generated ${SCHEMA_SHAPE_FILE2}`,
3828
+ note: draft ? "(draft schema \u2014 review it, it's yours)" : "(the shape \u2014 yours to edit)"
3829
+ };
3830
+ }
3831
+ function writeEnvFile(root, decisions = DEFAULT_DECISIONS) {
3832
+ const file = join5(root, ...decisions.schemaFile.split("/"));
3833
+ if (existsSync4(file)) {
3834
+ return {
3835
+ target: "env",
3551
3836
  action: "kept",
3552
3837
  text: `Kept ${decisions.schemaFile}`,
3553
3838
  note: "(yours \u2014 penv never regenerates it)"
3554
3839
  };
3555
3840
  }
3556
3841
  mkdirSync(dirname3(file), { recursive: true });
3557
- writeFileSync2(file, renderSchemaModule(fields, draft, decisions.inject), "utf8");
3842
+ writeFileSync3(file, renderEnvModule(decisions.schemaFile, decisions.inject), "utf8");
3558
3843
  return {
3559
- target: "schema",
3844
+ target: "env",
3560
3845
  action: "created",
3561
3846
  text: `Generated ${decisions.schemaFile}`,
3562
- note: draft ? "(draft schema \u2014 review it, it's yours)" : "(schema + loader \u2014 yours to edit)"
3847
+ note: "(loads the shape \u2014 yours to edit)"
3563
3848
  };
3564
3849
  }
3565
3850
  function writeConfigFile(root, decisions = DEFAULT_DECISIONS) {
3566
- const file = join2(root, CONFIG_FILE);
3567
- if (existsSync2(file)) {
3851
+ const file = join5(root, CONFIG_FILE);
3852
+ if (existsSync4(file)) {
3568
3853
  return { target: "config", action: "kept", text: `Kept ${CONFIG_FILE}` };
3569
3854
  }
3570
- writeFileSync2(file, renderConfigModule(decisions), "utf8");
3855
+ writeFileSync3(file, renderConfigModule(decisions), "utf8");
3571
3856
  return { target: "config", action: "created", text: `Generated ${CONFIG_FILE}` };
3572
3857
  }
3858
+ function snapshotConfig(decisions) {
3859
+ const providers = {};
3860
+ for (const environment of decisions.environments) {
3861
+ providers[environment] = { type: "@penvhq/provider-filesystem" };
3862
+ }
3863
+ return {
3864
+ environments: decisions.environments,
3865
+ providers,
3866
+ ...decisions.schemaFile === DEFAULT_SCHEMA_FILE2 ? {} : { schemaFile: decisions.schemaFile },
3867
+ ...decisions.publicPrefixes.length === 0 ? {} : { publicPrefixes: decisions.publicPrefixes }
3868
+ };
3869
+ }
3870
+ function writeSnapshotModule(root, decisions = DEFAULT_DECISIONS) {
3871
+ const file = join5(root, SNAPSHOT_FILE);
3872
+ if (existsSync4(file)) {
3873
+ return { target: "snapshot", action: "kept", text: `Kept ${SNAPSHOT_FILE}` };
3874
+ }
3875
+ writeFileSync3(
3876
+ file,
3877
+ renderSnapshotModule(buildSnapshotFrom(root, snapshotConfig(decisions))),
3878
+ "utf8"
3879
+ );
3880
+ return {
3881
+ target: "snapshot",
3882
+ action: "created",
3883
+ text: `Generated ${SNAPSHOT_FILE}`,
3884
+ note: "(committed \u2014 the bundle resolves load() from it)"
3885
+ };
3886
+ }
3573
3887
  function writeTsconfigAlias(root, decisions = DEFAULT_DECISIONS) {
3574
3888
  const alias = decisions.alias;
3575
3889
  const imports = alias.startsWith(IMPORTS_PREFIX);
3576
- const file = join2(root, imports ? PACKAGE_FILE : TSCONFIG_FILE);
3890
+ const file = join5(root, imports ? PACKAGE_FILE : TSCONFIG_FILE);
3577
3891
  const where = imports ? PACKAGE_FILE : TSCONFIG_FILE;
3578
- if (!existsSync2(file)) {
3892
+ if (!existsSync4(file)) {
3579
3893
  if (imports) {
3580
3894
  return {
3581
3895
  target: "tsconfig",
@@ -3584,14 +3898,14 @@ function writeTsconfigAlias(root, decisions = DEFAULT_DECISIONS) {
3584
3898
  note: `(run \`npm init\` first, or use \`--alias @env\` to alias through ${TSCONFIG_FILE})`
3585
3899
  };
3586
3900
  }
3587
- writeFileSync2(file, renderTsconfig(decisions.schemaFile, alias), "utf8");
3901
+ writeFileSync3(file, renderTsconfig(decisions.schemaFile, alias), "utf8");
3588
3902
  return {
3589
3903
  target: "tsconfig",
3590
3904
  action: "created",
3591
3905
  text: `Created ${TSCONFIG_FILE} with the ${alias} path alias`
3592
3906
  };
3593
3907
  }
3594
- const source = readFileSync2(file, "utf8");
3908
+ const source = readFileSync5(file, "utf8");
3595
3909
  const edit = imports ? insertImportsAlias(source, decisions.schemaFile, alias) : insertEnvAlias(source, decisions.schemaFile, alias);
3596
3910
  if (edit.conflict !== void 0) {
3597
3911
  return {
@@ -3608,7 +3922,7 @@ function writeTsconfigAlias(root, decisions = DEFAULT_DECISIONS) {
3608
3922
  text: `Kept the ${alias} alias in ${where}`
3609
3923
  };
3610
3924
  }
3611
- writeFileSync2(file, edit.source, "utf8");
3925
+ writeFileSync3(file, edit.source, "utf8");
3612
3926
  return {
3613
3927
  target: "tsconfig",
3614
3928
  action: "updated",
@@ -3616,30 +3930,63 @@ function writeTsconfigAlias(root, decisions = DEFAULT_DECISIONS) {
3616
3930
  };
3617
3931
  }
3618
3932
  function writeGitignore(root, decisions = DEFAULT_DECISIONS) {
3619
- const file = join2(root, PENV_DIR2, GITIGNORE_FILE);
3933
+ const file = join5(root, PENV_DIR2, GITIGNORE_FILE);
3620
3934
  const relative3 = `${PENV_DIR2}/${GITIGNORE_FILE}`;
3621
3935
  const wanted = renderGitignore(decisions);
3622
- const existing = existsSync2(file) ? readFileSync2(file, "utf8") : void 0;
3936
+ const existing = existsSync4(file) ? readFileSync5(file, "utf8") : void 0;
3623
3937
  if (existing === wanted) {
3624
3938
  return { target: "gitignore", action: "kept", text: `Kept ${relative3}` };
3625
3939
  }
3626
- mkdirSync(join2(root, PENV_DIR2), { recursive: true });
3627
- writeFileSync2(file, wanted, "utf8");
3940
+ mkdirSync(join5(root, PENV_DIR2), { recursive: true });
3941
+ writeFileSync3(file, wanted, "utf8");
3628
3942
  return {
3629
3943
  target: "gitignore",
3630
3944
  action: existing === void 0 ? "created" : "updated",
3631
3945
  text: `${existing === void 0 ? "Created" : "Updated"} ${relative3}`
3632
3946
  };
3633
3947
  }
3634
- function srcDirOf(root) {
3635
- return existsSync2(join2(root, "src")) ? "src/" : "";
3948
+ var INJECT_MIN_VERSION = "0.6.0";
3949
+ function outdatedRuntimeWarning(root) {
3950
+ const version = installedPenvVersion(root);
3951
+ if (version === void 0 || !isBelow(version, INJECT_MIN_VERSION)) {
3952
+ return void 0;
3953
+ }
3954
+ return `Injection needs @penvhq/penv ${INJECT_MIN_VERSION}+ \u2014 this project has ${version}, whose \`load\` ignores \`{ inject: true }\`. Upgrade, or process.env stays empty.`;
3955
+ }
3956
+ function installedPenvVersion(root) {
3957
+ const file = join5(root, "node_modules", "@penvhq", "penv", "package.json");
3958
+ if (!existsSync4(file)) {
3959
+ return void 0;
3960
+ }
3961
+ try {
3962
+ const version = JSON.parse(readFileSync5(file, "utf8")).version;
3963
+ return typeof version === "string" ? version : void 0;
3964
+ } catch {
3965
+ return void 0;
3966
+ }
3636
3967
  }
3637
- function writeSeam(root, decisions = DEFAULT_DECISIONS) {
3968
+ function releaseTriple(version) {
3969
+ const parts = (version.split("-")[0] ?? "").split(".");
3970
+ const at = (i) => Number.parseInt(parts[i] ?? "0", 10) || 0;
3971
+ return [at(0), at(1), at(2)];
3972
+ }
3973
+ function isBelow(a, b) {
3974
+ const [am, an, ap] = releaseTriple(a);
3975
+ const [bm, bn, bp] = releaseTriple(b);
3976
+ if (am !== bm) return am < bm;
3977
+ if (an !== bn) return an < bn;
3978
+ return ap < bp;
3979
+ }
3980
+ function writeSeam(root, decisions = DEFAULT_DECISIONS, framework = detectFramework(root)?.name) {
3638
3981
  if (!decisions.inject) {
3639
3982
  return void 0;
3640
3983
  }
3641
- const framework = detectFramework(root)?.name;
3642
- const seam = seamFor(framework, { alias: decisions.alias, srcDir: srcDirOf(root) });
3984
+ const seam = seamFor(framework, {
3985
+ alias: decisions.alias,
3986
+ srcDir: srcPrefix(root),
3987
+ schemaFile: decisions.schemaFile
3988
+ });
3989
+ const outdated = outdatedRuntimeWarning(root);
3643
3990
  if (seam.kind === "none") {
3644
3991
  return { target: "seam", action: "info", text: "No injection seam needed", note: seam.reason };
3645
3992
  }
@@ -3648,54 +3995,84 @@ function writeSeam(root, decisions = DEFAULT_DECISIONS) {
3648
3995
  target: "seam",
3649
3996
  action: "info",
3650
3997
  text: "Place the injection seam",
3651
- note: seam.instruction
3998
+ note: withWarning(seam.instruction, outdated)
3652
3999
  };
3653
4000
  }
3654
- const file = join2(root, ...seam.file.split("/"));
3655
- const notes = seam.notes.length === 0 ? "" : `
3656
- ${seam.notes.map((n) => ` ${n}`).join("\n")}`;
3657
- if (existsSync2(file)) {
4001
+ const alsoNote = writeAlso(root, seam.also);
4002
+ const file = join5(root, ...seam.file.split("/"));
4003
+ const baseNotes = [...seam.notes, ...alsoNote === void 0 ? [] : [alsoNote]];
4004
+ const notes = baseNotes.length === 0 ? "" : `
4005
+ ${baseNotes.map((n) => ` ${n}`).join("\n")}`;
4006
+ if (existsSync4(file)) {
3658
4007
  return {
3659
4008
  target: "seam",
3660
4009
  action: "info",
3661
4010
  text: `Add the injection seam to ${seam.file}`,
3662
- note: `${seam.ifPresent}${notes}`
4011
+ note: withWarning(`${seam.ifPresent}${notes}`, outdated)
3663
4012
  };
3664
4013
  }
3665
4014
  mkdirSync(dirname3(file), { recursive: true });
3666
- writeFileSync2(file, seam.content, "utf8");
4015
+ writeFileSync3(file, seam.content, "utf8");
3667
4016
  return {
3668
4017
  target: "seam",
3669
- action: "created",
4018
+ action: outdated === void 0 ? "created" : "info",
3670
4019
  text: `Wrote ${seam.file} (runs the injection before your app)`,
3671
- ...notes === "" ? {} : { note: notes.trimStart() }
4020
+ ...notes === "" && outdated === void 0 ? {} : { note: withWarning(notes.trimStart(), outdated) }
3672
4021
  };
3673
4022
  }
3674
- function scaffold(root, fields, draft, decisions = DEFAULT_DECISIONS) {
4023
+ function writeAlso(root, also) {
4024
+ if (also === void 0) {
4025
+ return void 0;
4026
+ }
4027
+ const file = join5(root, ...also.file.split("/"));
4028
+ if (existsSync4(file)) {
4029
+ return also.ifPresent;
4030
+ }
4031
+ mkdirSync(dirname3(file), { recursive: true });
4032
+ writeFileSync3(file, also.content, "utf8");
4033
+ return `Wrote ${also.file} to register it.`;
4034
+ }
4035
+ function withWarning(note, warning) {
4036
+ if (warning === void 0) {
4037
+ return note;
4038
+ }
4039
+ return note === "" ? warning : `${note}
4040
+ \u26A0 ${warning}`;
4041
+ }
4042
+ function scaffold(root, fields, draft, decisions = DEFAULT_DECISIONS, framework = detectFramework(root)?.name) {
3675
4043
  const steps = [
3676
4044
  ensurePenvDir(root),
3677
- writeSchemaFile(root, fields, draft, decisions),
4045
+ // The shape at the root, then the wrapper that loads it — both write-once.
4046
+ // The shape step needs the decisions too: an old-layout `schemaFile` already
4047
+ // holds the shape, and a second `penv.schema.ts` beside it would not compile.
4048
+ writeSchemaShapeFile(root, fields, draft, decisions),
4049
+ writeEnvFile(root, decisions),
3678
4050
  writeConfigFile(root, decisions),
4051
+ // After the config, whose evaluated shape the snapshot embeds; the wrapper
4052
+ // env.ts written above already imports it.
4053
+ writeSnapshotModule(root, decisions),
3679
4054
  writeTsconfigAlias(root, decisions),
3680
4055
  writeGitignore(root, decisions)
3681
4056
  ];
3682
- const seam = writeSeam(root, decisions);
4057
+ const seam = writeSeam(root, decisions, framework);
3683
4058
  return seam === void 0 ? steps : [...steps, seam];
3684
4059
  }
3685
4060
  function runInit(options) {
3686
4061
  const root = resolve4(options.cwd);
3687
4062
  const decisions = options.decisions ?? planInit(root).decisions;
3688
- return { root, decisions, steps: scaffold(root, [], false, decisions) };
4063
+ return { root, decisions, steps: scaffold(root, [], false, decisions, options.framework) };
3689
4064
  }
3690
4065
  function renderInit(result2) {
3691
4066
  const steps = result2.steps.map((step) => {
3692
4067
  const glyph = step.action === "conflicted" ? WARN : step.action === "info" ? "\u2192" : CHECK;
3693
4068
  return step.note === void 0 ? { glyph, text: step.text } : { glyph, text: step.text, note: step.note };
3694
4069
  });
4070
+ const schemaStep = result2.steps.find((step) => step.target === "schema");
4071
+ const shapeFile = schemaStep?.action === "conflicted" ? result2.decisions.schemaFile : SCHEMA_SHAPE_FILE2;
3695
4072
  return [
3696
4073
  ...formatSteps(steps),
3697
4074
  "",
3698
- `${out.green(CHECK)} ${out.bold("Done.")} Declare your parameters in ${result2.decisions.schemaFile}, then:`,
4075
+ `${out.green(CHECK)} ${out.bold("Done.")} Declare your parameters in ${shapeFile}, then:`,
3699
4076
  tip(out.cyan("penv set <key>")),
3700
4077
  ...result2.decisions.environments.length === 0 ? [
3701
4078
  `Then declare your environments in ${CONFIG_FILE}: penv leaves the whitelist empty rather than inventing one, and every command needs it.`
@@ -3752,7 +4129,15 @@ var initCommand = defineCommand9({
3752
4129
  if (!asked) {
3753
4130
  write([...plan2.notes, ""]);
3754
4131
  }
3755
- write(renderInit(runInit({ cwd: root, decisions })));
4132
+ write(
4133
+ renderInit(
4134
+ runInit({
4135
+ cwd: root,
4136
+ decisions,
4137
+ ...plan2.detected && { framework: plan2.detected.name }
4138
+ })
4139
+ )
4140
+ );
3756
4141
  });
3757
4142
  }
3758
4143
  });
@@ -3925,7 +4310,7 @@ function decisionsOf(config, cwd) {
3925
4310
  // `import` re-scaffolds env.ts for an existing project; injection is an init
3926
4311
  // choice, so it is not turned on here.
3927
4312
  inject: false,
3928
- schemaFile: schemaFileOf3(config),
4313
+ schemaFile: schemaFileOf5(config),
3929
4314
  publicPrefixes: config.publicPrefixes ?? [],
3930
4315
  alias: detectAlias(cwd)
3931
4316
  };
@@ -3947,14 +4332,14 @@ function configInEffect(cwd, environment) {
3947
4332
  function importDotenv(options) {
3948
4333
  const cwd = resolve5(options.cwd);
3949
4334
  const file = isAbsolute2(options.file) ? options.file : resolve5(cwd, options.file);
3950
- if (!existsSync3(file)) {
4335
+ if (!existsSync5(file)) {
3951
4336
  throw new PenvError13(
3952
4337
  "IMPORT_FILE_MISSING",
3953
4338
  `There is no file at ${file} to import`,
3954
4339
  "Point `penv import` at an existing dotenv file, e.g. `penv import .env`."
3955
4340
  );
3956
4341
  }
3957
- const parsed = parseDotenv(readFileSync3(file, "utf8"));
4342
+ const parsed = parseDotenv(readFileSync6(file, "utf8"));
3958
4343
  const { config, decisions } = configInEffect(cwd, environmentNamed(file, options.environment));
3959
4344
  const source = displayPath2(cwd, file);
3960
4345
  const named = scopeFromFilename(file, config);
@@ -3992,6 +4377,7 @@ function importDotenv(options) {
3992
4377
  }
3993
4378
  const backup = `${file}${BACKUP_SUFFIX}`;
3994
4379
  copyFileSync(file, backup);
4380
+ refreshSnapshot(project);
3995
4381
  return {
3996
4382
  root: project.root,
3997
4383
  file,
@@ -4304,7 +4690,7 @@ var listCommand = defineCommand12({
4304
4690
  import {
4305
4691
  accessPath as accessPath5,
4306
4692
  formatMetaFile,
4307
- formatValueFile as formatValueFile5,
4693
+ formatValueFile as formatValueFile6,
4308
4694
  openValue as openValue4,
4309
4695
  PenvError as PenvError15,
4310
4696
  parameterId as parameterId5,
@@ -4327,7 +4713,7 @@ async function planFile(project, source, target, parameter) {
4327
4713
  if (environment === void 0) {
4328
4714
  throw new PenvError15(
4329
4715
  "SECRET_SCOPE_AMBIGUOUS",
4330
- `${PENV_DIR}/${formatValueFile5(source)} is encrypted at a scope that names no environment, so penv cannot tell which key would re-seal it`,
4716
+ `${PENV_DIR}/${formatValueFile6(source)} is encrypted at a scope that names no environment, so penv cannot tell which key would re-seal it`,
4331
4717
  "Keys are declared per environment in the `keys` block of penv.config.ts. Decrypt it with `penv decrypt`, move the parameter, then encrypt it again at its new address."
4332
4718
  );
4333
4719
  }
@@ -4336,7 +4722,7 @@ async function planFile(project, source, target, parameter) {
4336
4722
  if (opened.kind === "failed") {
4337
4723
  throw new PenvError15(
4338
4724
  "VALUE_UNDECRYPTABLE",
4339
- `${PENV_DIR}/${formatValueFile5(source)} could not be decrypted, so penv cannot re-seal it at its new address: ${opened.failure.detail}`,
4725
+ `${PENV_DIR}/${formatValueFile6(source)} could not be decrypted, so penv cannot re-seal it at its new address: ${opened.failure.detail}`,
4340
4726
  "A sealed value is bound to the file it lives in, so moving it means opening it and sealing it again. Make the key available and run this again. Nothing has been moved."
4341
4727
  );
4342
4728
  }
@@ -4401,19 +4787,21 @@ async function runMove(options) {
4401
4787
  if (meta !== void 0) {
4402
4788
  await project.provider.removeMeta(from);
4403
4789
  }
4790
+ refreshSnapshot(project);
4404
4791
  return {
4405
4792
  from: parameterId5(from),
4406
4793
  to: parameterId5(to),
4407
4794
  files: planned.map((file) => ({
4408
- from: formatValueFile5(file.source),
4409
- to: formatValueFile5(file.target),
4795
+ from: formatValueFile6(file.source),
4796
+ to: formatValueFile6(file.target),
4410
4797
  resealed: file.resealed
4411
4798
  })),
4412
4799
  // The meta's path, not the parameter's dotted id: `redis.password` is what
4413
4800
  // the schema calls it and `redis/password.json` is the file, and a report
4414
4801
  // that printed the first while moving the second names no file on disk.
4415
4802
  meta: meta === void 0 ? void 0 : formatMetaFile({ ...to, format: "json" }),
4416
- schema: { was: accessPath5(from).join("."), now: accessPath5(to).join(".") }
4803
+ schema: { was: accessPath5(from).join("."), now: accessPath5(to).join(".") },
4804
+ schemaFile: schemaShapeFileOf(project)
4417
4805
  };
4418
4806
  }
4419
4807
  function renderMove(result2) {
@@ -4430,7 +4818,7 @@ function renderMove(result2) {
4430
4818
  lines.push(
4431
4819
  "",
4432
4820
  tip(
4433
- `.penv/env.ts still declares \`${result2.schema.was}\` \u2014 rename it to \`${result2.schema.now}\`, or \`penv validate\` will report the value as unused and the declaration as unset.`
4821
+ `${result2.schemaFile} still declares \`${result2.schema.was}\` \u2014 rename it to \`${result2.schema.now}\`, or \`penv validate\` will report the value as unused and the declaration as unset.`
4434
4822
  )
4435
4823
  );
4436
4824
  return lines;
@@ -4517,6 +4905,7 @@ async function runPull(options) {
4517
4905
  tree.writeMetaSync(ref, block);
4518
4906
  meta += 1;
4519
4907
  }
4908
+ refreshSnapshot(project);
4520
4909
  return { environment, source: source.type, localSource: false, values, meta, refs: refs.length };
4521
4910
  }
4522
4911
  function renderPull(result2) {
@@ -4585,7 +4974,7 @@ var pullCommand = defineCommand14({
4585
4974
  });
4586
4975
 
4587
4976
  // src/commands/remove.ts
4588
- import { formatValueFile as formatValueFile6 } from "@penvhq/core";
4977
+ import { formatValueFile as formatValueFile7 } from "@penvhq/core";
4589
4978
  import { defineCommand as defineCommand15 } from "citty";
4590
4979
  async function runRemove(options) {
4591
4980
  const project = openProject(options.cwd);
@@ -4603,12 +4992,13 @@ async function runRemove(options) {
4603
4992
  continue;
4604
4993
  }
4605
4994
  await project.provider.remove(file);
4606
- removed.push(formatValueFile6(file));
4995
+ removed.push(formatValueFile7(file));
4607
4996
  }
4997
+ refreshSnapshot(project);
4608
4998
  return {
4609
4999
  parameter: options.key,
4610
5000
  removed,
4611
- considered: files.map((file) => formatValueFile6(file))
5001
+ considered: files.map((file) => formatValueFile7(file))
4612
5002
  };
4613
5003
  }
4614
5004
  function renderRemove(result2) {
@@ -4684,6 +5074,7 @@ async function writeRotatedValue(project, provider, ref, environment, value) {
4684
5074
  value,
4685
5075
  environment
4686
5076
  });
5077
+ refreshSnapshot(project);
4687
5078
  return;
4688
5079
  }
4689
5080
  await provider.write(rotatingFile(ref, environment), value);
@@ -4851,11 +5242,53 @@ var rotateCommand = defineCommand16({
4851
5242
  }
4852
5243
  });
4853
5244
 
5245
+ // src/commands/snapshot.ts
5246
+ import { defineCommand as defineCommand17 } from "citty";
5247
+ function runSnapshot(options) {
5248
+ const project = openProject(options.cwd);
5249
+ return { write: writeSnapshotFile(project), wire: wireEnvModule(project) };
5250
+ }
5251
+ function renderSnapshot(result2) {
5252
+ const label = result2.write.action === "created" ? "Generated" : result2.write.action === "updated" ? "Updated" : "Up to date";
5253
+ const rows = [{ glyph: CHECK, label, subject: SNAPSHOT_FILE }];
5254
+ if (result2.wire.action === "wired") {
5255
+ rows.push({
5256
+ glyph: CHECK,
5257
+ label: "Wired",
5258
+ subject: result2.wire.file,
5259
+ detail: "load reads the snapshot"
5260
+ });
5261
+ } else if (result2.wire.action === "kept") {
5262
+ rows.push({ glyph: CHECK, label: "Wired", subject: result2.wire.file });
5263
+ }
5264
+ const lines = formatRows(rows);
5265
+ if (result2.wire.action === "manual") {
5266
+ lines.push(
5267
+ "",
5268
+ `${WARN} penv could not wire ${result2.wire.file} automatically \u2014 add these two lines yourself:`,
5269
+ tip(result2.wire.importLine),
5270
+ tip(result2.wire.loadHint)
5271
+ );
5272
+ }
5273
+ return lines;
5274
+ }
5275
+ var snapshotCommand = defineCommand17({
5276
+ meta: {
5277
+ name: "snapshot",
5278
+ description: "Generate penv.snapshot.ts so load() resolves in a bundled/serverless runtime"
5279
+ },
5280
+ run() {
5281
+ return guard(async () => {
5282
+ write(renderSnapshot(runSnapshot({ cwd: process.cwd() })));
5283
+ });
5284
+ }
5285
+ });
5286
+
4854
5287
  // src/commands/watch.ts
4855
- import { existsSync as existsSync4, watch } from "fs";
5288
+ import { existsSync as existsSync6, watch } from "fs";
4856
5289
  import { basename as basename2, dirname as dirname4, resolve as resolve6 } from "path";
4857
- import { schemaFileOf as schemaFileOf4, schemaInsideTree as schemaInsideTree2 } from "@penvhq/core";
4858
- import { defineCommand as defineCommand17 } from "citty";
5290
+ import { SCHEMA_SHAPE_FILE as SCHEMA_SHAPE_FILE3, schemaFileOf as schemaFileOf6, schemaInsideTree as schemaInsideTree2 } from "@penvhq/core";
5291
+ import { defineCommand as defineCommand18 } from "citty";
4859
5292
  var DEBOUNCE_MS = 100;
4860
5293
  function runWatch(options) {
4861
5294
  const project = openProject(options.cwd);
@@ -4920,14 +5353,14 @@ function runWatch(options) {
4920
5353
  if (closed || recovery === void 0) {
4921
5354
  return;
4922
5355
  }
4923
- if (!existsSync4(parent)) {
5356
+ if (!existsSync6(parent)) {
4924
5357
  stop(recovery);
4925
5358
  return;
4926
5359
  }
4927
5360
  if (filename !== null && basename2(filename) !== name) {
4928
5361
  return;
4929
5362
  }
4930
- if (!existsSync4(target)) {
5363
+ if (!existsSync6(target)) {
4931
5364
  return;
4932
5365
  }
4933
5366
  stop(recovery);
@@ -4951,7 +5384,7 @@ function runWatch(options) {
4951
5384
  if (closed) {
4952
5385
  return;
4953
5386
  }
4954
- if (!existsSync4(target)) {
5387
+ if (!existsSync6(target)) {
4955
5388
  if (watcher !== void 0) {
4956
5389
  stop(watcher);
4957
5390
  }
@@ -4987,8 +5420,9 @@ function runWatch(options) {
4987
5420
  }
4988
5421
  addWatcher(project.penvDir, true);
4989
5422
  addWatcher(dirname4(project.configFile), false, configFile);
5423
+ addWatcher(project.root, false, SCHEMA_SHAPE_FILE3);
4990
5424
  if (schemaInsideTree2(project.config) === void 0) {
4991
- const schemaFile = resolve6(project.root, schemaFileOf4(project.config));
5425
+ const schemaFile = resolve6(project.root, schemaFileOf6(project.config));
4992
5426
  addWatcher(dirname4(schemaFile), false, basename2(schemaFile));
4993
5427
  }
4994
5428
  void validate();
@@ -5032,7 +5466,7 @@ function renderDrift(drift, environment) {
5032
5466
  }
5033
5467
  return lines;
5034
5468
  }
5035
- var watchCommand = defineCommand17({
5469
+ var watchCommand = defineCommand18({
5036
5470
  meta: {
5037
5471
  name: "watch",
5038
5472
  description: "Re-validate whenever .penv/ or penv.config.ts changes"
@@ -5071,7 +5505,7 @@ var watchCommand = defineCommand17({
5071
5505
  });
5072
5506
 
5073
5507
  // src/index.ts
5074
- var main = defineCommand18({
5508
+ var main = defineCommand19({
5075
5509
  meta: {
5076
5510
  name: "penv",
5077
5511
  description: "Configuration that shares a data model with your production secret manager"
@@ -5092,6 +5526,7 @@ var main = defineCommand18({
5092
5526
  encrypt: encryptCommand,
5093
5527
  decrypt: decryptCommand,
5094
5528
  key: keyCommand,
5529
+ snapshot: snapshotCommand,
5095
5530
  validate: validateCommand,
5096
5531
  doctor: doctorCommand,
5097
5532
  watch: watchCommand
@@ -5113,6 +5548,7 @@ export {
5113
5548
  renderPull,
5114
5549
  renderPush,
5115
5550
  renderRotate,
5551
+ renderSnapshot,
5116
5552
  renderWatch,
5117
5553
  runDecrypt,
5118
5554
  runDoctor,
@@ -5130,6 +5566,7 @@ export {
5130
5566
  runRemove,
5131
5567
  runRotate,
5132
5568
  runSet,
5569
+ runSnapshot,
5133
5570
  runValidate,
5134
5571
  runWatch
5135
5572
  };