@penvhq/cli 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -8,6 +8,8 @@ import {
8
8
  assertNever,
9
9
  effectiveMeta,
10
10
  formatValueFile as formatValueFile2,
11
+ holdsProjection as holdsProjection3,
12
+ holdsRecords,
11
13
  isPublicVariable,
12
14
  isRequired,
13
15
  isSecret,
@@ -18,9 +20,66 @@ import {
18
20
  tryParseDuration,
19
21
  variableName as variableName3
20
22
  } from "@penvhq/core";
21
- import { createGithubSink as createGithubSink2 } from "@penvhq/sink-github";
22
23
  import { defineCommand as defineCommand3 } from "citty";
23
24
 
25
+ // src/env-flags.ts
26
+ import { PenvError } from "@penvhq/core";
27
+ var BASE_RESERVED = ["help", "version", "h", "v", "_", "--"];
28
+ var COMMAND_FLAGS = [
29
+ ...BASE_RESERVED,
30
+ "env",
31
+ "out",
32
+ "allow-decrypt",
33
+ "destination",
34
+ "dest",
35
+ "d",
36
+ "location",
37
+ "l",
38
+ "yes",
39
+ "y"
40
+ ];
41
+ function shadowedEnvironments(config) {
42
+ const flags = new Set(COMMAND_FLAGS);
43
+ return config.environments.filter((environment) => flags.has(environment));
44
+ }
45
+ function shorthandCandidates(args, declared) {
46
+ const reserved = /* @__PURE__ */ new Set([...BASE_RESERVED, ...declared]);
47
+ return Object.keys(args).filter((key) => !reserved.has(key) && args[key] === true);
48
+ }
49
+ function quoteList(values) {
50
+ return values.map((value) => `\`--${value}\``).join(", ");
51
+ }
52
+ function environmentFromShorthand(config, candidates, explicit) {
53
+ if (candidates.length === 0) {
54
+ return void 0;
55
+ }
56
+ const hits = candidates.filter((candidate) => config.environments.includes(candidate));
57
+ const strangers = candidates.filter((candidate) => !config.environments.includes(candidate));
58
+ if (strangers.length > 0) {
59
+ throw new PenvError(
60
+ "UNKNOWN_FLAG",
61
+ `${quoteList(strangers)} ${strangers.length === 1 ? "is not a flag" : "are not flags"} this command takes, and ${strangers.length === 1 ? "names" : "name"} no declared environment`,
62
+ `Declared environments work as bare flags: ${quoteList(config.environments)}. Anything else needs \`--env <name>\`.`
63
+ );
64
+ }
65
+ if (hits.length > 1) {
66
+ throw new PenvError(
67
+ "ENVIRONMENT_FLAG_AMBIGUOUS",
68
+ `${quoteList(hits)} name two environments at once, and a command acts on exactly one`,
69
+ "Pass a single environment flag, or the canonical `--env <name>`."
70
+ );
71
+ }
72
+ const hit = hits[0];
73
+ if (hit !== void 0 && explicit !== void 0 && explicit !== hit) {
74
+ throw new PenvError(
75
+ "ENVIRONMENT_FLAG_AMBIGUOUS",
76
+ `\`--env ${explicit}\` and \`--${hit}\` name two environments at once`,
77
+ "Drop one of them \u2014 `--env` is the canonical spelling."
78
+ );
79
+ }
80
+ return hit;
81
+ }
82
+
24
83
  // src/project.ts
25
84
  import { dirname as dirname2, resolve as resolve2 } from "path";
26
85
  import {
@@ -30,7 +89,7 @@ import {
30
89
  isReservedToken,
31
90
  loadConfig,
32
91
  openValue,
33
- PenvError as PenvError2,
92
+ PenvError as PenvError3,
34
93
  parameterId,
35
94
  ReservedTokenError,
36
95
  refFromAccessPath,
@@ -43,25 +102,19 @@ import { FilesystemProvider } from "@penvhq/provider-filesystem";
43
102
  import { createRequire } from "module";
44
103
  import { dirname, resolve } from "path";
45
104
  import { pathToFileURL } from "url";
46
- import { PenvError } from "@penvhq/core";
105
+ import { holdsProjection, PenvError as PenvError2 } from "@penvhq/core";
47
106
  import { createFilesystemProvider } from "@penvhq/provider-filesystem";
48
- import { createKubernetesProvider } from "@penvhq/provider-kubernetes";
49
107
  import { createMockProvider } from "@penvhq/provider-mock";
50
- import { createSsmProvider } from "@penvhq/provider-ssm";
51
- import { createVaultProvider } from "@penvhq/provider-vault";
52
108
  var PLUGIN_FACTORY_EXPORT = "penvProviderFactory";
53
- var LOCAL_TREE_TYPE = "filesystem";
109
+ var LOCAL_TREE_TYPE = "@penvhq/provider-filesystem";
54
110
  var REGISTRY = /* @__PURE__ */ new Map([
55
111
  [LOCAL_TREE_TYPE, ({ root, config }) => createFilesystemProvider({ root, config })],
56
- ["vault", ({ providerConfig }) => createVaultProvider({ path: providerConfig?.path ?? "penv" })],
57
- ["ssm", ({ providerConfig }) => createSsmProvider({ path: providerConfig?.path ?? "penv" })],
58
112
  [
59
- "kubernetes",
60
- ({ providerConfig }) => createKubernetesProvider(kubernetesOptions(providerConfig))
61
- ],
62
- ["mock", ({ root }) => createMockProvider({ storePath: resolve(root, ".penv-mock.json") })]
113
+ "@penvhq/provider-mock",
114
+ ({ root }) => createMockProvider({ storePath: resolve(root, ".penv-mock.json") })
115
+ ]
63
116
  ]);
64
- var CONTRACT_METHODS = [
117
+ var RECORD_CONTRACT_METHODS = [
65
118
  "read",
66
119
  "write",
67
120
  "list",
@@ -70,6 +123,7 @@ var CONTRACT_METHODS = [
70
123
  "writeMeta",
71
124
  "removeMeta"
72
125
  ];
126
+ var PROJECTION_CONTRACT_METHODS = ["verify", "push", "list"];
73
127
  var pluginModuleCache = /* @__PURE__ */ new Map();
74
128
  function isProviderRegistered(type) {
75
129
  return REGISTRY.has(type);
@@ -89,31 +143,30 @@ async function createSourceProvider(type, context) {
89
143
  }
90
144
  async function loadPluginProvider(type, context) {
91
145
  const fromDir = resolutionBase(context);
92
- const specifier = pluginSpecifier(context.providerConfig, type);
93
- const resolved = resolvePlugin(specifier, fromDir);
146
+ const resolved = resolvePlugin(type, fromDir);
94
147
  if (resolved === void 0) {
95
- throw unknownProvider(type, context.environment, specifier);
148
+ throw unknownProvider(type, context.environment);
96
149
  }
97
150
  let mod;
98
151
  try {
99
152
  mod = await importPlugin(resolved);
100
153
  } catch {
101
- throw new PenvError(
154
+ throw new PenvError2(
102
155
  "PROVIDER_PLUGIN_LOAD",
103
- `The provider package \`${specifier}\` for type \`${type}\` failed to load`,
156
+ `The provider package \`${type}\` failed to load`,
104
157
  "It resolved but threw while importing. Check it builds and its dependencies are installed."
105
158
  );
106
159
  }
107
160
  const factory = mod[PLUGIN_FACTORY_EXPORT];
108
161
  if (typeof factory !== "function") {
109
- throw new PenvError(
162
+ throw new PenvError2(
110
163
  "PROVIDER_PLUGIN_INVALID",
111
- `\`${specifier}\` does not export \`${PLUGIN_FACTORY_EXPORT}\``,
164
+ `\`${type}\` does not export \`${PLUGIN_FACTORY_EXPORT}\``,
112
165
  `A penv provider package must export \`${PLUGIN_FACTORY_EXPORT}(context) => Provider\`.`
113
166
  );
114
167
  }
115
168
  const provider = await factory(context);
116
- assertSatisfiesContract(provider, specifier);
169
+ assertSatisfiesContract(provider, type);
117
170
  return provider;
118
171
  }
119
172
  function assertProvidersRegistered(config, projectRoot) {
@@ -121,15 +174,11 @@ function assertProvidersRegistered(config, projectRoot) {
121
174
  if (isProviderRegistered(provider.type)) {
122
175
  continue;
123
176
  }
124
- const specifier = pluginSpecifier(provider, provider.type);
125
- if (resolvePlugin(specifier, projectRoot) === void 0) {
126
- throw unknownProvider(provider.type, environment, specifier);
177
+ if (resolvePlugin(provider.type, projectRoot) === void 0) {
178
+ throw unknownProvider(provider.type, environment);
127
179
  }
128
180
  }
129
181
  }
130
- function pluginSpecifier(providerConfig, type) {
131
- return providerConfig?.module ?? `@penvhq/provider-${type}`;
132
- }
133
182
  function resolutionBase(context) {
134
183
  return dirname(context.root);
135
184
  }
@@ -151,32 +200,26 @@ function importPlugin(resolvedPath) {
151
200
  return loading;
152
201
  }
153
202
  function assertSatisfiesContract(provider, specifier) {
154
- for (const method of CONTRACT_METHODS) {
203
+ const projection = holdsProjection(provider);
204
+ const methods = projection ? PROJECTION_CONTRACT_METHODS : RECORD_CONTRACT_METHODS;
205
+ const contract = projection ? "the @penvhq/core ProjectionProvider contract its declared capabilities select" : "the @penvhq/core Provider contract that the filesystem provider defines";
206
+ for (const method of methods) {
155
207
  if (typeof provider[method] !== "function") {
156
- throw new PenvError(
208
+ throw new PenvError2(
157
209
  "PROVIDER_PLUGIN_INVALID",
158
210
  `The provider from \`${specifier}\` is missing \`${method}()\``,
159
- "It must satisfy the @penvhq/core Provider contract that the filesystem provider defines."
211
+ `It must satisfy ${contract}.`
160
212
  );
161
213
  }
162
214
  }
163
215
  }
164
- function kubernetesOptions(providerConfig) {
165
- const path = providerConfig?.path ?? "penv";
166
- const slash = path.indexOf("/");
167
- if (slash === -1) return { secretName: path };
168
- const namespace = path.slice(0, slash);
169
- const secretName = path.slice(slash + 1) || "penv";
170
- return namespace === "" ? { secretName } : { namespace, secretName };
171
- }
172
- function unknownProvider(type, environment, specifier) {
173
- const known = [...REGISTRY.keys()].map((name) => `\`${name}\``).join(", ");
216
+ function unknownProvider(type, environment) {
217
+ const preinstalled = [...REGISTRY.keys()].map((name) => `\`${name}\``).join(", ");
174
218
  const where = environment === void 0 ? "" : ` for environment ${environment}`;
175
- const remedy = specifier === void 0 ? `This build registers ${known}. Name a registered provider, or install the build that carries \`${type}\`.` : `Install its package with \`npm i ${specifier}\`, or name a built-in provider: ${known}.`;
176
- return new PenvError(
219
+ return new PenvError2(
177
220
  "UNKNOWN_PROVIDER",
178
- `The provider type \`${type}\`${where} in penv.config.ts is not one this penv build carries`,
179
- remedy
221
+ `The provider \`${type}\`${where} in penv.config.ts is not installed in this project`,
222
+ `Install it with \`npm i ${type}\` \u2014 a provider's \`type\` is the package penv imports. The CLI ships ${preinstalled} pre-installed.`
180
223
  );
181
224
  }
182
225
 
@@ -197,7 +240,7 @@ function openProject(cwd) {
197
240
  }
198
241
  function localTree(project) {
199
242
  if (!(project.provider instanceof FilesystemProvider)) {
200
- throw new PenvError2(
243
+ throw new PenvError3(
201
244
  "PROVIDER_NOT_LOCAL",
202
245
  `This command reads the local .penv tree synchronously, which the \`${project.provider.type}\` provider is not`,
203
246
  "Run this against a filesystem-backed project, or use a command that speaks the async provider contract."
@@ -217,8 +260,9 @@ async function sourceProviderFor(project, environment) {
217
260
  environment
218
261
  });
219
262
  }
220
- function targetEnvironment(project, explicit) {
221
- return resolveEnvironment(project.config, explicit);
263
+ function targetEnvironment(project, explicit, shorthand) {
264
+ const fromFlags = shorthand === void 0 ? void 0 : environmentFromShorthand(project.config, shorthand, explicit);
265
+ return resolveEnvironment(project.config, explicit ?? fromFlags);
222
266
  }
223
267
  var KEY_SEPARATOR = /[./\\]/;
224
268
  var NO_ENVIRONMENTS = { environments: [], providers: {} };
@@ -226,7 +270,7 @@ function refFromKey(key, config) {
226
270
  const segments = key.split(KEY_SEPARATOR).filter((segment) => segment.length > 0);
227
271
  const name = segments[segments.length - 1];
228
272
  if (name === void 0) {
229
- throw new PenvError2(
273
+ throw new PenvError3(
230
274
  "PARAMETER_KEY",
231
275
  `\`${key}\` names no parameter`,
232
276
  "A key is `<namespace>/<name>` or `<namespace>.<name>`, e.g. `redis/password`."
@@ -245,13 +289,13 @@ function assertWritableKey(key) {
245
289
  const ref = refFromAccessPath(segments);
246
290
  if (ref !== void 0) {
247
291
  const suggestion = [...ref.namespace, ref.name].join("/");
248
- throw new PenvError2(
292
+ throw new PenvError3(
249
293
  "PARAMETER_KEY_CASING",
250
294
  `\`${key}\` is not a canonical parameter name`,
251
295
  `Parameter files are lower-case and hyphenated. Did you mean \`${suggestion}\`? That is the file that backs the \`${key}\` key in your schema.`
252
296
  );
253
297
  }
254
- throw new PenvError2(
298
+ throw new PenvError3(
255
299
  "PARAMETER_KEY_UNREACHABLE",
256
300
  `No value file can be named that reaches \`${key}\``,
257
301
  "Parameter files are lower-case and hyphenated, and this key maps to no such file \u2014 a run of capitals like `apiURL` cannot be reached (use `api-url`, which the schema reads as `apiUrl`). Run `penv validate` or `penv fill` to see the names penv expects."
@@ -306,19 +350,19 @@ import {
306
350
  refFromAccessPath as refFromAccessPath2,
307
351
  variableName
308
352
  } from "@penvhq/core";
309
- function defOf(node) {
310
- if (typeof node !== "object" || node === null) {
353
+ function defOf(node2) {
354
+ if (typeof node2 !== "object" || node2 === null) {
311
355
  return void 0;
312
356
  }
313
- const def = node.def;
357
+ const def = node2.def;
314
358
  return typeof def === "object" && def !== null ? def : void 0;
315
359
  }
316
- function typeOf(node) {
317
- const type = defOf(node)?.type;
360
+ function typeOf(node2) {
361
+ const type = defOf(node2)?.type;
318
362
  return typeof type === "string" ? type : void 0;
319
363
  }
320
- function unwrap(node) {
321
- let current = node;
364
+ function unwrap(node2) {
365
+ let current = node2;
322
366
  for (let depth = 0; depth < 8; depth += 1) {
323
367
  const inner = defOf(current)?.innerType;
324
368
  if (inner === void 0) {
@@ -328,38 +372,38 @@ function unwrap(node) {
328
372
  }
329
373
  return current;
330
374
  }
331
- function shapeOf(node) {
332
- if (typeOf(node) !== "object") {
375
+ function shapeOf(node2) {
376
+ if (typeOf(node2) !== "object") {
333
377
  return void 0;
334
378
  }
335
- const shape = node.shape;
379
+ const shape = node2.shape;
336
380
  return typeof shape === "object" && shape !== null ? shape : void 0;
337
381
  }
338
382
  function lookup(root, path) {
339
- let node = unwrap(root);
383
+ let node2 = unwrap(root);
340
384
  for (const key of path) {
341
- const shape = shapeOf(node);
385
+ const shape = shapeOf(node2);
342
386
  if (shape === void 0) {
343
387
  return { kind: "unknown" };
344
388
  }
345
389
  if (!Object.hasOwn(shape, key)) {
346
390
  return { kind: "absent" };
347
391
  }
348
- node = unwrap(shape[key]);
392
+ node2 = unwrap(shape[key]);
349
393
  }
350
- return { kind: "found", node };
394
+ return { kind: "found", node: node2 };
351
395
  }
352
- function minLengthOf(node) {
353
- if (typeOf(node) !== "string") {
396
+ function minLengthOf(node2) {
397
+ if (typeOf(node2) !== "string") {
354
398
  return void 0;
355
399
  }
356
- const min = node.minLength;
400
+ const min = node2.minLength;
357
401
  return typeof min === "number" ? min : void 0;
358
402
  }
359
403
  var ABSENCE_PERMITTED = /* @__PURE__ */ new Set(["optional", "default", "catch", "prefault"]);
360
404
  var ABSENCE_REFUSED = /* @__PURE__ */ new Set(["nullable", "nonoptional", "readonly"]);
361
- function permitsAbsence(node) {
362
- let current = node;
405
+ function permitsAbsence(node2) {
406
+ let current = node2;
363
407
  for (let depth = 0; depth < 8; depth += 1) {
364
408
  const type = typeOf(current);
365
409
  if (type !== void 0 && ABSENCE_PERMITTED.has(type)) {
@@ -376,13 +420,13 @@ function permitsAbsence(node) {
376
420
  }
377
421
  return void 0;
378
422
  }
379
- function leaves(node, path, inherited, out2) {
380
- const own = permitsAbsence(node);
423
+ function leaves(node2, path, inherited, out2) {
424
+ const own = permitsAbsence(node2);
381
425
  const absencePermitted = inherited === true || own === true ? true : combine(inherited, own);
382
- const shape = shapeOf(unwrap(node));
426
+ const shape = shapeOf(unwrap(node2));
383
427
  if (shape === void 0) {
384
428
  if (path.length > 0) {
385
- out2.push({ path, absencePermitted, node });
429
+ out2.push({ path, absencePermitted, node: node2 });
386
430
  }
387
431
  return;
388
432
  }
@@ -398,8 +442,8 @@ function declaredLeaves(schema) {
398
442
  leaves(schema, [], false, out2);
399
443
  return out2;
400
444
  }
401
- function defaultValueOf(node) {
402
- let current = node;
445
+ function defaultValueOf(node2) {
446
+ let current = node2;
403
447
  for (let depth = 0; depth < 8; depth += 1) {
404
448
  const def = defOf(current);
405
449
  if (def === void 0) {
@@ -540,7 +584,7 @@ function padVisible(text, width) {
540
584
  }
541
585
 
542
586
  // src/ui.ts
543
- import { PenvError as PenvError3 } from "@penvhq/core";
587
+ import { PenvError as PenvError4 } from "@penvhq/core";
544
588
  var CHECK = "\u2713";
545
589
  var WARN = "\u26A0";
546
590
  var CROSS = "\u2717";
@@ -614,7 +658,7 @@ function write(lines) {
614
658
  }
615
659
  }
616
660
  function reportError(error) {
617
- if (error instanceof PenvError3) {
661
+ if (error instanceof PenvError4) {
618
662
  const suffix = error.remedy === void 0 ? void 0 : `
619
663
  ${error.remedy}`;
620
664
  const message = suffix !== void 0 && error.message.endsWith(suffix) ? error.message.slice(0, -suffix.length) : error.message;
@@ -648,31 +692,92 @@ async function guard(run) {
648
692
  }
649
693
 
650
694
  // src/commands/push.ts
651
- import { checkNameCollisions, PenvError as PenvError4, requireValue, variableName as variableName2 } from "@penvhq/core";
652
- import { checkGithubNames, createGithubSink } from "@penvhq/sink-github";
695
+ import {
696
+ checkNameCollisions,
697
+ holdsProjection as holdsProjection2,
698
+ PenvError as PenvError5,
699
+ requireValue,
700
+ variableName as variableName2
701
+ } from "@penvhq/core";
653
702
  import { defineCommand } from "citty";
703
+
704
+ // src/input.ts
705
+ import { createInterface } from "readline";
706
+ function lineReader(input = process.stdin, output = process.stdout) {
707
+ const rl = createInterface({ input, output });
708
+ const buffered = [];
709
+ const waiting = [];
710
+ let closed = false;
711
+ rl.on("line", (line) => {
712
+ const next = waiting.shift();
713
+ if (next === void 0) {
714
+ buffered.push(line);
715
+ } else {
716
+ next(line);
717
+ }
718
+ });
719
+ rl.on("close", () => {
720
+ closed = true;
721
+ for (const next of waiting.splice(0)) {
722
+ next(void 0);
723
+ }
724
+ });
725
+ return {
726
+ ask(question) {
727
+ const early = buffered.shift();
728
+ if (early !== void 0) {
729
+ return Promise.resolve(early);
730
+ }
731
+ if (closed) {
732
+ return Promise.resolve(void 0);
733
+ }
734
+ rl.setPrompt(question);
735
+ rl.prompt();
736
+ return new Promise((resolve7) => {
737
+ waiting.push(resolve7);
738
+ });
739
+ },
740
+ close() {
741
+ rl.close();
742
+ }
743
+ };
744
+ }
745
+
746
+ // src/commands/push.ts
654
747
  var LAST_PUSHED_KEY = "lastPushedAt";
655
- function sinkFor(project, environment, override) {
656
- const declared = project.config.sinks?.[environment];
657
- if (declared === void 0) {
658
- throw new PenvError4(
659
- "NO_SINK",
660
- `Environment ${environment} declares no sink in penv.config.ts, so penv has nowhere to push`,
661
- `Add a \`sinks\` entry, e.g. \`sinks: { ${environment}: { type: "github" } }\`, then run \`penv push --env ${environment}\` again.`
748
+ async function destinationFor(project, environment, options) {
749
+ if (options.destination !== void 0) {
750
+ const providerConfig = {
751
+ type: options.destination,
752
+ ...options.location === void 0 ? {} : { location: options.location }
753
+ };
754
+ const provider2 = options.provider ?? await createSourceProvider(options.destination, {
755
+ root: project.penvDir,
756
+ config: project.config,
757
+ providerConfig,
758
+ environment
759
+ });
760
+ return { provider: provider2, location: options.location };
761
+ }
762
+ const declared = project.config.providers[environment];
763
+ const location = declared?.location;
764
+ if (declared === void 0 || declared.type === LOCAL_TREE_TYPE) {
765
+ throw new PenvError5(
766
+ "NO_DESTINATION",
767
+ `Environment ${environment}'s provider is the local .penv tree itself, so penv has nowhere to push`,
768
+ `Declare a provider for it in penv.config.ts \u2014 e.g. \`${environment}: { type: "@penvhq/provider-github", location: "owner/repo" }\` \u2014 or push somewhere once with \`penv push --env ${environment} --destination <package> --location <place>\`.`
662
769
  );
663
770
  }
664
- const repo = declared.repo;
665
- if (override !== void 0) {
666
- return { sink: override, repo };
771
+ if (options.provider !== void 0) {
772
+ return { provider: options.provider, location };
667
773
  }
668
- if (declared.type === "github") {
669
- return { sink: createGithubSink(repo === void 0 ? {} : { repo }), repo };
670
- }
671
- throw new PenvError4(
672
- "UNKNOWN_SINK",
673
- `Environment ${environment} declares sink type \`${declared.type}\`, which penv does not know`,
674
- 'The only sink in this release is `github`. Set `type: "github"`.'
675
- );
774
+ const provider = await createSourceProvider(declared.type, {
775
+ root: project.penvDir,
776
+ config: project.config,
777
+ providerConfig: declared,
778
+ environment
779
+ });
780
+ return { provider, location };
676
781
  }
677
782
  function plan(resolutions, config, environment, allowDecrypt) {
678
783
  const outbound = [];
@@ -684,10 +789,10 @@ function plan(resolutions, config, environment, allowDecrypt) {
684
789
  let encrypted = false;
685
790
  if (winner.file.encrypted) {
686
791
  if (!allowDecrypt) {
687
- throw new PenvError4(
792
+ throw new PenvError5(
688
793
  "ENCRYPTED_VALUE_REFUSED",
689
- `Parameter ${resolution.parameter} for environment ${environment} resolves to the encrypted value file ${PENV_DIR}/${winner.location}, and a push sends plaintext for GitHub to re-seal`,
690
- "Re-run with `--allow-decrypt` to decrypt it locally and push it, or push an environment whose values are plaintext. penv's encryption stops at the sink; the destination seals it under its own key."
794
+ `Parameter ${resolution.parameter} for environment ${environment} resolves to the encrypted value file ${PENV_DIR}/${winner.location}, and a push sends plaintext for the destination to re-seal`,
795
+ "Re-run with `--allow-decrypt` to decrypt it locally and push it, or push an environment whose values are plaintext. penv's encryption stops at the projection; the destination seals it under its own key."
691
796
  );
692
797
  }
693
798
  requireValue(resolution, environment);
@@ -717,10 +822,46 @@ function recordPush(tree, ref, environment, iso) {
717
822
  const meta = withLastPushed(tree.readMetaSync(ref), environment, iso);
718
823
  tree.writeMetaSync(ref, meta);
719
824
  }
720
- async function runPush(options) {
721
- const project = openProject(options.cwd);
722
- const environment = targetEnvironment(project, options.environment);
723
- const { sink, repo } = sinkFor(project, environment, options.sink);
825
+ async function terminalConfirm(question) {
826
+ if (process.stdin.isTTY !== true) {
827
+ return false;
828
+ }
829
+ const reader = lineReader();
830
+ try {
831
+ const answer = await reader.ask(`${question} [y/N] `);
832
+ return answer !== void 0 && /^y(es)?$/i.test(answer.trim());
833
+ } finally {
834
+ reader.close();
835
+ }
836
+ }
837
+ async function ensureTargetApproved(provider, environment, options) {
838
+ if (provider.targetExists === void 0) {
839
+ return false;
840
+ }
841
+ if (await provider.targetExists(environment)) {
842
+ return false;
843
+ }
844
+ if (provider.ensureTarget === void 0) {
845
+ throw new PenvError5(
846
+ "MISSING_TARGET",
847
+ `The destination has no environment \`${environment}\` to receive this push, and this provider cannot create one`,
848
+ `Create the environment on the destination side, then run \`penv push --env ${environment}\` again.`
849
+ );
850
+ }
851
+ const approved = options.yes === true || await (options.confirm ?? terminalConfirm)(
852
+ `The destination has no environment \`${environment}\`. Create it?`
853
+ );
854
+ if (!approved) {
855
+ throw new PenvError5(
856
+ "MISSING_TARGET",
857
+ `The destination has no environment \`${environment}\` to receive this push`,
858
+ `Re-run with \`--yes\` to create it, answer \`y\` at the prompt, or create it on the destination side yourself.`
859
+ );
860
+ }
861
+ await provider.ensureTarget(environment);
862
+ return true;
863
+ }
864
+ async function pushProjection(project, provider, environment, location, options) {
724
865
  const tree = localTree(project);
725
866
  const keys = keySourceFor(project, environment);
726
867
  const resolutions = resolveAllSync(tree, environment, keys, true);
@@ -729,16 +870,17 @@ async function runPush(options) {
729
870
  if (collision !== void 0) {
730
871
  throw collision;
731
872
  }
732
- const nameError = checkGithubNames(refs, project.config)[0];
873
+ const nameError = provider.checkNames?.(refs, project.config)[0];
733
874
  if (nameError !== void 0) {
734
875
  throw nameError;
735
876
  }
736
877
  const outbound = plan(resolutions, project.config, environment, options.allowDecrypt === true);
737
- await sink.verify();
878
+ await provider.verify();
879
+ const createdTarget = outbound.some((item) => item.scope.kind === "environment") && await ensureTargetApproved(provider, environment, options);
738
880
  let repositorySecrets = 0;
739
881
  let environmentSecrets = 0;
740
882
  for (const item of outbound) {
741
- await sink.push(item.variable, item.value, item.scope);
883
+ await provider.push(item.variable, item.value, item.scope);
742
884
  if (item.scope.kind === "repository") {
743
885
  repositorySecrets += 1;
744
886
  } else {
@@ -748,15 +890,70 @@ async function runPush(options) {
748
890
  }
749
891
  return {
750
892
  environment,
751
- repo,
893
+ destination: provider.type,
894
+ mode: "projection",
895
+ location,
752
896
  pushed: outbound.length,
897
+ meta: 0,
753
898
  repositorySecrets,
754
899
  environmentSecrets,
755
- decrypted: outbound.filter((item) => item.encrypted).length
900
+ decrypted: outbound.filter((item) => item.encrypted).length,
901
+ createdTarget
902
+ };
903
+ }
904
+ function crossesToRecords(file, environment) {
905
+ const scope = file.scope;
906
+ if (scope.kind === "unscoped") {
907
+ return true;
908
+ }
909
+ return scope.kind === "environment" && scope.environment === environment;
910
+ }
911
+ async function pushRecords(project, provider, environment, location) {
912
+ const tree = localTree(project);
913
+ const files = tree.listSync().filter((file) => crossesToRecords(file, environment));
914
+ let pushed = 0;
915
+ for (const file of files) {
916
+ const value = tree.readSync(file);
917
+ if (value === void 0) {
918
+ continue;
919
+ }
920
+ await provider.write(file, value);
921
+ pushed += 1;
922
+ }
923
+ const refs = refsFrom(files);
924
+ let meta = 0;
925
+ for (const ref of refs) {
926
+ const block = tree.readMetaSync(ref);
927
+ if (block === void 0) {
928
+ continue;
929
+ }
930
+ await provider.writeMeta(ref, block);
931
+ meta += 1;
932
+ }
933
+ return {
934
+ environment,
935
+ destination: provider.type,
936
+ mode: "records",
937
+ location,
938
+ pushed,
939
+ meta,
940
+ repositorySecrets: 0,
941
+ environmentSecrets: 0,
942
+ decrypted: 0,
943
+ createdTarget: false
756
944
  };
757
945
  }
946
+ async function runPush(options) {
947
+ const project = openProject(options.cwd);
948
+ const environment = targetEnvironment(project, options.environment, options.envFlags);
949
+ const { provider, location } = await destinationFor(project, environment, options);
950
+ if (holdsProjection2(provider)) {
951
+ return pushProjection(project, provider, environment, location, options);
952
+ }
953
+ return pushRecords(project, provider, environment, location);
954
+ }
758
955
  function renderPush(result2) {
759
- if (result2.pushed === 0) {
956
+ if (result2.pushed === 0 && result2.meta === 0) {
760
957
  return formatRows([
761
958
  {
762
959
  glyph: CHECK,
@@ -765,7 +962,23 @@ function renderPush(result2) {
765
962
  }
766
963
  ]);
767
964
  }
768
- const target = `GitHub Actions for environment ${result2.environment}${result2.repo === void 0 ? "" : ` (${result2.repo})`}`;
965
+ const target = `${result2.destination} for environment ${result2.environment}${result2.location === void 0 ? "" : ` (${result2.location})`}`;
966
+ if (result2.mode === "records") {
967
+ return formatRows([
968
+ {
969
+ glyph: CHECK,
970
+ label: "Pushed",
971
+ subject: `${result2.pushed} value ${result2.pushed === 1 ? "file" : "files"}, ${result2.meta} meta`,
972
+ detail: `mirrored verbatim to ${target}`
973
+ },
974
+ {
975
+ glyph: CHECK,
976
+ label: "Sealed values",
977
+ subject: "crossed byte-for-byte, still sealed",
978
+ detail: "the destination holds envelopes; no key was needed to push them"
979
+ }
980
+ ]);
981
+ }
769
982
  const rows = [
770
983
  {
771
984
  glyph: CHECK,
@@ -780,12 +993,20 @@ function renderPush(result2) {
780
993
  detail: "environment secrets override repository secrets, as penv's env scope overrides the default"
781
994
  }
782
995
  ];
996
+ if (result2.createdTarget) {
997
+ rows.push({
998
+ glyph: CHECK,
999
+ label: "Created",
1000
+ subject: `the destination environment ${result2.environment}`,
1001
+ detail: "it did not exist yet; created on your approval"
1002
+ });
1003
+ }
783
1004
  if (result2.decrypted > 0) {
784
1005
  rows.push({
785
1006
  glyph: WARN,
786
1007
  label: "Decrypted",
787
1008
  subject: `${result2.decrypted} ${result2.decrypted === 1 ? "secret" : "secrets"}`,
788
- detail: "sent as plaintext for GitHub to re-seal under its own key"
1009
+ detail: "sent as plaintext for the destination to re-seal under its own key"
789
1010
  });
790
1011
  }
791
1012
  return formatRows(rows);
@@ -793,13 +1014,28 @@ function renderPush(result2) {
793
1014
  var pushCommand = defineCommand({
794
1015
  meta: {
795
1016
  name: "push",
796
- description: "Push an environment's resolved values to its sink (GitHub Actions Secrets)"
1017
+ description: "Push an environment's values to its provider"
797
1018
  },
798
1019
  args: {
799
1020
  env: { type: "string", description: "The environment to push" },
800
1021
  "allow-decrypt": {
801
1022
  type: "boolean",
802
- description: "Decrypt sealed values locally and push them as plaintext for GitHub to re-seal"
1023
+ description: "Decrypt sealed values locally and push them as plaintext for the destination to re-seal"
1024
+ },
1025
+ destination: {
1026
+ type: "string",
1027
+ alias: ["dest", "d"],
1028
+ description: "Push once to this provider package instead of the declared one"
1029
+ },
1030
+ location: {
1031
+ type: "string",
1032
+ alias: "l",
1033
+ description: "The destination-side place, when --destination needs one"
1034
+ },
1035
+ yes: {
1036
+ type: "boolean",
1037
+ alias: "y",
1038
+ description: "Create a missing destination-side environment without prompting"
803
1039
  }
804
1040
  },
805
1041
  run({ args }) {
@@ -807,7 +1043,22 @@ var pushCommand = defineCommand({
807
1043
  const result2 = await runPush({
808
1044
  cwd: process.cwd(),
809
1045
  ...args.env === void 0 ? {} : { environment: args.env },
810
- ...args["allow-decrypt"] === void 0 ? {} : { allowDecrypt: args["allow-decrypt"] }
1046
+ ...args["allow-decrypt"] === void 0 ? {} : { allowDecrypt: args["allow-decrypt"] },
1047
+ ...args.destination === void 0 ? {} : { destination: args.destination },
1048
+ ...args.location === void 0 ? {} : { location: args.location },
1049
+ ...args.yes === void 0 ? {} : { yes: args.yes },
1050
+ envFlags: shorthandCandidates(args, [
1051
+ "env",
1052
+ "allow-decrypt",
1053
+ "allowDecrypt",
1054
+ "destination",
1055
+ "dest",
1056
+ "d",
1057
+ "location",
1058
+ "l",
1059
+ "yes",
1060
+ "y"
1061
+ ])
811
1062
  });
812
1063
  write(renderPush(result2));
813
1064
  });
@@ -822,7 +1073,7 @@ import {
822
1073
  checkNameCollisions as checkNameCollisions2,
823
1074
  jitiFor,
824
1075
  NameCollisionError,
825
- PenvError as PenvError5,
1076
+ PenvError as PenvError6,
826
1077
  ReservedTokenError as ReservedTokenError2,
827
1078
  resolveAll,
828
1079
  SCHEMA_HARVEST_ENV,
@@ -859,18 +1110,18 @@ function place(root, path, value) {
859
1110
  if (leaf === void 0) {
860
1111
  return;
861
1112
  }
862
- let node = root;
1113
+ let node2 = root;
863
1114
  for (const key of path.slice(0, -1)) {
864
- const existing = node[key];
1115
+ const existing = node2[key];
865
1116
  if (typeof existing === "object" && existing !== null) {
866
- node = existing;
1117
+ node2 = existing;
867
1118
  continue;
868
1119
  }
869
1120
  const child = {};
870
- node[key] = child;
871
- node = child;
1121
+ node2[key] = child;
1122
+ node2 = child;
872
1123
  }
873
- node[leaf] = value;
1124
+ node2[leaf] = value;
874
1125
  }
875
1126
  function isZodType(value) {
876
1127
  return typeof value === "object" && value !== null && "safeParse" in value && typeof value.safeParse === "function";
@@ -961,14 +1212,14 @@ async function loadSchemaExclusively(file, schemaPath, environment) {
961
1212
  }
962
1213
  async function runValidate(options) {
963
1214
  const project = openProject(options.cwd);
964
- const environment = targetEnvironment(project, options.environment);
1215
+ const environment = targetEnvironment(project, options.environment, options.envFlags);
965
1216
  const schemaPath = schemaFileOf(project.config);
966
1217
  const issues = [];
967
1218
  let files;
968
1219
  try {
969
1220
  files = await project.provider.list();
970
1221
  } catch (error) {
971
- if (!(error instanceof PenvError5)) {
1222
+ if (!(error instanceof PenvError6)) {
972
1223
  throw error;
973
1224
  }
974
1225
  return {
@@ -1076,7 +1327,8 @@ var validateCommand = defineCommand2({
1076
1327
  return guard(async () => {
1077
1328
  const result2 = await runValidate({
1078
1329
  cwd: process.cwd(),
1079
- ...args.env === void 0 ? {} : { environment: args.env }
1330
+ ...args.env === void 0 ? {} : { environment: args.env },
1331
+ envFlags: shorthandCandidates(args, ["env"])
1080
1332
  });
1081
1333
  write(renderValidate(result2));
1082
1334
  if (!result2.ok) {
@@ -1099,10 +1351,19 @@ function skipped(check, label) {
1099
1351
  }
1100
1352
  async function runDoctor(options) {
1101
1353
  const project = openProject(options.cwd);
1102
- const environment = targetEnvironment(project, options.environment);
1354
+ const environment = targetEnvironment(project, options.environment, options.envFlags);
1103
1355
  const findings = [];
1104
1356
  const now = new Date(options.now ?? (/* @__PURE__ */ new Date()).toISOString());
1105
1357
  const stuckThresholdMs = options.stuckThresholdMs ?? STUCK_THRESHOLD_MS;
1358
+ for (const shadowed of shadowedEnvironments(project.config)) {
1359
+ findings.push({
1360
+ check: "environment-flag-shadow",
1361
+ severity: "warning",
1362
+ label: "Environment shadows a flag",
1363
+ subject: shadowed,
1364
+ detail: `\`--${shadowed}\` is a flag penv defines, so this environment has no shorthand \u2014 \`--env ${shadowed}\` always works`
1365
+ });
1366
+ }
1106
1367
  const { schema, issues } = await loadSchema(project, environment);
1107
1368
  if (schema !== void 0) {
1108
1369
  findings.push({ check: "schema", severity: "pass", label: "Schema valid" });
@@ -1159,7 +1420,7 @@ async function runDoctor(options) {
1159
1420
  findings.push(...stuckFindings(rotation.subjects, environment, now, stuckThresholdMs));
1160
1421
  }
1161
1422
  findings.push(...await providerDriftFindings(project, environment, options.source));
1162
- findings.push(...await sinkFindings(project, environment, options.sink));
1423
+ findings.push(...await projectionFindings(project, environment, options.projection));
1163
1424
  findings.push({
1164
1425
  check: "provider",
1165
1426
  severity: "pass",
@@ -1395,15 +1656,6 @@ function encryptionFindings(subjects, environment) {
1395
1656
  ];
1396
1657
  }
1397
1658
  var EDIT_SKEW_MS = 12e4;
1398
- function buildSink(declared, override) {
1399
- if (override !== void 0) {
1400
- return override;
1401
- }
1402
- if (declared.type === "github") {
1403
- return createGithubSink2(declared.repo === void 0 ? {} : { repo: declared.repo });
1404
- }
1405
- return void 0;
1406
- }
1407
1659
  function errorDetail(error) {
1408
1660
  if (error instanceof Error) {
1409
1661
  return error.message.split("\n")[0] ?? error.message;
@@ -1413,32 +1665,43 @@ function errorDetail(error) {
1413
1665
  function scopeLabel(scope) {
1414
1666
  return scope.kind === "repository" ? "repository secrets" : `environment secrets for ${scope.environment}`;
1415
1667
  }
1416
- async function sinkFindings(project, environment, override) {
1417
- const declared = project.config.sinks?.[environment];
1418
- if (declared === void 0) {
1419
- return [];
1420
- }
1421
- const sink = buildSink(declared, override);
1422
- if (sink === void 0) {
1423
- return [
1424
- {
1425
- check: "sink-unreachable",
1426
- severity: "unknown",
1427
- label: "Sink",
1428
- subject: `sink type \`${declared.type}\` is not one penv knows`,
1429
- detail: "penv cannot check a sink it cannot build"
1430
- }
1431
- ];
1668
+ async function projectionFindings(project, environment, override) {
1669
+ let projection;
1670
+ if (override !== void 0) {
1671
+ projection = override;
1672
+ } else {
1673
+ const declared = project.config.providers[environment];
1674
+ if (declared === void 0 || declared.type === LOCAL_TREE_TYPE) {
1675
+ return [];
1676
+ }
1677
+ let source;
1678
+ try {
1679
+ source = await sourceProviderFor(project, environment);
1680
+ } catch (error) {
1681
+ return [
1682
+ {
1683
+ check: "projection-unreachable",
1684
+ severity: "unknown",
1685
+ label: "Destination",
1686
+ subject: `could not build the ${declared.type} provider for ${environment}`,
1687
+ detail: errorDetail(error)
1688
+ }
1689
+ ];
1690
+ }
1691
+ if (!holdsProjection3(source)) {
1692
+ return [];
1693
+ }
1694
+ projection = source;
1432
1695
  }
1433
1696
  try {
1434
- await sink.verify();
1697
+ await projection.verify();
1435
1698
  } catch (error) {
1436
1699
  return [
1437
1700
  {
1438
- check: "sink-unreachable",
1701
+ check: "projection-unreachable",
1439
1702
  severity: "unknown",
1440
- label: "Sink",
1441
- subject: `could not reach the ${declared.type} sink for ${environment}`,
1703
+ label: "Destination",
1704
+ subject: `could not reach the ${projection.type} destination for ${environment}`,
1442
1705
  detail: errorDetail(error)
1443
1706
  }
1444
1707
  ];
@@ -1446,15 +1709,15 @@ async function sinkFindings(project, environment, override) {
1446
1709
  let repoSecrets;
1447
1710
  let envSecrets;
1448
1711
  try {
1449
- repoSecrets = await sink.list({ kind: "repository" });
1450
- envSecrets = await sink.list({ kind: "environment", environment });
1712
+ repoSecrets = await projection.list({ kind: "repository" });
1713
+ envSecrets = await projection.list({ kind: "environment", environment });
1451
1714
  } catch (error) {
1452
1715
  return [
1453
1716
  {
1454
- check: "sink-unreachable",
1717
+ check: "projection-unreachable",
1455
1718
  severity: "unknown",
1456
- label: "Sink",
1457
- subject: `could not list secrets in the ${declared.type} sink for ${environment}`,
1719
+ label: "Destination",
1720
+ subject: `could not list secrets in the ${projection.type} destination for ${environment}`,
1458
1721
  detail: errorDetail(error)
1459
1722
  }
1460
1723
  ];
@@ -1492,7 +1755,7 @@ async function sinkFindings(project, environment, override) {
1492
1755
  }
1493
1756
  if (!destOf(item.scope).has(key)) {
1494
1757
  nameDrift.push({
1495
- check: "sink-name-drift",
1758
+ check: "projection-name-drift",
1496
1759
  severity: "warning",
1497
1760
  label: "Declared, not pushed",
1498
1761
  subject: item.variable,
@@ -1504,7 +1767,7 @@ async function sinkFindings(project, environment, override) {
1504
1767
  for (const secret of repoSecrets) {
1505
1768
  if (!allVariables.has(upper(secret.name))) {
1506
1769
  nameDrift.push({
1507
- check: "sink-name-drift",
1770
+ check: "projection-name-drift",
1508
1771
  severity: "warning",
1509
1772
  label: "In destination, not declared",
1510
1773
  subject: secret.name,
@@ -1515,7 +1778,7 @@ async function sinkFindings(project, environment, override) {
1515
1778
  for (const secret of envSecrets) {
1516
1779
  if (!expectedEnv.has(upper(secret.name))) {
1517
1780
  nameDrift.push({
1518
- check: "sink-name-drift",
1781
+ check: "projection-name-drift",
1519
1782
  severity: "warning",
1520
1783
  label: "In destination, not declared",
1521
1784
  subject: secret.name,
@@ -1539,7 +1802,7 @@ async function sinkFindings(project, environment, override) {
1539
1802
  continue;
1540
1803
  }
1541
1804
  manualEdits.push({
1542
- check: "sink-manual-edit",
1805
+ check: "projection-manual-edit",
1543
1806
  severity: "warning",
1544
1807
  label: "Edited outside penv",
1545
1808
  subject: item.variable,
@@ -1550,9 +1813,9 @@ async function sinkFindings(project, environment, override) {
1550
1813
  findings.push(
1551
1814
  ...nameDrift.length > 0 ? nameDrift : [
1552
1815
  {
1553
- check: "sink-name-drift",
1816
+ check: "projection-name-drift",
1554
1817
  severity: "pass",
1555
- label: "Sink names",
1818
+ label: "Destination names",
1556
1819
  subject: `every parameter resolving for ${environment} is present, and nothing undeclared is`
1557
1820
  }
1558
1821
  ]
@@ -1560,17 +1823,17 @@ async function sinkFindings(project, environment, override) {
1560
1823
  findings.push(
1561
1824
  ...manualEdits.length > 0 ? manualEdits : [
1562
1825
  {
1563
- check: "sink-manual-edit",
1826
+ check: "projection-manual-edit",
1564
1827
  severity: "pass",
1565
- label: "Sink hand-edits",
1828
+ label: "Destination hand-edits",
1566
1829
  subject: `no secret has changed outside penv since its last push for ${environment}`
1567
1830
  }
1568
1831
  ]
1569
1832
  );
1570
1833
  findings.push({
1571
- check: "sink-value-drift",
1834
+ check: "projection-value-drift",
1572
1835
  severity: "unknown",
1573
- label: "Sink values",
1836
+ label: "Destination values",
1574
1837
  subject: "cannot be read back from a write-only destination",
1575
1838
  detail: "value drift between the tree and the destination is unknowable by design"
1576
1839
  });
@@ -1599,6 +1862,9 @@ async function rotationSubjects(project, environment, local, override) {
1599
1862
  return { kind: "read", subjects: local };
1600
1863
  }
1601
1864
  const source = override ?? await sourceProviderFor(project, environment);
1865
+ if (!holdsRecords(source)) {
1866
+ return { kind: "read", subjects: local };
1867
+ }
1602
1868
  try {
1603
1869
  const subjects = await Promise.all(
1604
1870
  local.map(async ({ resolution }) => ({
@@ -1747,6 +2013,9 @@ async function providerDriftFindings(project, environment, override) {
1747
2013
  ];
1748
2014
  }
1749
2015
  const source = override ?? await sourceProviderFor(project, environment);
2016
+ if (!holdsRecords(source)) {
2017
+ return [];
2018
+ }
1750
2019
  let local;
1751
2020
  let remote;
1752
2021
  try {
@@ -1866,7 +2135,7 @@ function renderDoctor(report) {
1866
2135
  var doctorCommand = defineCommand3({
1867
2136
  meta: {
1868
2137
  name: "doctor",
1869
- description: "Report missing, weak, unused, fallback, plaintext-secret, and sink-drift issues"
2138
+ description: "Report missing, weak, unused, fallback, plaintext-secret, and drift issues"
1870
2139
  },
1871
2140
  args: {
1872
2141
  env: { type: "string", description: "The environment to report on" }
@@ -1875,7 +2144,8 @@ var doctorCommand = defineCommand3({
1875
2144
  return guard(async () => {
1876
2145
  const report = await runDoctor({
1877
2146
  cwd: process.cwd(),
1878
- ...args.env === void 0 ? {} : { environment: args.env }
2147
+ ...args.env === void 0 ? {} : { environment: args.env },
2148
+ envFlags: shorthandCandidates(args, ["env"])
1879
2149
  });
1880
2150
  write(renderDoctor(report));
1881
2151
  if (!report.ok) {
@@ -1890,14 +2160,14 @@ import {
1890
2160
  formatValueFile as formatValueFile4,
1891
2161
  isSecret as isSecret3,
1892
2162
  openValue as openValue3,
1893
- PenvError as PenvError7,
2163
+ PenvError as PenvError8,
1894
2164
  parameterId as parameterId4,
1895
2165
  sealValue as sealValue2
1896
2166
  } from "@penvhq/core";
1897
2167
  import { defineCommand as defineCommand5 } from "citty";
1898
2168
 
1899
2169
  // src/commands/set.ts
1900
- import { formatValueFile as formatValueFile3, isSecret as isSecret2, PenvError as PenvError6, parameterId as parameterId3, sealValue } from "@penvhq/core";
2170
+ import { formatValueFile as formatValueFile3, isSecret as isSecret2, PenvError as PenvError7, parameterId as parameterId3, sealValue } from "@penvhq/core";
1901
2171
  import { defineCommand as defineCommand4 } from "citty";
1902
2172
  function scopeFrom(options) {
1903
2173
  if (options.local === true) {
@@ -1917,7 +2187,7 @@ function targetScope(project, options, key) {
1917
2187
  return scopeFrom(options);
1918
2188
  }
1919
2189
  if (environment.trim().length === 0) {
1920
- throw new PenvError6(
2190
+ throw new PenvError7(
1921
2191
  "ENVIRONMENT_FLAG_EMPTY",
1922
2192
  `\`--env\` for parameter ${key} names no environment`,
1923
2193
  `Pass a declared environment \u2014 ${project.config.environments.map((e) => `\`${e}\``).join(", ")} \u2014 e.g. \`--env production\`, or drop \`--env\` to write the scope that has no environment.`
@@ -1930,7 +2200,7 @@ function policyEnvironment(project, options) {
1930
2200
  }
1931
2201
  function sealFor(project, file, value, parameter, environment) {
1932
2202
  if (environment === void 0) {
1933
- throw new PenvError6(
2203
+ throw new PenvError7(
1934
2204
  "SECRET_SCOPE_AMBIGUOUS",
1935
2205
  `Parameter ${parameter} is a secret, and ${PENV_DIR}/${formatValueFile3(file)} names no environment`,
1936
2206
  "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."
@@ -2032,7 +2302,7 @@ function twins(project, key, options) {
2032
2302
  }
2033
2303
  function environmentFor(project, options, verb) {
2034
2304
  if (options.environment === void 0) {
2035
- throw new PenvError7(
2305
+ throw new PenvError8(
2036
2306
  "SECRET_SCOPE_AMBIGUOUS",
2037
2307
  `\`penv ${verb}\` names no environment, and keys are declared per environment`,
2038
2308
  "Pass `--env <environment>`. penv cannot tell which environment's key applies to a file that names none, and will not pick one for you."
@@ -2051,7 +2321,7 @@ async function runEncrypt(options) {
2051
2321
  const value = await readOne(project, plain);
2052
2322
  if (value === void 0) {
2053
2323
  const already = await readOne(project, sealed);
2054
- throw new PenvError7(
2324
+ throw new PenvError8(
2055
2325
  "PARAMETER_ABSENT",
2056
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)}`,
2057
2327
  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."
@@ -2072,7 +2342,7 @@ async function runDecrypt(options) {
2072
2342
  const [plain, sealed] = twins(project, options.key, options);
2073
2343
  const parameter = parameterId4(plain);
2074
2344
  if (isSecret3(await project.provider.readMeta(plain), environment)) {
2075
- throw new PenvError7(
2345
+ throw new PenvError8(
2076
2346
  "SECRET_DECRYPT_REFUSED",
2077
2347
  `Parameter ${parameter} is declared a secret for environment ${environment}, so penv will not write it in plaintext`,
2078
2348
  "A secret with a plaintext value file is a `penv doctor` failure. Drop `secret` from the parameter's meta if it is not one, or run `penv generate --allow-decrypt` if you need the plaintext value in a `.env` artifact."
@@ -2080,7 +2350,7 @@ async function runDecrypt(options) {
2080
2350
  }
2081
2351
  const stored = await readOne(project, sealed);
2082
2352
  if (stored === void 0) {
2083
- throw new PenvError7(
2353
+ throw new PenvError8(
2084
2354
  "PARAMETER_ABSENT",
2085
2355
  `Parameter ${parameter} has no encrypted value file at ${PENV_DIR}/${formatValueFile4(sealed)}`,
2086
2356
  `Nothing to decrypt. \`penv get ${options.key} --env ${environment} --explain\` shows every file penv looked at.`
@@ -2103,7 +2373,7 @@ async function runDecrypt(options) {
2103
2373
  removed: formatValueFile4(sealed)
2104
2374
  };
2105
2375
  }
2106
- var UndecryptableAt = class extends PenvError7 {
2376
+ var UndecryptableAt = class extends PenvError8 {
2107
2377
  constructor(parameter, environment, location, detail) {
2108
2378
  super(
2109
2379
  "VALUE_UNDECRYPTABLE",
@@ -2158,57 +2428,14 @@ var decryptCommand = defineCommand5({
2158
2428
  });
2159
2429
 
2160
2430
  // src/commands/fill.ts
2161
- import { PenvError as PenvError8 } from "@penvhq/core";
2431
+ import { PenvError as PenvError9 } from "@penvhq/core";
2162
2432
  import { defineCommand as defineCommand6 } from "citty";
2163
-
2164
- // src/input.ts
2165
- import { createInterface } from "readline";
2166
- function lineReader(input = process.stdin, output = process.stdout) {
2167
- const rl = createInterface({ input, output });
2168
- const buffered = [];
2169
- const waiting = [];
2170
- let closed = false;
2171
- rl.on("line", (line) => {
2172
- const next = waiting.shift();
2173
- if (next === void 0) {
2174
- buffered.push(line);
2175
- } else {
2176
- next(line);
2177
- }
2178
- });
2179
- rl.on("close", () => {
2180
- closed = true;
2181
- for (const next of waiting.splice(0)) {
2182
- next(void 0);
2183
- }
2184
- });
2185
- return {
2186
- ask(question) {
2187
- const early = buffered.shift();
2188
- if (early !== void 0) {
2189
- return Promise.resolve(early);
2190
- }
2191
- if (closed) {
2192
- return Promise.resolve(void 0);
2193
- }
2194
- rl.setPrompt(question);
2195
- rl.prompt();
2196
- return new Promise((resolve7) => {
2197
- waiting.push(resolve7);
2198
- });
2199
- },
2200
- close() {
2201
- rl.close();
2202
- }
2203
- };
2204
- }
2205
-
2206
- // src/commands/fill.ts
2207
2433
  var BLOCKING = /* @__PURE__ */ new Set(["config", "collision", "reserved"]);
2208
2434
  async function runFill(options) {
2209
2435
  const validation = await runValidate({
2210
2436
  cwd: options.cwd,
2211
- ...options.environment === void 0 ? {} : { environment: options.environment }
2437
+ ...options.environment === void 0 ? {} : { environment: options.environment },
2438
+ ...options.envFlags === void 0 ? {} : { envFlags: options.envFlags }
2212
2439
  });
2213
2440
  const environment = validation.environment;
2214
2441
  const blockers = validation.issues.filter((issue) => BLOCKING.has(issue.kind));
@@ -2216,7 +2443,7 @@ async function runFill(options) {
2216
2443
  const detail = blockers.map(
2217
2444
  (issue) => ` - ${issue.message}${issue.remedy === void 0 ? "" : ` (${issue.remedy})`}`
2218
2445
  ).join("\n");
2219
- throw new PenvError8(
2446
+ throw new PenvError9(
2220
2447
  "FILL_BLOCKED",
2221
2448
  `penv fill cannot run: environment ${environment} has ${blockers.length} unresolved configuration ${blockers.length === 1 ? "issue" : "issues"}:
2222
2449
  ${detail}`,
@@ -2338,7 +2565,8 @@ var fillCommand = defineCommand6({
2338
2565
  await runFill({
2339
2566
  cwd: process.cwd(),
2340
2567
  ...args.env === void 0 ? {} : { environment: args.env },
2341
- ask
2568
+ ask,
2569
+ envFlags: shorthandCandidates(args, ["env"])
2342
2570
  })
2343
2571
  )
2344
2572
  );
@@ -2355,7 +2583,7 @@ import { isAbsolute, relative, resolve as resolve3 } from "path";
2355
2583
  import {
2356
2584
  checkNameCollisions as checkNameCollisions3,
2357
2585
  effectiveMeta as effectiveMeta2,
2358
- PenvError as PenvError9,
2586
+ PenvError as PenvError10,
2359
2587
  requireValue as requireValue2,
2360
2588
  serializeDotenv,
2361
2589
  variableName as variableName4
@@ -2379,7 +2607,7 @@ function entriesFor(project, environment, allowDecrypt) {
2379
2607
  const winner = resolution.winner;
2380
2608
  if (winner?.file.encrypted === true) {
2381
2609
  if (!allowDecrypt) {
2382
- throw new PenvError9(
2610
+ throw new PenvError10(
2383
2611
  "ENCRYPTED_VALUE_REFUSED",
2384
2612
  `Parameter ${resolution.parameter} for environment ${environment} resolves to the encrypted value file ${PENV_DIR}/${winner.location}, and \`penv generate\` writes plaintext`,
2385
2613
  `Re-run with \`--allow-decrypt\` to write the decrypted value into the artifact, or generate for an environment whose values are plaintext. The artifact is gitignored; a committed plaintext secret is a \`penv doctor\` failure.`
@@ -2403,12 +2631,12 @@ function entriesFor(project, environment, allowDecrypt) {
2403
2631
  }
2404
2632
  function generateDotenv(options) {
2405
2633
  const project = openProject(options.cwd);
2406
- const environment = targetEnvironment(project, options.environment);
2634
+ const environment = targetEnvironment(project, options.environment, options.envFlags);
2407
2635
  return serializeDotenv(entriesFor(project, environment, options.allowDecrypt === true).entries);
2408
2636
  }
2409
2637
  function runGenerate(options) {
2410
2638
  const project = openProject(options.cwd);
2411
- const environment = targetEnvironment(project, options.environment);
2639
+ const environment = targetEnvironment(project, options.environment, options.envFlags);
2412
2640
  const { entries, decrypted } = entriesFor(project, environment, options.allowDecrypt === true);
2413
2641
  const file = options.out === void 0 ? resolve3(project.root, DEFAULT_OUTPUT) : isAbsolute(options.out) ? options.out : resolve3(options.cwd, options.out);
2414
2642
  writeFileSync(file, serializeDotenv(entries), "utf8");
@@ -2454,7 +2682,8 @@ var generateCommand = defineCommand7({
2454
2682
  cwd,
2455
2683
  ...args.env === void 0 ? {} : { environment: args.env },
2456
2684
  ...args.out === void 0 ? {} : { out: args.out },
2457
- ...args["allow-decrypt"] === void 0 ? {} : { allowDecrypt: args["allow-decrypt"] }
2685
+ ...args["allow-decrypt"] === void 0 ? {} : { allowDecrypt: args["allow-decrypt"] },
2686
+ envFlags: shorthandCandidates(args, ["env", "out", "allow-decrypt", "allowDecrypt"])
2458
2687
  });
2459
2688
  write(renderGenerate(result2, cwd));
2460
2689
  });
@@ -2462,7 +2691,7 @@ var generateCommand = defineCommand7({
2462
2691
  });
2463
2692
 
2464
2693
  // src/commands/get.ts
2465
- import { PenvError as PenvError10, requireValue as requireValue3, resolveParameter } from "@penvhq/core";
2694
+ import { PenvError as PenvError11, requireValue as requireValue3, resolveParameter } from "@penvhq/core";
2466
2695
  import { defineCommand as defineCommand8 } from "citty";
2467
2696
  async function runGet(options) {
2468
2697
  const project = openProject(options.cwd);
@@ -2472,7 +2701,7 @@ async function runGet(options) {
2472
2701
  const resolution = await resolveParameter(ref, environment, project.provider, keys);
2473
2702
  const value = requireValue3(resolution, environment);
2474
2703
  if (value === void 0) {
2475
- throw new PenvError10(
2704
+ throw new PenvError11(
2476
2705
  "PARAMETER_ABSENT",
2477
2706
  `Parameter ${resolution.parameter} resolves to no value for environment ${environment}`,
2478
2707
  `Set it with \`penv set ${options.key} --env ${environment}\`, or run \`penv get ${options.key} --env ${environment} --explain\` to see every file penv looked at.`
@@ -2559,7 +2788,7 @@ import {
2559
2788
  isReservedToken as isReservedToken3,
2560
2789
  loadConfigFrom as loadConfigFrom2,
2561
2790
  lookupEnvironment,
2562
- PenvError as PenvError12,
2791
+ PenvError as PenvError13,
2563
2792
  parseDotenv,
2564
2793
  ReservedTokenError as ReservedTokenError3,
2565
2794
  refFromVariable,
@@ -2576,6 +2805,8 @@ import { join } from "path";
2576
2805
  import { DEFAULT_SCHEMA_FILE } from "@penvhq/core";
2577
2806
  var SIGNATURES = [
2578
2807
  { name: "Next.js", packages: ["next"], publicPrefixes: ["NEXT_PUBLIC_"] },
2808
+ { name: "SvelteKit", packages: ["@sveltejs/kit"], publicPrefixes: ["PUBLIC_"] },
2809
+ { name: "Nuxt", packages: ["nuxt"], publicPrefixes: ["NUXT_PUBLIC_"] },
2579
2810
  {
2580
2811
  name: "TanStack Start",
2581
2812
  packages: ["@tanstack/react-start", "@tanstack/start"],
@@ -2649,17 +2880,23 @@ function detectFramework(cwd) {
2649
2880
  }
2650
2881
  for (const signature of SIGNATURES) {
2651
2882
  if (signature.packages.some((name) => dependencies.has(name))) {
2652
- const schema = schemaFileFor(cwd);
2653
- return {
2654
- name: signature.name,
2655
- schemaFile: schema.file,
2656
- ...schema.displaced === void 0 ? {} : { displacedFrom: schema.displaced },
2657
- publicPrefixes: signature.publicPrefixes
2658
- };
2883
+ return detectedFrom(cwd, signature.name, signature.publicPrefixes);
2659
2884
  }
2660
2885
  }
2886
+ if (existsSync(join(cwd, "bunfig.toml")) || dependencies.has("bun-types")) {
2887
+ return detectedFrom(cwd, "Bun", []);
2888
+ }
2661
2889
  return void 0;
2662
2890
  }
2891
+ function detectedFrom(cwd, name, publicPrefixes) {
2892
+ const schema = schemaFileFor(cwd);
2893
+ return {
2894
+ name,
2895
+ schemaFile: schema.file,
2896
+ ...schema.displaced === void 0 ? {} : { displacedFrom: schema.displaced },
2897
+ publicPrefixes
2898
+ };
2899
+ }
2663
2900
  var DEFAULT_ALIAS = "@env";
2664
2901
  var IMPORTS_ALIAS = "#env";
2665
2902
  function detectAlias(cwd) {
@@ -2679,13 +2916,139 @@ import {
2679
2916
  DEFAULT_SCHEMA_FILE as DEFAULT_SCHEMA_FILE2,
2680
2917
  isLegalEnvironmentName,
2681
2918
  loadConfigFrom,
2682
- PenvError as PenvError11,
2919
+ PenvError as PenvError12,
2683
2920
  RESERVED_TOKENS,
2684
2921
  schemaFileOf as schemaFileOf2,
2685
2922
  schemaInsideTree,
2686
2923
  validateSchemaFile
2687
2924
  } from "@penvhq/core";
2688
2925
  import { defineCommand as defineCommand9 } from "citty";
2926
+
2927
+ // src/seams.ts
2928
+ function nextjs({ alias, srcDir }) {
2929
+ return {
2930
+ kind: "scaffold",
2931
+ file: `${srcDir}instrumentation.ts`,
2932
+ content: `// This is instrumentation.ts, Next.js's own startup hook \u2014 Next runs it once,
2933
+ // before any of your app code, on every server boot. penv put one line here so
2934
+ // your config is in process.env before the first library reads it. The filename
2935
+ // is Next's requirement (it only finds \`instrumentation.ts\`), so keep it as-is.
2936
+ export async function register() {
2937
+ // Next also calls register on the Edge runtime, where penv cannot read the
2938
+ // filesystem \u2014 so inject runs on the Node runtime only.
2939
+ if (process.env.NEXT_RUNTIME === "nodejs") {
2940
+ await import("${alias}");
2941
+ }
2942
+ }
2943
+ `,
2944
+ ifPresent: `Add to your existing \`${srcDir}instrumentation.ts\`, inside \`register()\` and first:
2945
+ if (process.env.NEXT_RUNTIME === "nodejs") { await import("${alias}"); }`,
2946
+ notes: []
2947
+ };
2948
+ }
2949
+ function sveltekit({ alias }) {
2950
+ return {
2951
+ kind: "scaffold",
2952
+ // SvelteKit's hooks module is always under `src/`, whatever the schema path.
2953
+ file: "src/hooks.server.ts",
2954
+ content: `// This is hooks.server.ts, SvelteKit's server-startup module \u2014 it loads before
2955
+ // your routes handle a request. penv put this import first so your config is in
2956
+ // process.env before any library reads it. Keep it the first line; the name and
2957
+ // location are SvelteKit's, not penv's.
2958
+ import "${alias}";
2959
+ `,
2960
+ ifPresent: `Add \`import "${alias}";\` as the FIRST line of src/hooks.server.ts, above every other import.`,
2961
+ notes: [
2962
+ `SvelteKit resolves app aliases through \`kit.alias\` \u2014 make sure \`${alias}\` is registered in svelte.config.js, not only tsconfig.`
2963
+ ]
2964
+ };
2965
+ }
2966
+ function nuxt({ alias }) {
2967
+ return {
2968
+ kind: "scaffold",
2969
+ // The `0.` prefix is load-bearing: Nitro sorts plugin filenames as strings,
2970
+ // so it must sort first to run before any other plugin reads process.env.
2971
+ file: "server/plugins/0.penv.ts",
2972
+ content: `// This is a Nitro server plugin (Nuxt's server layer runs it once at startup,
2973
+ // before any middleware or request handler). penv put the import here so your
2974
+ // config is in process.env before a library reads it. The \`0.\` prefix keeps this
2975
+ // plugin first \u2014 Nitro sorts plugin filenames as text \u2014 so don't rename it.
2976
+ import "${alias}";
2977
+
2978
+ // Nitro requires every plugin file to default-export a function; the injection
2979
+ // above already ran at import, so this body is intentionally empty.
2980
+ export default defineNitroPlugin(() => {});
2981
+ `,
2982
+ ifPresent: `Create server/plugins/0.penv.ts with \`import "${alias}";\` and \`export default defineNitroPlugin(() => {});\`.`,
2983
+ notes: [
2984
+ `Wire \`${alias}\` for the Nitro (server) build so it never leaks into the client bundle.`
2985
+ ]
2986
+ };
2987
+ }
2988
+ function bun({ alias }) {
2989
+ return {
2990
+ kind: "scaffold",
2991
+ file: ".penv/preload.ts",
2992
+ 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.
2995
+ import "${alias}";
2996
+ `,
2997
+ 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
+ ]
3002
+ };
3003
+ }
3004
+ function node({ alias, srcDir }) {
3005
+ const runtimeSpecifier = alias.startsWith("#") ? alias : `./${srcDir}.penv/env.js`;
3006
+ return {
3007
+ kind: "instruct",
3008
+ instruction: `Plain Node / Express / Fastify have no framework hook that runs before your
3009
+ code, so place the injection yourself, one of two ways (both run first):
3010
+ \u2022 Preload at launch \u2014 add to your start script: node --import "${runtimeSpecifier}" your-entry.js
3011
+ \u2022 Or make it the FIRST line of your entry file: import "${runtimeSpecifier}";
3012
+ ` + (alias.startsWith("#") ? "" : `Note: \`${alias}\` is a tsconfig alias that Node does not resolve at runtime \u2014 the paths above point at the built schema module instead.
3013
+ `)
3014
+ };
3015
+ }
3016
+ function tanstack({ alias, srcDir }) {
3017
+ return {
3018
+ kind: "instruct",
3019
+ instruction: `Add \`import "${alias}";\` as the FIRST line of ${srcDir}server.ts (TanStack Start's
3020
+ server entry), above the \`@tanstack/react-start/server-entry\` import \u2014 nothing may
3021
+ import before it. If you have no server.ts yet, create it from TanStack's server-entry
3022
+ template and put that import first.`
3023
+ };
3024
+ }
3025
+ function astro(_context) {
3026
+ return {
3027
+ 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.'
3029
+ };
3030
+ }
3031
+ function viteSpa(_context) {
3032
+ return {
3033
+ kind: "none",
3034
+ reason: "This is a client-only app \u2014 nothing reads process.env at runtime, so injection does not apply. Read client config through import.meta.env (only VITE_-prefixed vars, which ship to the browser and must not hold secrets). If you add a server later, place the injection there."
3035
+ };
3036
+ }
3037
+ var SEAMS = {
3038
+ "Next.js": nextjs,
3039
+ SvelteKit: sveltekit,
3040
+ Nuxt: nuxt,
3041
+ Bun: bun,
3042
+ "TanStack Start": tanstack,
3043
+ Astro: astro,
3044
+ Vite: viteSpa
3045
+ };
3046
+ function seamFor(framework, context) {
3047
+ const build = framework === void 0 ? node : SEAMS[framework] ?? node;
3048
+ return build(context);
3049
+ }
3050
+
3051
+ // src/commands/init.ts
2689
3052
  var CONFIG_FILE = "penv.config.ts";
2690
3053
  var TSCONFIG_FILE = "tsconfig.json";
2691
3054
  var GITIGNORE_FILE = ".gitignore";
@@ -2695,6 +3058,7 @@ var IMPORTS_PREFIX = "#";
2695
3058
  var PACKAGE_FILE = "package.json";
2696
3059
  var DEFAULT_DECISIONS = {
2697
3060
  environments: [],
3061
+ inject: false,
2698
3062
  schemaFile: DEFAULT_SCHEMA_FILE2,
2699
3063
  publicPrefixes: [],
2700
3064
  alias: DEFAULT_ALIAS
@@ -2723,7 +3087,7 @@ function suggestEnvironments(root) {
2723
3087
  return [...found].sort();
2724
3088
  }
2725
3089
  function emptyFlag(flag) {
2726
- return new PenvError11(
3090
+ return new PenvError12(
2727
3091
  "INIT_FLAG_EMPTY",
2728
3092
  `\`--${flag}\` was given without a value`,
2729
3093
  flag === "schema" ? `Name the module that exports the schema, e.g. \`--schema src/env.ts\`, or drop the flag to use ${DEFAULT_SCHEMA_FILE2}.` : "Name the environment, e.g. `--env production`, or drop the flag to leave the whitelist empty and declare it in penv.config.ts."
@@ -2786,7 +3150,7 @@ function planInit(root, flags = {}) {
2786
3150
  throw emptyFlag("alias");
2787
3151
  }
2788
3152
  if (!ALIAS_NAME.test(alias)) {
2789
- throw new PenvError11(
3153
+ throw new PenvError12(
2790
3154
  "INIT_ALIAS_INVALID",
2791
3155
  `\`${alias}\` is not an alias penv can write`,
2792
3156
  `An alias is \`@name\` \u2014 a tsconfig \`paths\` entry a bundler resolves \u2014 or \`#name\`, a package.json \`imports\` entry Node resolves itself. Those are the two things a module specifier can be that is not a package.`
@@ -2813,6 +3177,9 @@ function planInit(root, flags = {}) {
2813
3177
  detected,
2814
3178
  decisions: {
2815
3179
  environments,
3180
+ // Injection is a choice about the app's needs, not something on disk — so
3181
+ // the plan defaults it off, and only the interactive prompt turns it on.
3182
+ inject: false,
2816
3183
  schemaFile,
2817
3184
  publicPrefixes: declared?.publicPrefixes ?? detected?.publicPrefixes ?? [],
2818
3185
  alias
@@ -2859,16 +3226,33 @@ async function promptForDecisions(plan2, io) {
2859
3226
  );
2860
3227
  io.write("");
2861
3228
  }
3229
+ const inject = seamKindFor(plan2) === "none" ? false : await askInject(io);
2862
3230
  const proceed = (await io.ask(prompt("Proceed?", "Y/n"))).trim().toLowerCase();
2863
3231
  if (proceed.length > 0 && proceed !== "y" && proceed !== "yes") {
2864
3232
  return void 0;
2865
3233
  }
2866
- return { ...plan2.decisions, environments };
3234
+ return { ...plan2.decisions, environments, inject };
3235
+ }
3236
+ function seamKindFor(plan2) {
3237
+ return seamFor(plan2.detected?.name, {
3238
+ alias: plan2.decisions.alias,
3239
+ srcDir: plan2.decisions.schemaFile.startsWith("src/") ? "src/" : ""
3240
+ }).kind;
3241
+ }
3242
+ async function askInject(io) {
3243
+ io.write("");
3244
+ io.write("Also inject your validated config into process.env?");
3245
+ io.write(" y libraries that read process.env directly (WorkOS, Prisma\u2026) just work");
3246
+ io.write(' n config stays available only through import { env } from "@env"');
3247
+ const answer = (await io.ask(prompt("inject", "y/N"))).trim().toLowerCase();
3248
+ return answer === "y" || answer === "yes";
2867
3249
  }
2868
3250
  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.";
2869
3251
  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";
2870
- function renderSchemaModule(fields, draft) {
3252
+ function renderSchemaModule(fields, draft, inject = false) {
2871
3253
  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";
2872
3256
  return `${draft ? DRAFT_HEADER : ""}import { z } from "zod";
2873
3257
  import { load } from "@penvhq/penv";
2874
3258
 
@@ -2878,10 +3262,15 @@ export const schema = z.object({
2878
3262
  ${body}
2879
3263
  });
2880
3264
 
2881
- // The loaded, validated values for the current environment. Import this in app
2882
- // code. Importing it loads configuration and throws (naming the parameter and
2883
- // environment) if anything required is missing or invalid.
2884
- export const env = load(schema);
3265
+ ` + loadComment + loadCall + `
3266
+ // Registers the schema's shape with penv's types (erased at runtime, so
3267
+ // nothing cycles). This is what makes \`override\` keys in penv.config.ts
3268
+ // autocomplete from this schema \u2014 a typo'd parameter id is a compile error.
3269
+ declare module "@penvhq/core" {
3270
+ interface PenvSchemaShape {
3271
+ readonly shape: z.infer<typeof schema>;
3272
+ }
3273
+ }
2885
3274
  `;
2886
3275
  }
2887
3276
  function renderEnvironments(decisions) {
@@ -2891,14 +3280,14 @@ function renderEnvironments(decisions) {
2891
3280
  // can read off your codebase, and an environment you do not have is worse
2892
3281
  // than one you have not declared yet. Name yours, and give each a provider:
2893
3282
  // environments: ["development", "production"],
2894
- // providers: { development: { type: "filesystem" }, production: { type: "filesystem" } },
3283
+ // providers: { development: { type: "@penvhq/provider-filesystem" }, production: { type: "@penvhq/provider-filesystem" } },
2895
3284
  environments: [],
2896
3285
 
2897
3286
  providers: {},
2898
3287
  `;
2899
3288
  }
2900
3289
  const names = decisions.environments.map((name) => JSON.stringify(name)).join(", ");
2901
- const providers = decisions.environments.map((name) => ` ${JSON.stringify(name)}: { type: "filesystem" },
3290
+ const providers = decisions.environments.map((name) => ` ${JSON.stringify(name)}: { type: "@penvhq/provider-filesystem" },
2902
3291
  `).join("");
2903
3292
  return `${shared} environments: [${names}],
2904
3293
 
@@ -3077,7 +3466,7 @@ ${objectIndent}${source.slice(close)}`;
3077
3466
  ${entryIndent}${member},${source.slice(open + 1)}`;
3078
3467
  }
3079
3468
  function shapeError(what, target, alias) {
3080
- return new PenvError11(
3469
+ return new PenvError12(
3081
3470
  "TSCONFIG_SHAPE",
3082
3471
  `penv cannot add the \`${alias}\` path alias to tsconfig.json: ${what}`,
3083
3472
  `Add it by hand: \`{ "compilerOptions": { "paths": { "${alias}": ["${target}"] } } }\`.`
@@ -3165,7 +3554,7 @@ function writeSchemaFile(root, fields, draft, decisions = DEFAULT_DECISIONS) {
3165
3554
  };
3166
3555
  }
3167
3556
  mkdirSync(dirname3(file), { recursive: true });
3168
- writeFileSync2(file, renderSchemaModule(fields, draft), "utf8");
3557
+ writeFileSync2(file, renderSchemaModule(fields, draft, decisions.inject), "utf8");
3169
3558
  return {
3170
3559
  target: "schema",
3171
3560
  action: "created",
@@ -3242,14 +3631,56 @@ function writeGitignore(root, decisions = DEFAULT_DECISIONS) {
3242
3631
  text: `${existing === void 0 ? "Created" : "Updated"} ${relative3}`
3243
3632
  };
3244
3633
  }
3634
+ function srcDirOf(root) {
3635
+ return existsSync2(join2(root, "src")) ? "src/" : "";
3636
+ }
3637
+ function writeSeam(root, decisions = DEFAULT_DECISIONS) {
3638
+ if (!decisions.inject) {
3639
+ return void 0;
3640
+ }
3641
+ const framework = detectFramework(root)?.name;
3642
+ const seam = seamFor(framework, { alias: decisions.alias, srcDir: srcDirOf(root) });
3643
+ if (seam.kind === "none") {
3644
+ return { target: "seam", action: "info", text: "No injection seam needed", note: seam.reason };
3645
+ }
3646
+ if (seam.kind === "instruct") {
3647
+ return {
3648
+ target: "seam",
3649
+ action: "info",
3650
+ text: "Place the injection seam",
3651
+ note: seam.instruction
3652
+ };
3653
+ }
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)) {
3658
+ return {
3659
+ target: "seam",
3660
+ action: "info",
3661
+ text: `Add the injection seam to ${seam.file}`,
3662
+ note: `${seam.ifPresent}${notes}`
3663
+ };
3664
+ }
3665
+ mkdirSync(dirname3(file), { recursive: true });
3666
+ writeFileSync2(file, seam.content, "utf8");
3667
+ return {
3668
+ target: "seam",
3669
+ action: "created",
3670
+ text: `Wrote ${seam.file} (runs the injection before your app)`,
3671
+ ...notes === "" ? {} : { note: notes.trimStart() }
3672
+ };
3673
+ }
3245
3674
  function scaffold(root, fields, draft, decisions = DEFAULT_DECISIONS) {
3246
- return [
3675
+ const steps = [
3247
3676
  ensurePenvDir(root),
3248
3677
  writeSchemaFile(root, fields, draft, decisions),
3249
3678
  writeConfigFile(root, decisions),
3250
3679
  writeTsconfigAlias(root, decisions),
3251
3680
  writeGitignore(root, decisions)
3252
3681
  ];
3682
+ const seam = writeSeam(root, decisions);
3683
+ return seam === void 0 ? steps : [...steps, seam];
3253
3684
  }
3254
3685
  function runInit(options) {
3255
3686
  const root = resolve4(options.cwd);
@@ -3258,7 +3689,7 @@ function runInit(options) {
3258
3689
  }
3259
3690
  function renderInit(result2) {
3260
3691
  const steps = result2.steps.map((step) => {
3261
- const glyph = step.action === "conflicted" ? WARN : CHECK;
3692
+ const glyph = step.action === "conflicted" ? WARN : step.action === "info" ? "\u2192" : CHECK;
3262
3693
  return step.note === void 0 ? { glyph, text: step.text } : { glyph, text: step.text, note: step.note };
3263
3694
  });
3264
3695
  return [
@@ -3357,7 +3788,7 @@ function assertImportable(ref, variable) {
3357
3788
  if (!ref.name.includes(".")) {
3358
3789
  return;
3359
3790
  }
3360
- throw new PenvError12(
3791
+ throw new PenvError13(
3361
3792
  "IMPORT_UNPARSEABLE_NAME",
3362
3793
  `The variable ${variable} becomes the parameter \`${ref.name}\`, whose \`.\` would be read as a scope`,
3363
3794
  `Filenames are split on \`.\`. Rename ${variable} in the source file, then import it again.`
@@ -3376,10 +3807,10 @@ function assertRoundTrips(ref, variable, config) {
3376
3807
  return;
3377
3808
  }
3378
3809
  const generated = variableName5(ref, config);
3379
- throw new PenvError12(
3810
+ throw new PenvError13(
3380
3811
  "IMPORT_LOSSY_NAME",
3381
3812
  `The variable ${variable} becomes the parameter \`${ref.name}\`, which regenerates as ${generated}`,
3382
- `\`penv generate\` would write ${generated}, so anything reading \`process.env["${variable}"]\` would read \`undefined\`. Declare the name you want in the \`names\` block of penv.config.ts \u2014 \`names: { "${ref.name}": "${variable}" }\` \u2014 then import it again. Nothing was imported.`
3813
+ `\`penv generate\` would write ${generated}, so anything reading \`process.env["${variable}"]\` would read \`undefined\`. Declare the name you want in the \`override\` block of penv.config.ts \u2014 \`override: { "${ref.name}": "${variable}" }\` \u2014 then import it again. Nothing was imported.`
3383
3814
  );
3384
3815
  }
3385
3816
  function collisionsIn(refs, config) {
@@ -3460,7 +3891,7 @@ function explicitEnvironment(options, source, config) {
3460
3891
  if (value.length > 0) {
3461
3892
  return value;
3462
3893
  }
3463
- throw new PenvError12(
3894
+ throw new PenvError13(
3464
3895
  "IMPORT_ENV_FLAG_EMPTY",
3465
3896
  `\`--env\` for the import of ${source} names no environment`,
3466
3897
  `Pass a declared environment \u2014 ${config.environments.map((e) => `\`${e}\``).join(", ")} \u2014 e.g. \`--env production\`, or drop \`--env\` to import ${source} as the scope that has no environment. Nothing was imported.`
@@ -3470,7 +3901,7 @@ function assertEnvironmentAgrees(derived, explicit, source) {
3470
3901
  if (derived === void 0 || explicit === void 0 || derived === explicit) {
3471
3902
  return;
3472
3903
  }
3473
- throw new PenvError12(
3904
+ throw new PenvError13(
3474
3905
  "IMPORT_ENV_CONFLICT",
3475
3906
  `The file ${source} is scoped to environment ${derived}, but \`--env ${explicit}\` names ${explicit}`,
3476
3907
  `Drop \`--env\` to import ${source} as ${derived}, pass \`--env ${derived}\` to say the same thing twice, or point \`penv import\` at the file that holds ${explicit}'s values. Nothing was imported.`
@@ -3491,6 +3922,9 @@ function environmentNamed(file, explicit) {
3491
3922
  function decisionsOf(config, cwd) {
3492
3923
  return {
3493
3924
  environments: config.environments,
3925
+ // `import` re-scaffolds env.ts for an existing project; injection is an init
3926
+ // choice, so it is not turned on here.
3927
+ inject: false,
3494
3928
  schemaFile: schemaFileOf3(config),
3495
3929
  publicPrefixes: config.publicPrefixes ?? [],
3496
3930
  alias: detectAlias(cwd)
@@ -3514,7 +3948,7 @@ function importDotenv(options) {
3514
3948
  const cwd = resolve5(options.cwd);
3515
3949
  const file = isAbsolute2(options.file) ? options.file : resolve5(cwd, options.file);
3516
3950
  if (!existsSync3(file)) {
3517
- throw new PenvError12(
3951
+ throw new PenvError13(
3518
3952
  "IMPORT_FILE_MISSING",
3519
3953
  `There is no file at ${file} to import`,
3520
3954
  "Point `penv import` at an existing dotenv file, e.g. `penv import .env`."
@@ -3659,7 +4093,7 @@ var importCommand = defineCommand10({
3659
4093
 
3660
4094
  // src/commands/key.ts
3661
4095
  import { randomBytes } from "crypto";
3662
- import { KEY_BYTES, KEYCHAIN_SERVICE, PenvError as PenvError13 } from "@penvhq/core";
4096
+ import { KEY_BYTES, KEYCHAIN_SERVICE, PenvError as PenvError14 } from "@penvhq/core";
3663
4097
  import { defineCommand as defineCommand11 } from "citty";
3664
4098
 
3665
4099
  // src/keychain.ts
@@ -3692,7 +4126,7 @@ function runKeyCreate(options) {
3692
4126
  const environment = targetEnvironment(project, options.environment);
3693
4127
  const declared = project.config.keys?.[environment];
3694
4128
  if (declared === void 0) {
3695
- throw new PenvError13(
4129
+ throw new PenvError14(
3696
4130
  "KEY_SOURCE_UNDECLARED",
3697
4131
  `Environment ${environment} declares no key source, so penv does not know what a key for it would be`,
3698
4132
  `Add a \`keys\` entry to penv.config.ts \u2014 e.g. \`keys: { ${environment}: { source: "env", id: "${environment}" } }\` \u2014 then run this again.`
@@ -3706,14 +4140,14 @@ function runKeyCreate(options) {
3706
4140
  try {
3707
4141
  existing = keychain.getPassword(KEYCHAIN_SERVICE, declared.id);
3708
4142
  } catch (cause) {
3709
- throw new PenvError13(
4143
+ throw new PenvError14(
3710
4144
  "KEYCHAIN_UNAVAILABLE",
3711
4145
  `penv could not read your OS keychain to check for an existing key \`${declared.id}\``,
3712
4146
  `Unlock your keychain and run this again. Original error: ${cause instanceof Error ? cause.message : String(cause)}`
3713
4147
  );
3714
4148
  }
3715
4149
  if (existing !== null) {
3716
- throw new PenvError13(
4150
+ throw new PenvError14(
3717
4151
  "KEY_EXISTS",
3718
4152
  `Environment ${environment} already has a key \`${declared.id}\` in your OS keychain`,
3719
4153
  "Replacing it would orphan every value already sealed under it \u2014 they could never be decrypted again. Re-run with `--force` only if you are certain nothing is sealed under the current key."
@@ -3872,7 +4306,7 @@ import {
3872
4306
  formatMetaFile,
3873
4307
  formatValueFile as formatValueFile5,
3874
4308
  openValue as openValue4,
3875
- PenvError as PenvError14,
4309
+ PenvError as PenvError15,
3876
4310
  parameterId as parameterId5,
3877
4311
  sealValue as sealValue3
3878
4312
  } from "@penvhq/core";
@@ -3891,7 +4325,7 @@ async function planFile(project, source, target, parameter) {
3891
4325
  }
3892
4326
  const environment = environmentOf2(source);
3893
4327
  if (environment === void 0) {
3894
- throw new PenvError14(
4328
+ throw new PenvError15(
3895
4329
  "SECRET_SCOPE_AMBIGUOUS",
3896
4330
  `${PENV_DIR}/${formatValueFile5(source)} is encrypted at a scope that names no environment, so penv cannot tell which key would re-seal it`,
3897
4331
  "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."
@@ -3900,7 +4334,7 @@ async function planFile(project, source, target, parameter) {
3900
4334
  const keys = keySourceFor(project, environment);
3901
4335
  const opened = openValue4(source, stored, keys);
3902
4336
  if (opened.kind === "failed") {
3903
- throw new PenvError14(
4337
+ throw new PenvError15(
3904
4338
  "VALUE_UNDECRYPTABLE",
3905
4339
  `${PENV_DIR}/${formatValueFile5(source)} could not be decrypted, so penv cannot re-seal it at its new address: ${opened.failure.detail}`,
3906
4340
  "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."
@@ -3923,7 +4357,7 @@ async function runMove(options) {
3923
4357
  assertWritableKey(options.to);
3924
4358
  const to = refFromKey(options.to, project.config);
3925
4359
  if (parameterId5(from) === parameterId5(to)) {
3926
- throw new PenvError14(
4360
+ throw new PenvError15(
3927
4361
  "PARAMETER_UNCHANGED",
3928
4362
  `\`${options.from}\` and \`${options.to}\` are the same parameter`,
3929
4363
  "Name a different destination, e.g. `penv mv redis-password redis/password`."
@@ -3933,7 +4367,7 @@ async function runMove(options) {
3933
4367
  const sources = filesOf(all, from);
3934
4368
  const meta = await project.provider.readMeta(from);
3935
4369
  if (sources.length === 0 && meta === void 0) {
3936
- throw new PenvError14(
4370
+ throw new PenvError15(
3937
4371
  "PARAMETER_ABSENT",
3938
4372
  `Parameter ${parameterId5(from)} has no value files and no meta, so there is nothing to move`,
3939
4373
  `\`penv list\` shows every parameter penv holds.`
@@ -3941,7 +4375,7 @@ async function runMove(options) {
3941
4375
  }
3942
4376
  const occupied2 = filesOf(all, to);
3943
4377
  if (occupied2.length > 0 || await project.provider.readMeta(to) !== void 0) {
3944
- throw new PenvError14(
4378
+ throw new PenvError15(
3945
4379
  "PARAMETER_EXISTS",
3946
4380
  `Parameter ${parameterId5(to)} already exists, and penv will not merge two parameters into one`,
3947
4381
  `Remove or rename ${parameterId5(to)} first. \`penv get ${options.to} --explain\` shows every file it holds.`
@@ -4023,14 +4457,45 @@ var mvCommand = defineCommand13({
4023
4457
  });
4024
4458
 
4025
4459
  // src/commands/pull.ts
4460
+ import { holdsProjection as holdsProjection4, refFromVariable as refFromVariable2 } from "@penvhq/core";
4026
4461
  import { defineCommand as defineCommand14 } from "citty";
4462
+ async function pullProjection(project, source, environment) {
4463
+ const tree = localTree(project);
4464
+ const names = /* @__PURE__ */ new Set();
4465
+ for (const secret of await source.list({ kind: "repository" })) {
4466
+ names.add(secret.name);
4467
+ }
4468
+ for (const secret of await source.list({ kind: "environment", environment })) {
4469
+ names.add(secret.name);
4470
+ }
4471
+ let meta = 0;
4472
+ for (const name of [...names].sort()) {
4473
+ const ref = refFromVariable2(name);
4474
+ if (tree.readMetaSync(ref) === void 0) {
4475
+ tree.writeMetaSync(ref, {});
4476
+ meta += 1;
4477
+ }
4478
+ }
4479
+ return {
4480
+ environment,
4481
+ source: source.type,
4482
+ localSource: false,
4483
+ values: 0,
4484
+ meta,
4485
+ refs: names.size,
4486
+ valuesUnreadable: true
4487
+ };
4488
+ }
4027
4489
  async function runPull(options) {
4028
4490
  const project = openProject(options.cwd);
4029
- const environment = targetEnvironment(project, options.environment);
4030
- const source = await sourceProviderFor(project, environment);
4491
+ const environment = targetEnvironment(project, options.environment, options.envFlags);
4492
+ const source = options.source ?? await sourceProviderFor(project, environment);
4031
4493
  if (source.type === LOCAL_TREE_TYPE) {
4032
4494
  return { environment, source: source.type, localSource: true, values: 0, meta: 0, refs: 0 };
4033
4495
  }
4496
+ if (holdsProjection4(source)) {
4497
+ return pullProjection(project, source, environment);
4498
+ }
4034
4499
  const tree = localTree(project);
4035
4500
  const files = await source.list();
4036
4501
  let values = 0;
@@ -4065,6 +4530,22 @@ function renderPull(result2) {
4065
4530
  }
4066
4531
  ]);
4067
4532
  }
4533
+ if (result2.valuesUnreadable === true) {
4534
+ return formatRows([
4535
+ {
4536
+ glyph: CHECK,
4537
+ label: "Pulled",
4538
+ subject: `${result2.refs} ${result2.refs === 1 ? "parameter name" : "parameter names"}`,
4539
+ detail: `from ${result2.source} for environment ${result2.environment}`
4540
+ },
4541
+ {
4542
+ glyph: WARN,
4543
+ label: "Values not readable",
4544
+ subject: "this destination never returns a secret's value",
4545
+ detail: "fill them locally with `penv set` or `penv fill` \u2014 `penv validate` names every gap"
4546
+ }
4547
+ ]);
4548
+ }
4068
4549
  return formatRows([
4069
4550
  {
4070
4551
  glyph: CHECK,
@@ -4086,13 +4567,17 @@ var pullCommand = defineCommand14({
4086
4567
  description: "Materialise the local .penv tree from an environment's source-of-truth provider"
4087
4568
  },
4088
4569
  args: {
4089
- env: { type: "string", description: "The environment to pull" }
4570
+ env: {
4571
+ type: "string",
4572
+ description: "The environment to pull (or pass it as a bare flag: --production)"
4573
+ }
4090
4574
  },
4091
4575
  run({ args }) {
4092
4576
  return guard(async () => {
4093
4577
  const result2 = await runPull({
4094
4578
  cwd: process.cwd(),
4095
- ...args.env === void 0 ? {} : { environment: args.env }
4579
+ ...args.env === void 0 ? {} : { environment: args.env },
4580
+ envFlags: shorthandCandidates(args, ["env"])
4096
4581
  });
4097
4582
  write(renderPull(result2));
4098
4583
  });
@@ -4175,7 +4660,8 @@ var removeCommand = defineCommand15({
4175
4660
  import {
4176
4661
  beginRotation,
4177
4662
  completeRotation,
4178
- PenvError as PenvError15,
4663
+ holdsRecords as holdsRecords2,
4664
+ PenvError as PenvError16,
4179
4665
  retainsPrevious,
4180
4666
  rotationOf as rotationOf2
4181
4667
  } from "@penvhq/core";
@@ -4204,7 +4690,7 @@ async function writeRotatedValue(project, provider, ref, environment, value) {
4204
4690
  }
4205
4691
  function requireNewValue(value, phase, key) {
4206
4692
  if (value === void 0) {
4207
- throw new PenvError15(
4693
+ throw new PenvError16(
4208
4694
  "ROTATION_NO_VALUE",
4209
4695
  `A ${phase} rotation of ${key} writes a new value, and none was given`,
4210
4696
  "Pass the new value as the argument \u2014 `penv rotate <key> <value>` \u2014 or pipe it in on stdin."
@@ -4214,7 +4700,7 @@ function requireNewValue(value, phase, key) {
4214
4700
  }
4215
4701
  function requireRetaining(provider, environment) {
4216
4702
  if (!retainsPrevious(provider)) {
4217
- throw new PenvError15(
4703
+ throw new PenvError16(
4218
4704
  "ROTATION_NOT_RETAINING",
4219
4705
  `A dual-valid rotation needs the previous value to stay readable during the grace window, and the \`${provider.type}\` provider for environment ${environment} does not retain it`,
4220
4706
  "Point this environment at a provider that keeps prior versions (its `readPrevious` is what penv reads during the window), or, if a momentary overlap is not required, declare the parameter `atomic-cutover` in its meta and flip it in one step."
@@ -4226,12 +4712,20 @@ async function runRotate(options) {
4226
4712
  const project = openProject(options.cwd);
4227
4713
  const environment = targetEnvironment(project, options.environment);
4228
4714
  const ref = refFromKey(options.key, project.config);
4229
- const provider = await sourceProviderFor(project, environment);
4715
+ const source = await sourceProviderFor(project, environment);
4716
+ if (!holdsRecords2(source)) {
4717
+ throw new PenvError16(
4718
+ "ROTATION_NOT_RECORDS",
4719
+ `Environment ${environment} is backed by \`${source.type}\`, which holds a resolved projection penv cannot rotate in place`,
4720
+ "Rotate the parameter in the environment that holds the records (the local tree, Vault, SSM), then `penv push` the result to this destination."
4721
+ );
4722
+ }
4723
+ const provider = source;
4230
4724
  const nowIso = options.now ?? (/* @__PURE__ */ new Date()).toISOString();
4231
4725
  const before = await provider.readMeta(ref);
4232
4726
  const { mechanism } = rotationOf2(before, environment);
4233
4727
  if (mechanism === void 0) {
4234
- throw new PenvError15(
4728
+ throw new PenvError16(
4235
4729
  "ROTATION_NO_MECHANISM",
4236
4730
  `Parameter ${options.key} declares no rotation mechanism for environment ${environment}, so penv does not know how to rotate it`,
4237
4731
  'Set `rotationMechanism` in the parameter\'s meta to `"dual-valid"` (a grace-window overlap) or `"atomic-cutover"` (a single flip), then run `penv rotate` again.'
@@ -4241,7 +4735,7 @@ async function runRotate(options) {
4241
4735
  const complete = options.complete === true;
4242
4736
  if (mechanism === "atomic-cutover") {
4243
4737
  if (begin || complete) {
4244
- throw new PenvError15(
4738
+ throw new PenvError16(
4245
4739
  "ROTATION_MECHANISM_MISMATCH",
4246
4740
  `Parameter ${options.key} is atomic-cutover, which flips in one step, so \`--begin\`/\`--complete\` do not apply`,
4247
4741
  "Run `penv rotate <key> <value>` with no phase flag to flip it. `--begin`/`--complete` bracket a dual-valid grace window, which atomic-cutover has none of."
@@ -4255,7 +4749,7 @@ async function runRotate(options) {
4255
4749
  }
4256
4750
  const retaining = requireRetaining(provider, environment);
4257
4751
  if (begin === complete) {
4258
- throw new PenvError15(
4752
+ throw new PenvError16(
4259
4753
  "ROTATION_PHASE_REQUIRED",
4260
4754
  `A dual-valid rotation of ${options.key} needs exactly one of \`--begin\` or \`--complete\``,
4261
4755
  "`--begin` writes the new value and opens the grace window; `--complete` closes it once every reader has moved to the new value. Run them in that order, one at a time."