@penvhq/cli 0.7.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,8 +3053,8 @@ 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
3060
  const dir = srcPrefix(cwd);
@@ -2845,7 +3069,7 @@ function schemaFileFor(cwd) {
2845
3069
  return { file: DEFAULT_SCHEMA_FILE, displaced: preferred };
2846
3070
  }
2847
3071
  function srcPrefix(cwd) {
2848
- return existsSync(join(cwd, "src")) ? "src/" : "";
3072
+ return existsSync3(join4(cwd, "src")) ? "src/" : "";
2849
3073
  }
2850
3074
  function dependenciesOf(cwd) {
2851
3075
  const manifest = manifestOf(cwd);
@@ -2864,13 +3088,13 @@ function dependenciesOf(cwd) {
2864
3088
  return names;
2865
3089
  }
2866
3090
  function manifestOf(cwd) {
2867
- const file = join(cwd, "package.json");
2868
- if (!existsSync(file)) {
3091
+ const file = join4(cwd, "package.json");
3092
+ if (!existsSync3(file)) {
2869
3093
  return void 0;
2870
3094
  }
2871
3095
  let manifest;
2872
3096
  try {
2873
- manifest = JSON.parse(readFileSync(file, "utf8"));
3097
+ manifest = JSON.parse(readFileSync4(file, "utf8"));
2874
3098
  } catch {
2875
3099
  return void 0;
2876
3100
  }
@@ -2886,7 +3110,7 @@ function detectFramework(cwd) {
2886
3110
  return detectedFrom(cwd, signature.name, signature.publicPrefixes);
2887
3111
  }
2888
3112
  }
2889
- if (existsSync(join(cwd, "bunfig.toml")) || dependencies.has("bun-types")) {
3113
+ if (existsSync3(join4(cwd, "bunfig.toml")) || dependencies.has("bun-types")) {
2890
3114
  return detectedFrom(cwd, "Bun", []);
2891
3115
  }
2892
3116
  return void 0;
@@ -2912,8 +3136,8 @@ function hasImportsBlock(cwd) {
2912
3136
  }
2913
3137
 
2914
3138
  // src/commands/init.ts
2915
- import { existsSync as existsSync2, mkdirSync, readdirSync, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
2916
- 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";
2917
3141
  import { createInterface as createInterface2 } from "readline/promises";
2918
3142
  import {
2919
3143
  DEFAULT_SCHEMA_FILE as DEFAULT_SCHEMA_FILE2,
@@ -2921,7 +3145,8 @@ import {
2921
3145
  loadConfigFrom,
2922
3146
  PenvError as PenvError12,
2923
3147
  RESERVED_TOKENS,
2924
- schemaFileOf as schemaFileOf2,
3148
+ SCHEMA_SHAPE_FILE as SCHEMA_SHAPE_FILE2,
3149
+ schemaFileOf as schemaFileOf4,
2925
3150
  schemaInsideTree,
2926
3151
  validateSchemaFile
2927
3152
  } from "@penvhq/core";
@@ -3065,6 +3290,7 @@ function seamFor(framework, context) {
3065
3290
  }
3066
3291
 
3067
3292
  // src/commands/init.ts
3293
+ var SCHEMA_SHAPE_BASENAME = "penv.schema";
3068
3294
  var CONFIG_FILE = "penv.config.ts";
3069
3295
  var TSCONFIG_FILE = "tsconfig.json";
3070
3296
  var GITIGNORE_FILE = ".gitignore";
@@ -3127,8 +3353,8 @@ function configOf(decisions) {
3127
3353
  return { environments: decisions.environments, providers: {}, schemaFile: decisions.schemaFile };
3128
3354
  }
3129
3355
  function declaredIn(root) {
3130
- const file = join2(root, CONFIG_FILE);
3131
- if (!existsSync2(file)) {
3356
+ const file = join5(root, CONFIG_FILE);
3357
+ if (!existsSync4(file)) {
3132
3358
  return void 0;
3133
3359
  }
3134
3360
  return loadConfigFrom(file);
@@ -3151,7 +3377,7 @@ function planInit(root, flags = {}) {
3151
3377
  );
3152
3378
  }
3153
3379
  }
3154
- 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();
3155
3381
  if (flags.schema !== void 0) {
3156
3382
  if (schemaFile.length === 0) {
3157
3383
  throw emptyFlag("schema");
@@ -3266,20 +3492,17 @@ async function askInject(io) {
3266
3492
  }
3267
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.";
3268
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";
3269
- function renderSchemaModule(fields, draft, inject = false) {
3495
+ function renderSchemaShapeModule(fields, draft) {
3270
3496
  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";
3273
3497
  return `${draft ? DRAFT_HEADER : ""}import { z } from "zod";
3274
- import { load } from "@penvhq/penv";
3275
3498
 
3276
- // The shape. Import this (or z.infer<typeof schema>) when you only need the
3277
- // 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().
3278
3502
  export const schema = z.object({
3279
3503
  ${body}
3280
3504
  });
3281
3505
 
3282
- ` + loadComment + loadCall + `
3283
3506
  // Registers the schema's shape with penv's types (erased at runtime, so
3284
3507
  // nothing cycles). This is what makes \`override\` keys in penv.config.ts
3285
3508
  // autocomplete from this schema \u2014 a typo'd parameter id is a compile error.
@@ -3290,6 +3513,24 @@ declare module "@penvhq/core" {
3290
3513
  }
3291
3514
  `;
3292
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
+ }
3293
3534
  function renderEnvironments(decisions) {
3294
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";
3295
3536
  if (decisions.environments.length === 0) {
@@ -3297,7 +3538,7 @@ function renderEnvironments(decisions) {
3297
3538
  // can read off your codebase, and an environment you do not have is worse
3298
3539
  // than one you have not declared yet. Name yours, and give each a provider:
3299
3540
  // environments: ["development", "production"],
3300
- // providers: { development: { type: "@penvhq/provider-filesystem" }, production: { type: "@penvhq/provider-filesystem" } },
3541
+ // providers: { development: { type: "@penvhq/provider-filesystem" }, production: { type: "@penvhq/provider-vault" } },
3301
3542
  environments: [],
3302
3543
 
3303
3544
  providers: {},
@@ -3554,45 +3795,101 @@ function renderTsconfig(target, alias) {
3554
3795
  }
3555
3796
  function ensurePenvDir(root) {
3556
3797
  const dir = resolve4(root, PENV_DIR2);
3557
- if (existsSync2(dir)) {
3798
+ if (existsSync4(dir)) {
3558
3799
  return { target: "penv-dir", action: "kept", text: `Found ${PENV_DIR2}/` };
3559
3800
  }
3560
3801
  mkdirSync(dir, { recursive: true });
3561
3802
  return { target: "penv-dir", action: "created", text: `Created ${PENV_DIR2}/` };
3562
3803
  }
3563
- function writeSchemaFile(root, fields, draft, decisions = DEFAULT_DECISIONS) {
3564
- const file = join2(root, ...decisions.schemaFile.split("/"));
3565
- if (existsSync2(file)) {
3804
+ function writeSchemaShapeFile(root, fields, draft, decisions = DEFAULT_DECISIONS) {
3805
+ const file = join5(root, SCHEMA_SHAPE_FILE2);
3806
+ if (existsSync4(file)) {
3566
3807
  return {
3567
3808
  target: "schema",
3568
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) {
3816
+ return {
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",
3836
+ action: "kept",
3569
3837
  text: `Kept ${decisions.schemaFile}`,
3570
3838
  note: "(yours \u2014 penv never regenerates it)"
3571
3839
  };
3572
3840
  }
3573
3841
  mkdirSync(dirname3(file), { recursive: true });
3574
- writeFileSync2(file, renderSchemaModule(fields, draft, decisions.inject), "utf8");
3842
+ writeFileSync3(file, renderEnvModule(decisions.schemaFile, decisions.inject), "utf8");
3575
3843
  return {
3576
- target: "schema",
3844
+ target: "env",
3577
3845
  action: "created",
3578
3846
  text: `Generated ${decisions.schemaFile}`,
3579
- 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)"
3580
3848
  };
3581
3849
  }
3582
3850
  function writeConfigFile(root, decisions = DEFAULT_DECISIONS) {
3583
- const file = join2(root, CONFIG_FILE);
3584
- if (existsSync2(file)) {
3851
+ const file = join5(root, CONFIG_FILE);
3852
+ if (existsSync4(file)) {
3585
3853
  return { target: "config", action: "kept", text: `Kept ${CONFIG_FILE}` };
3586
3854
  }
3587
- writeFileSync2(file, renderConfigModule(decisions), "utf8");
3855
+ writeFileSync3(file, renderConfigModule(decisions), "utf8");
3588
3856
  return { target: "config", action: "created", text: `Generated ${CONFIG_FILE}` };
3589
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
+ }
3590
3887
  function writeTsconfigAlias(root, decisions = DEFAULT_DECISIONS) {
3591
3888
  const alias = decisions.alias;
3592
3889
  const imports = alias.startsWith(IMPORTS_PREFIX);
3593
- const file = join2(root, imports ? PACKAGE_FILE : TSCONFIG_FILE);
3890
+ const file = join5(root, imports ? PACKAGE_FILE : TSCONFIG_FILE);
3594
3891
  const where = imports ? PACKAGE_FILE : TSCONFIG_FILE;
3595
- if (!existsSync2(file)) {
3892
+ if (!existsSync4(file)) {
3596
3893
  if (imports) {
3597
3894
  return {
3598
3895
  target: "tsconfig",
@@ -3601,14 +3898,14 @@ function writeTsconfigAlias(root, decisions = DEFAULT_DECISIONS) {
3601
3898
  note: `(run \`npm init\` first, or use \`--alias @env\` to alias through ${TSCONFIG_FILE})`
3602
3899
  };
3603
3900
  }
3604
- writeFileSync2(file, renderTsconfig(decisions.schemaFile, alias), "utf8");
3901
+ writeFileSync3(file, renderTsconfig(decisions.schemaFile, alias), "utf8");
3605
3902
  return {
3606
3903
  target: "tsconfig",
3607
3904
  action: "created",
3608
3905
  text: `Created ${TSCONFIG_FILE} with the ${alias} path alias`
3609
3906
  };
3610
3907
  }
3611
- const source = readFileSync2(file, "utf8");
3908
+ const source = readFileSync5(file, "utf8");
3612
3909
  const edit = imports ? insertImportsAlias(source, decisions.schemaFile, alias) : insertEnvAlias(source, decisions.schemaFile, alias);
3613
3910
  if (edit.conflict !== void 0) {
3614
3911
  return {
@@ -3625,7 +3922,7 @@ function writeTsconfigAlias(root, decisions = DEFAULT_DECISIONS) {
3625
3922
  text: `Kept the ${alias} alias in ${where}`
3626
3923
  };
3627
3924
  }
3628
- writeFileSync2(file, edit.source, "utf8");
3925
+ writeFileSync3(file, edit.source, "utf8");
3629
3926
  return {
3630
3927
  target: "tsconfig",
3631
3928
  action: "updated",
@@ -3633,15 +3930,15 @@ function writeTsconfigAlias(root, decisions = DEFAULT_DECISIONS) {
3633
3930
  };
3634
3931
  }
3635
3932
  function writeGitignore(root, decisions = DEFAULT_DECISIONS) {
3636
- const file = join2(root, PENV_DIR2, GITIGNORE_FILE);
3933
+ const file = join5(root, PENV_DIR2, GITIGNORE_FILE);
3637
3934
  const relative3 = `${PENV_DIR2}/${GITIGNORE_FILE}`;
3638
3935
  const wanted = renderGitignore(decisions);
3639
- const existing = existsSync2(file) ? readFileSync2(file, "utf8") : void 0;
3936
+ const existing = existsSync4(file) ? readFileSync5(file, "utf8") : void 0;
3640
3937
  if (existing === wanted) {
3641
3938
  return { target: "gitignore", action: "kept", text: `Kept ${relative3}` };
3642
3939
  }
3643
- mkdirSync(join2(root, PENV_DIR2), { recursive: true });
3644
- writeFileSync2(file, wanted, "utf8");
3940
+ mkdirSync(join5(root, PENV_DIR2), { recursive: true });
3941
+ writeFileSync3(file, wanted, "utf8");
3645
3942
  return {
3646
3943
  target: "gitignore",
3647
3944
  action: existing === void 0 ? "created" : "updated",
@@ -3657,12 +3954,12 @@ function outdatedRuntimeWarning(root) {
3657
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.`;
3658
3955
  }
3659
3956
  function installedPenvVersion(root) {
3660
- const file = join2(root, "node_modules", "@penvhq", "penv", "package.json");
3661
- if (!existsSync2(file)) {
3957
+ const file = join5(root, "node_modules", "@penvhq", "penv", "package.json");
3958
+ if (!existsSync4(file)) {
3662
3959
  return void 0;
3663
3960
  }
3664
3961
  try {
3665
- const version = JSON.parse(readFileSync2(file, "utf8")).version;
3962
+ const version = JSON.parse(readFileSync5(file, "utf8")).version;
3666
3963
  return typeof version === "string" ? version : void 0;
3667
3964
  } catch {
3668
3965
  return void 0;
@@ -3702,11 +3999,11 @@ function writeSeam(root, decisions = DEFAULT_DECISIONS, framework = detectFramew
3702
3999
  };
3703
4000
  }
3704
4001
  const alsoNote = writeAlso(root, seam.also);
3705
- const file = join2(root, ...seam.file.split("/"));
4002
+ const file = join5(root, ...seam.file.split("/"));
3706
4003
  const baseNotes = [...seam.notes, ...alsoNote === void 0 ? [] : [alsoNote]];
3707
4004
  const notes = baseNotes.length === 0 ? "" : `
3708
4005
  ${baseNotes.map((n) => ` ${n}`).join("\n")}`;
3709
- if (existsSync2(file)) {
4006
+ if (existsSync4(file)) {
3710
4007
  return {
3711
4008
  target: "seam",
3712
4009
  action: "info",
@@ -3715,7 +4012,7 @@ ${baseNotes.map((n) => ` ${n}`).join("\n")}`;
3715
4012
  };
3716
4013
  }
3717
4014
  mkdirSync(dirname3(file), { recursive: true });
3718
- writeFileSync2(file, seam.content, "utf8");
4015
+ writeFileSync3(file, seam.content, "utf8");
3719
4016
  return {
3720
4017
  target: "seam",
3721
4018
  action: outdated === void 0 ? "created" : "info",
@@ -3727,12 +4024,12 @@ function writeAlso(root, also) {
3727
4024
  if (also === void 0) {
3728
4025
  return void 0;
3729
4026
  }
3730
- const file = join2(root, ...also.file.split("/"));
3731
- if (existsSync2(file)) {
4027
+ const file = join5(root, ...also.file.split("/"));
4028
+ if (existsSync4(file)) {
3732
4029
  return also.ifPresent;
3733
4030
  }
3734
4031
  mkdirSync(dirname3(file), { recursive: true });
3735
- writeFileSync2(file, also.content, "utf8");
4032
+ writeFileSync3(file, also.content, "utf8");
3736
4033
  return `Wrote ${also.file} to register it.`;
3737
4034
  }
3738
4035
  function withWarning(note, warning) {
@@ -3745,8 +4042,15 @@ function withWarning(note, warning) {
3745
4042
  function scaffold(root, fields, draft, decisions = DEFAULT_DECISIONS, framework = detectFramework(root)?.name) {
3746
4043
  const steps = [
3747
4044
  ensurePenvDir(root),
3748
- 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),
3749
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),
3750
4054
  writeTsconfigAlias(root, decisions),
3751
4055
  writeGitignore(root, decisions)
3752
4056
  ];
@@ -3763,10 +4067,12 @@ function renderInit(result2) {
3763
4067
  const glyph = step.action === "conflicted" ? WARN : step.action === "info" ? "\u2192" : CHECK;
3764
4068
  return step.note === void 0 ? { glyph, text: step.text } : { glyph, text: step.text, note: step.note };
3765
4069
  });
4070
+ const schemaStep = result2.steps.find((step) => step.target === "schema");
4071
+ const shapeFile = schemaStep?.action === "conflicted" ? result2.decisions.schemaFile : SCHEMA_SHAPE_FILE2;
3766
4072
  return [
3767
4073
  ...formatSteps(steps),
3768
4074
  "",
3769
- `${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:`,
3770
4076
  tip(out.cyan("penv set <key>")),
3771
4077
  ...result2.decisions.environments.length === 0 ? [
3772
4078
  `Then declare your environments in ${CONFIG_FILE}: penv leaves the whitelist empty rather than inventing one, and every command needs it.`
@@ -4004,7 +4310,7 @@ function decisionsOf(config, cwd) {
4004
4310
  // `import` re-scaffolds env.ts for an existing project; injection is an init
4005
4311
  // choice, so it is not turned on here.
4006
4312
  inject: false,
4007
- schemaFile: schemaFileOf3(config),
4313
+ schemaFile: schemaFileOf5(config),
4008
4314
  publicPrefixes: config.publicPrefixes ?? [],
4009
4315
  alias: detectAlias(cwd)
4010
4316
  };
@@ -4026,14 +4332,14 @@ function configInEffect(cwd, environment) {
4026
4332
  function importDotenv(options) {
4027
4333
  const cwd = resolve5(options.cwd);
4028
4334
  const file = isAbsolute2(options.file) ? options.file : resolve5(cwd, options.file);
4029
- if (!existsSync3(file)) {
4335
+ if (!existsSync5(file)) {
4030
4336
  throw new PenvError13(
4031
4337
  "IMPORT_FILE_MISSING",
4032
4338
  `There is no file at ${file} to import`,
4033
4339
  "Point `penv import` at an existing dotenv file, e.g. `penv import .env`."
4034
4340
  );
4035
4341
  }
4036
- const parsed = parseDotenv(readFileSync3(file, "utf8"));
4342
+ const parsed = parseDotenv(readFileSync6(file, "utf8"));
4037
4343
  const { config, decisions } = configInEffect(cwd, environmentNamed(file, options.environment));
4038
4344
  const source = displayPath2(cwd, file);
4039
4345
  const named = scopeFromFilename(file, config);
@@ -4071,6 +4377,7 @@ function importDotenv(options) {
4071
4377
  }
4072
4378
  const backup = `${file}${BACKUP_SUFFIX}`;
4073
4379
  copyFileSync(file, backup);
4380
+ refreshSnapshot(project);
4074
4381
  return {
4075
4382
  root: project.root,
4076
4383
  file,
@@ -4383,7 +4690,7 @@ var listCommand = defineCommand12({
4383
4690
  import {
4384
4691
  accessPath as accessPath5,
4385
4692
  formatMetaFile,
4386
- formatValueFile as formatValueFile5,
4693
+ formatValueFile as formatValueFile6,
4387
4694
  openValue as openValue4,
4388
4695
  PenvError as PenvError15,
4389
4696
  parameterId as parameterId5,
@@ -4406,7 +4713,7 @@ async function planFile(project, source, target, parameter) {
4406
4713
  if (environment === void 0) {
4407
4714
  throw new PenvError15(
4408
4715
  "SECRET_SCOPE_AMBIGUOUS",
4409
- `${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`,
4410
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."
4411
4718
  );
4412
4719
  }
@@ -4415,7 +4722,7 @@ async function planFile(project, source, target, parameter) {
4415
4722
  if (opened.kind === "failed") {
4416
4723
  throw new PenvError15(
4417
4724
  "VALUE_UNDECRYPTABLE",
4418
- `${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}`,
4419
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."
4420
4727
  );
4421
4728
  }
@@ -4480,19 +4787,21 @@ async function runMove(options) {
4480
4787
  if (meta !== void 0) {
4481
4788
  await project.provider.removeMeta(from);
4482
4789
  }
4790
+ refreshSnapshot(project);
4483
4791
  return {
4484
4792
  from: parameterId5(from),
4485
4793
  to: parameterId5(to),
4486
4794
  files: planned.map((file) => ({
4487
- from: formatValueFile5(file.source),
4488
- to: formatValueFile5(file.target),
4795
+ from: formatValueFile6(file.source),
4796
+ to: formatValueFile6(file.target),
4489
4797
  resealed: file.resealed
4490
4798
  })),
4491
4799
  // The meta's path, not the parameter's dotted id: `redis.password` is what
4492
4800
  // the schema calls it and `redis/password.json` is the file, and a report
4493
4801
  // that printed the first while moving the second names no file on disk.
4494
4802
  meta: meta === void 0 ? void 0 : formatMetaFile({ ...to, format: "json" }),
4495
- schema: { was: accessPath5(from).join("."), now: accessPath5(to).join(".") }
4803
+ schema: { was: accessPath5(from).join("."), now: accessPath5(to).join(".") },
4804
+ schemaFile: schemaShapeFileOf(project)
4496
4805
  };
4497
4806
  }
4498
4807
  function renderMove(result2) {
@@ -4509,7 +4818,7 @@ function renderMove(result2) {
4509
4818
  lines.push(
4510
4819
  "",
4511
4820
  tip(
4512
- `.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.`
4513
4822
  )
4514
4823
  );
4515
4824
  return lines;
@@ -4596,6 +4905,7 @@ async function runPull(options) {
4596
4905
  tree.writeMetaSync(ref, block);
4597
4906
  meta += 1;
4598
4907
  }
4908
+ refreshSnapshot(project);
4599
4909
  return { environment, source: source.type, localSource: false, values, meta, refs: refs.length };
4600
4910
  }
4601
4911
  function renderPull(result2) {
@@ -4664,7 +4974,7 @@ var pullCommand = defineCommand14({
4664
4974
  });
4665
4975
 
4666
4976
  // src/commands/remove.ts
4667
- import { formatValueFile as formatValueFile6 } from "@penvhq/core";
4977
+ import { formatValueFile as formatValueFile7 } from "@penvhq/core";
4668
4978
  import { defineCommand as defineCommand15 } from "citty";
4669
4979
  async function runRemove(options) {
4670
4980
  const project = openProject(options.cwd);
@@ -4682,12 +4992,13 @@ async function runRemove(options) {
4682
4992
  continue;
4683
4993
  }
4684
4994
  await project.provider.remove(file);
4685
- removed.push(formatValueFile6(file));
4995
+ removed.push(formatValueFile7(file));
4686
4996
  }
4997
+ refreshSnapshot(project);
4687
4998
  return {
4688
4999
  parameter: options.key,
4689
5000
  removed,
4690
- considered: files.map((file) => formatValueFile6(file))
5001
+ considered: files.map((file) => formatValueFile7(file))
4691
5002
  };
4692
5003
  }
4693
5004
  function renderRemove(result2) {
@@ -4763,6 +5074,7 @@ async function writeRotatedValue(project, provider, ref, environment, value) {
4763
5074
  value,
4764
5075
  environment
4765
5076
  });
5077
+ refreshSnapshot(project);
4766
5078
  return;
4767
5079
  }
4768
5080
  await provider.write(rotatingFile(ref, environment), value);
@@ -4930,11 +5242,53 @@ var rotateCommand = defineCommand16({
4930
5242
  }
4931
5243
  });
4932
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
+
4933
5287
  // src/commands/watch.ts
4934
- import { existsSync as existsSync4, watch } from "fs";
5288
+ import { existsSync as existsSync6, watch } from "fs";
4935
5289
  import { basename as basename2, dirname as dirname4, resolve as resolve6 } from "path";
4936
- import { schemaFileOf as schemaFileOf4, schemaInsideTree as schemaInsideTree2 } from "@penvhq/core";
4937
- 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";
4938
5292
  var DEBOUNCE_MS = 100;
4939
5293
  function runWatch(options) {
4940
5294
  const project = openProject(options.cwd);
@@ -4999,14 +5353,14 @@ function runWatch(options) {
4999
5353
  if (closed || recovery === void 0) {
5000
5354
  return;
5001
5355
  }
5002
- if (!existsSync4(parent)) {
5356
+ if (!existsSync6(parent)) {
5003
5357
  stop(recovery);
5004
5358
  return;
5005
5359
  }
5006
5360
  if (filename !== null && basename2(filename) !== name) {
5007
5361
  return;
5008
5362
  }
5009
- if (!existsSync4(target)) {
5363
+ if (!existsSync6(target)) {
5010
5364
  return;
5011
5365
  }
5012
5366
  stop(recovery);
@@ -5030,7 +5384,7 @@ function runWatch(options) {
5030
5384
  if (closed) {
5031
5385
  return;
5032
5386
  }
5033
- if (!existsSync4(target)) {
5387
+ if (!existsSync6(target)) {
5034
5388
  if (watcher !== void 0) {
5035
5389
  stop(watcher);
5036
5390
  }
@@ -5066,8 +5420,9 @@ function runWatch(options) {
5066
5420
  }
5067
5421
  addWatcher(project.penvDir, true);
5068
5422
  addWatcher(dirname4(project.configFile), false, configFile);
5423
+ addWatcher(project.root, false, SCHEMA_SHAPE_FILE3);
5069
5424
  if (schemaInsideTree2(project.config) === void 0) {
5070
- const schemaFile = resolve6(project.root, schemaFileOf4(project.config));
5425
+ const schemaFile = resolve6(project.root, schemaFileOf6(project.config));
5071
5426
  addWatcher(dirname4(schemaFile), false, basename2(schemaFile));
5072
5427
  }
5073
5428
  void validate();
@@ -5111,7 +5466,7 @@ function renderDrift(drift, environment) {
5111
5466
  }
5112
5467
  return lines;
5113
5468
  }
5114
- var watchCommand = defineCommand17({
5469
+ var watchCommand = defineCommand18({
5115
5470
  meta: {
5116
5471
  name: "watch",
5117
5472
  description: "Re-validate whenever .penv/ or penv.config.ts changes"
@@ -5150,7 +5505,7 @@ var watchCommand = defineCommand17({
5150
5505
  });
5151
5506
 
5152
5507
  // src/index.ts
5153
- var main = defineCommand18({
5508
+ var main = defineCommand19({
5154
5509
  meta: {
5155
5510
  name: "penv",
5156
5511
  description: "Configuration that shares a data model with your production secret manager"
@@ -5171,6 +5526,7 @@ var main = defineCommand18({
5171
5526
  encrypt: encryptCommand,
5172
5527
  decrypt: decryptCommand,
5173
5528
  key: keyCommand,
5529
+ snapshot: snapshotCommand,
5174
5530
  validate: validateCommand,
5175
5531
  doctor: doctorCommand,
5176
5532
  watch: watchCommand
@@ -5192,6 +5548,7 @@ export {
5192
5548
  renderPull,
5193
5549
  renderPush,
5194
5550
  renderRotate,
5551
+ renderSnapshot,
5195
5552
  renderWatch,
5196
5553
  runDecrypt,
5197
5554
  runDoctor,
@@ -5209,6 +5566,7 @@ export {
5209
5566
  runRemove,
5210
5567
  runRotate,
5211
5568
  runSet,
5569
+ runSnapshot,
5212
5570
  runValidate,
5213
5571
  runWatch
5214
5572
  };