@penvhq/cli 0.3.2 → 0.5.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."
@@ -376,29 +420,61 @@ function permitsAbsence(node) {
376
420
  }
377
421
  return void 0;
378
422
  }
379
- function leaves(node, path, inherited, out) {
423
+ function leaves(node, path, inherited, out2) {
380
424
  const own = permitsAbsence(node);
381
425
  const absencePermitted = inherited === true || own === true ? true : combine(inherited, own);
382
426
  const shape = shapeOf(unwrap(node));
383
427
  if (shape === void 0) {
384
428
  if (path.length > 0) {
385
- out.push({ path, absencePermitted });
429
+ out2.push({ path, absencePermitted, node });
386
430
  }
387
431
  return;
388
432
  }
389
433
  for (const key of Object.keys(shape)) {
390
- leaves(shape[key], [...path, key], absencePermitted, out);
434
+ leaves(shape[key], [...path, key], absencePermitted, out2);
391
435
  }
392
436
  }
393
437
  function combine(left, right) {
394
438
  return left === void 0 || right === void 0 ? void 0 : false;
395
439
  }
396
440
  function declaredLeaves(schema) {
397
- const out = [];
398
- leaves(schema, [], false, out);
399
- return out;
441
+ const out2 = [];
442
+ leaves(schema, [], false, out2);
443
+ return out2;
444
+ }
445
+ function defaultValueOf(node) {
446
+ let current = node;
447
+ for (let depth = 0; depth < 8; depth += 1) {
448
+ const def = defOf(current);
449
+ if (def === void 0) {
450
+ return void 0;
451
+ }
452
+ if (typeOf(current) === "default" || typeOf(current) === "prefault") {
453
+ let value = def.defaultValue;
454
+ if (typeof value === "function") {
455
+ try {
456
+ value = value();
457
+ } catch {
458
+ return void 0;
459
+ }
460
+ }
461
+ if (typeof value === "string") {
462
+ return JSON.stringify(value);
463
+ }
464
+ if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
465
+ return String(value);
466
+ }
467
+ return void 0;
468
+ }
469
+ const inner = def.innerType;
470
+ if (inner === void 0) {
471
+ return void 0;
472
+ }
473
+ current = inner;
474
+ }
475
+ return void 0;
400
476
  }
401
- var EMPTY_DRIFT = { declared: [], undeclared: [] };
477
+ var EMPTY_DRIFT = { declared: [], undeclared: [], optional: [] };
402
478
  function hasValue(resolution) {
403
479
  return resolution.winner !== void 0;
404
480
  }
@@ -406,23 +482,39 @@ function computeDrift(input) {
406
482
  const { schema, resolutions, config, environment } = input;
407
483
  const valued = new Set(resolutions.filter(hasValue).map((resolution) => resolution.parameter));
408
484
  const declared = [];
485
+ const optional = [];
409
486
  for (const leaf of declaredLeaves(schema)) {
410
- if (leaf.absencePermitted !== false) {
487
+ if (leaf.absencePermitted === void 0) {
411
488
  continue;
412
489
  }
413
490
  const path = leaf.path.join(".");
414
491
  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.`;
415
493
  if (ref === void 0 || isReservedToken2(ref.name, config)) {
416
- declared.push({
417
- subject: path,
418
- remedy: `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.`,
419
- detail: "declared, no filename reaches it"
420
- });
494
+ if (leaf.absencePermitted) {
495
+ optional.push({ subject: path, remedy: renameRemedy });
496
+ } else {
497
+ declared.push({
498
+ subject: path,
499
+ remedy: renameRemedy,
500
+ detail: "declared, no filename reaches it"
501
+ });
502
+ }
421
503
  continue;
422
504
  }
423
505
  if (valued.has(parameterId2(ref))) {
424
506
  continue;
425
507
  }
508
+ if (leaf.absencePermitted) {
509
+ const defaultValue = defaultValueOf(leaf.node);
510
+ optional.push({
511
+ subject: parameterId2(ref),
512
+ ref,
513
+ ...defaultValue === void 0 ? {} : { defaultValue },
514
+ remedy: `penv set ${[...ref.namespace, ref.name].join("/")} --env ${environment}`
515
+ });
516
+ continue;
517
+ }
426
518
  declared.push({
427
519
  subject: parameterId2(ref),
428
520
  ref,
@@ -440,28 +532,91 @@ function computeDrift(input) {
440
532
  variable: variableName(resolution.ref, config)
441
533
  });
442
534
  }
443
- return { declared, undeclared };
535
+ return { declared, undeclared, optional };
536
+ }
537
+
538
+ // src/style.ts
539
+ function supportsColor(stream) {
540
+ const env = process.env;
541
+ if (env.NO_COLOR !== void 0 && env.NO_COLOR !== "") {
542
+ return false;
543
+ }
544
+ if (env.FORCE_COLOR !== void 0 && env.FORCE_COLOR !== "" && env.FORCE_COLOR !== "0") {
545
+ return true;
546
+ }
547
+ if (env.TERM === "dumb") {
548
+ return false;
549
+ }
550
+ return stream.isTTY === true;
551
+ }
552
+ var ESC = `${String.fromCharCode(27)}[`;
553
+ var identity = (text) => text;
554
+ function sgr(open, close, on) {
555
+ if (!on) {
556
+ return identity;
557
+ }
558
+ const opener = `${ESC}${open}m`;
559
+ const closer = `${ESC}${close}m`;
560
+ return (text) => `${opener}${text}${closer}`;
561
+ }
562
+ function paletteFor(stream) {
563
+ const on = supportsColor(stream);
564
+ return {
565
+ enabled: on,
566
+ bold: sgr(1, 22, on),
567
+ dim: sgr(2, 22, on),
568
+ red: sgr(31, 39, on),
569
+ green: sgr(32, 39, on),
570
+ yellow: sgr(33, 39, on),
571
+ cyan: sgr(36, 39, on),
572
+ magenta: sgr(35, 39, on)
573
+ };
574
+ }
575
+ var out = paletteFor(process.stdout);
576
+ var err = paletteFor(process.stderr);
577
+ var STYLE_PATTERN = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
578
+ function visibleWidth(text) {
579
+ return text.replace(STYLE_PATTERN, "").length;
580
+ }
581
+ function padVisible(text, width) {
582
+ const missing = width - visibleWidth(text);
583
+ return missing > 0 ? text + " ".repeat(missing) : text;
444
584
  }
445
585
 
446
586
  // src/ui.ts
447
- import { PenvError as PenvError3 } from "@penvhq/core";
587
+ import { PenvError as PenvError4 } from "@penvhq/core";
448
588
  var CHECK = "\u2713";
449
589
  var WARN = "\u26A0";
590
+ var CROSS = "\u2717";
450
591
  var UNKNOWN = "?";
592
+ function paintGlyph(glyph) {
593
+ switch (glyph) {
594
+ case CHECK:
595
+ return out.green(glyph);
596
+ case WARN:
597
+ return out.yellow(glyph);
598
+ case CROSS:
599
+ return out.red(glyph);
600
+ case UNKNOWN:
601
+ return out.dim(glyph);
602
+ default:
603
+ return glyph;
604
+ }
605
+ }
451
606
  var NOTE_COLUMN = 29;
452
607
  function widest(values) {
453
- return values.reduce((max, value) => Math.max(max, value.length), 0);
608
+ return values.reduce((max, value) => Math.max(max, visibleWidth(value)), 0);
454
609
  }
455
610
  function formatRows(rows) {
456
611
  const labelWidth = widest(rows.map((row) => row.label)) + 2;
457
612
  const detailed = rows.filter((row) => row.detail !== void 0);
458
613
  const subjectWidth = widest(detailed.map((row) => row.subject ?? "")) + 1;
459
614
  return rows.map((row) => {
460
- const head = `${row.glyph} ${row.label.padEnd(labelWidth)}`;
615
+ const head = `${paintGlyph(row.glyph)} ${padVisible(row.label, labelWidth)}`;
461
616
  if (row.detail === void 0) {
462
617
  return `${head}${row.subject ?? ""}`.trimEnd();
463
618
  }
464
- return `${head}${(row.subject ?? "").padEnd(subjectWidth)}${row.detail}`.trimEnd();
619
+ return `${head}${padVisible(row.subject ?? "", subjectWidth)}${out.dim(row.detail)}`.trimEnd();
465
620
  });
466
621
  }
467
622
  function columns(rows, gap = 2) {
@@ -471,18 +626,31 @@ function columns(rows, gap = 2) {
471
626
  widths.push(widest(rows.map((row) => row[column] ?? "")) + gap);
472
627
  }
473
628
  return rows.map(
474
- (row) => row.map((cell, index) => index === row.length - 1 ? cell : cell.padEnd(widths[index] ?? 0)).join("").trimEnd()
629
+ (row) => row.map(
630
+ (cell, index) => index === row.length - 1 ? cell : padVisible(cell, widths[index] ?? 0)
631
+ ).join("").trimEnd()
475
632
  );
476
633
  }
477
634
  function formatSteps(steps) {
478
635
  return steps.map((step) => {
479
636
  if (step.note === void 0) {
480
- return `${step.glyph} ${step.text}`;
637
+ return `${paintGlyph(step.glyph)} ${step.text}`;
481
638
  }
482
- const text = step.text.length >= NOTE_COLUMN ? `${step.text} ` : step.text.padEnd(NOTE_COLUMN);
483
- return `${step.glyph} ${text}${step.note}`;
639
+ const text = visibleWidth(step.text) >= NOTE_COLUMN ? `${step.text} ` : padVisible(step.text, NOTE_COLUMN);
640
+ return `${paintGlyph(step.glyph)} ${text}${out.dim(step.note)}`;
484
641
  });
485
642
  }
643
+ function heading(command, context) {
644
+ return context === void 0 ? out.bold(command) : `${out.bold(command)} ${out.dim(`\xB7 ${context}`)}`;
645
+ }
646
+ function tip(text) {
647
+ return ` ${out.cyan("\u2192")} ${text}`;
648
+ }
649
+ function prompt(subject, context) {
650
+ const head = `${out.cyan("?")} ${out.bold(subject)}`;
651
+ const tail = out.dim("\u203A ");
652
+ return context === void 0 ? `${head} ${tail}` : `${head} ${out.dim(`\xB7 ${context}`)} ${tail}`;
653
+ }
486
654
  function write(lines) {
487
655
  for (const line of lines) {
488
656
  process.stdout.write(`${line}
@@ -490,14 +658,27 @@ function write(lines) {
490
658
  }
491
659
  }
492
660
  function reportError(error) {
493
- if (error instanceof PenvError3) {
494
- process.stderr.write(`${error.message}
661
+ if (error instanceof PenvError4) {
662
+ const suffix = error.remedy === void 0 ? void 0 : `
663
+ ${error.remedy}`;
664
+ const message = suffix !== void 0 && error.message.endsWith(suffix) ? error.message.slice(0, -suffix.length) : error.message;
665
+ process.stderr.write(`${err.red(CROSS)} ${message}
666
+ `);
667
+ if (error.remedy !== void 0) {
668
+ process.stderr.write(` ${err.cyan("\u2192")} ${error.remedy}
495
669
  `);
670
+ }
496
671
  } else if (error instanceof Error) {
497
- process.stderr.write(`${error.stack ?? error.message}
672
+ process.stderr.write(`${err.red(CROSS)} ${error.message}
673
+ `);
674
+ const stack = error.stack;
675
+ const frames = stack?.startsWith(`${error.name}: ${error.message}`) ? stack.slice(`${error.name}: ${error.message}`.length).replace(/^\n/, "") : stack;
676
+ if (frames !== void 0 && frames !== "") {
677
+ process.stderr.write(`${err.dim(frames)}
498
678
  `);
679
+ }
499
680
  } else {
500
- process.stderr.write(`${String(error)}
681
+ process.stderr.write(`${err.red(CROSS)} ${String(error)}
501
682
  `);
502
683
  }
503
684
  process.exitCode = 1;
@@ -511,31 +692,92 @@ async function guard(run) {
511
692
  }
512
693
 
513
694
  // src/commands/push.ts
514
- import { checkNameCollisions, PenvError as PenvError4, requireValue, variableName as variableName2 } from "@penvhq/core";
515
- 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";
516
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
517
747
  var LAST_PUSHED_KEY = "lastPushedAt";
518
- function sinkFor(project, environment, override) {
519
- const declared = project.config.sinks?.[environment];
520
- if (declared === void 0) {
521
- throw new PenvError4(
522
- "NO_SINK",
523
- `Environment ${environment} declares no sink in penv.config.ts, so penv has nowhere to push`,
524
- `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>\`.`
525
769
  );
526
770
  }
527
- const repo = declared.repo;
528
- if (override !== void 0) {
529
- return { sink: override, repo };
771
+ if (options.provider !== void 0) {
772
+ return { provider: options.provider, location };
530
773
  }
531
- if (declared.type === "github") {
532
- return { sink: createGithubSink(repo === void 0 ? {} : { repo }), repo };
533
- }
534
- throw new PenvError4(
535
- "UNKNOWN_SINK",
536
- `Environment ${environment} declares sink type \`${declared.type}\`, which penv does not know`,
537
- 'The only sink in this release is `github`. Set `type: "github"`.'
538
- );
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 };
539
781
  }
540
782
  function plan(resolutions, config, environment, allowDecrypt) {
541
783
  const outbound = [];
@@ -547,10 +789,10 @@ function plan(resolutions, config, environment, allowDecrypt) {
547
789
  let encrypted = false;
548
790
  if (winner.file.encrypted) {
549
791
  if (!allowDecrypt) {
550
- throw new PenvError4(
792
+ throw new PenvError5(
551
793
  "ENCRYPTED_VALUE_REFUSED",
552
- `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`,
553
- "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."
554
796
  );
555
797
  }
556
798
  requireValue(resolution, environment);
@@ -580,10 +822,46 @@ function recordPush(tree, ref, environment, iso) {
580
822
  const meta = withLastPushed(tree.readMetaSync(ref), environment, iso);
581
823
  tree.writeMetaSync(ref, meta);
582
824
  }
583
- async function runPush(options) {
584
- const project = openProject(options.cwd);
585
- const environment = targetEnvironment(project, options.environment);
586
- 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) {
587
865
  const tree = localTree(project);
588
866
  const keys = keySourceFor(project, environment);
589
867
  const resolutions = resolveAllSync(tree, environment, keys, true);
@@ -592,16 +870,17 @@ async function runPush(options) {
592
870
  if (collision !== void 0) {
593
871
  throw collision;
594
872
  }
595
- const nameError = checkGithubNames(refs, project.config)[0];
873
+ const nameError = provider.checkNames?.(refs, project.config)[0];
596
874
  if (nameError !== void 0) {
597
875
  throw nameError;
598
876
  }
599
877
  const outbound = plan(resolutions, project.config, environment, options.allowDecrypt === true);
600
- await sink.verify();
878
+ await provider.verify();
879
+ const createdTarget = outbound.some((item) => item.scope.kind === "environment") && await ensureTargetApproved(provider, environment, options);
601
880
  let repositorySecrets = 0;
602
881
  let environmentSecrets = 0;
603
882
  for (const item of outbound) {
604
- await sink.push(item.variable, item.value, item.scope);
883
+ await provider.push(item.variable, item.value, item.scope);
605
884
  if (item.scope.kind === "repository") {
606
885
  repositorySecrets += 1;
607
886
  } else {
@@ -611,15 +890,70 @@ async function runPush(options) {
611
890
  }
612
891
  return {
613
892
  environment,
614
- repo,
893
+ destination: provider.type,
894
+ mode: "projection",
895
+ location,
615
896
  pushed: outbound.length,
897
+ meta: 0,
616
898
  repositorySecrets,
617
899
  environmentSecrets,
618
- decrypted: outbound.filter((item) => item.encrypted).length
900
+ decrypted: outbound.filter((item) => item.encrypted).length,
901
+ createdTarget
619
902
  };
620
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
944
+ };
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
+ }
621
955
  function renderPush(result2) {
622
- if (result2.pushed === 0) {
956
+ if (result2.pushed === 0 && result2.meta === 0) {
623
957
  return formatRows([
624
958
  {
625
959
  glyph: CHECK,
@@ -628,7 +962,23 @@ function renderPush(result2) {
628
962
  }
629
963
  ]);
630
964
  }
631
- 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
+ }
632
982
  const rows = [
633
983
  {
634
984
  glyph: CHECK,
@@ -643,12 +993,20 @@ function renderPush(result2) {
643
993
  detail: "environment secrets override repository secrets, as penv's env scope overrides the default"
644
994
  }
645
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
+ }
646
1004
  if (result2.decrypted > 0) {
647
1005
  rows.push({
648
1006
  glyph: WARN,
649
1007
  label: "Decrypted",
650
1008
  subject: `${result2.decrypted} ${result2.decrypted === 1 ? "secret" : "secrets"}`,
651
- 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"
652
1010
  });
653
1011
  }
654
1012
  return formatRows(rows);
@@ -656,13 +1014,28 @@ function renderPush(result2) {
656
1014
  var pushCommand = defineCommand({
657
1015
  meta: {
658
1016
  name: "push",
659
- description: "Push an environment's resolved values to its sink (GitHub Actions Secrets)"
1017
+ description: "Push an environment's values to its provider"
660
1018
  },
661
1019
  args: {
662
1020
  env: { type: "string", description: "The environment to push" },
663
1021
  "allow-decrypt": {
664
1022
  type: "boolean",
665
- 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"
666
1039
  }
667
1040
  },
668
1041
  run({ args }) {
@@ -670,7 +1043,22 @@ var pushCommand = defineCommand({
670
1043
  const result2 = await runPush({
671
1044
  cwd: process.cwd(),
672
1045
  ...args.env === void 0 ? {} : { environment: args.env },
673
- ...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
+ ])
674
1062
  });
675
1063
  write(renderPush(result2));
676
1064
  });
@@ -685,7 +1073,7 @@ import {
685
1073
  checkNameCollisions as checkNameCollisions2,
686
1074
  jitiFor,
687
1075
  NameCollisionError,
688
- PenvError as PenvError5,
1076
+ PenvError as PenvError6,
689
1077
  ReservedTokenError as ReservedTokenError2,
690
1078
  resolveAll,
691
1079
  SCHEMA_HARVEST_ENV,
@@ -824,14 +1212,14 @@ async function loadSchemaExclusively(file, schemaPath, environment) {
824
1212
  }
825
1213
  async function runValidate(options) {
826
1214
  const project = openProject(options.cwd);
827
- const environment = targetEnvironment(project, options.environment);
1215
+ const environment = targetEnvironment(project, options.environment, options.envFlags);
828
1216
  const schemaPath = schemaFileOf(project.config);
829
1217
  const issues = [];
830
1218
  let files;
831
1219
  try {
832
1220
  files = await project.provider.list();
833
1221
  } catch (error) {
834
- if (!(error instanceof PenvError5)) {
1222
+ if (!(error instanceof PenvError6)) {
835
1223
  throw error;
836
1224
  }
837
1225
  return {
@@ -905,18 +1293,26 @@ function renderValidate(result2) {
905
1293
  ]);
906
1294
  }
907
1295
  const rows = result2.issues.map((issue) => ({
908
- glyph: WARN,
1296
+ glyph: CROSS,
909
1297
  label: LABELS[issue.kind],
910
1298
  subject: issue.subject,
911
1299
  detail: issue.message
912
1300
  }));
913
1301
  const lines = formatRows(rows);
914
- const remedies = [...new Set(result2.issues.map((issue) => issue.remedy))];
915
- for (const remedy of remedies) {
916
- if (remedy !== void 0) {
917
- lines.push(` ${remedy}`);
1302
+ const remedies = [...new Set(result2.issues.map((issue) => issue.remedy))].filter(
1303
+ (remedy) => remedy !== void 0
1304
+ );
1305
+ if (remedies.length > 0) {
1306
+ lines.push("");
1307
+ for (const remedy of remedies) {
1308
+ lines.push(tip(remedy));
918
1309
  }
919
1310
  }
1311
+ const count = result2.issues.length;
1312
+ lines.push(
1313
+ "",
1314
+ `${out.red(CROSS)} ${count} ${count === 1 ? "issue" : "issues"} for environment ${result2.environment}`
1315
+ );
920
1316
  return lines;
921
1317
  }
922
1318
  var validateCommand = defineCommand2({
@@ -931,7 +1327,8 @@ var validateCommand = defineCommand2({
931
1327
  return guard(async () => {
932
1328
  const result2 = await runValidate({
933
1329
  cwd: process.cwd(),
934
- ...args.env === void 0 ? {} : { environment: args.env }
1330
+ ...args.env === void 0 ? {} : { environment: args.env },
1331
+ envFlags: shorthandCandidates(args, ["env"])
935
1332
  });
936
1333
  write(renderValidate(result2));
937
1334
  if (!result2.ok) {
@@ -954,10 +1351,19 @@ function skipped(check, label) {
954
1351
  }
955
1352
  async function runDoctor(options) {
956
1353
  const project = openProject(options.cwd);
957
- const environment = targetEnvironment(project, options.environment);
1354
+ const environment = targetEnvironment(project, options.environment, options.envFlags);
958
1355
  const findings = [];
959
1356
  const now = new Date(options.now ?? (/* @__PURE__ */ new Date()).toISOString());
960
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
+ }
961
1367
  const { schema, issues } = await loadSchema(project, environment);
962
1368
  if (schema !== void 0) {
963
1369
  findings.push({ check: "schema", severity: "pass", label: "Schema valid" });
@@ -1014,7 +1420,7 @@ async function runDoctor(options) {
1014
1420
  findings.push(...stuckFindings(rotation.subjects, environment, now, stuckThresholdMs));
1015
1421
  }
1016
1422
  findings.push(...await providerDriftFindings(project, environment, options.source));
1017
- findings.push(...await sinkFindings(project, environment, options.sink));
1423
+ findings.push(...await projectionFindings(project, environment, options.projection));
1018
1424
  findings.push({
1019
1425
  check: "provider",
1020
1426
  severity: "pass",
@@ -1250,15 +1656,6 @@ function encryptionFindings(subjects, environment) {
1250
1656
  ];
1251
1657
  }
1252
1658
  var EDIT_SKEW_MS = 12e4;
1253
- function buildSink(declared, override) {
1254
- if (override !== void 0) {
1255
- return override;
1256
- }
1257
- if (declared.type === "github") {
1258
- return createGithubSink2(declared.repo === void 0 ? {} : { repo: declared.repo });
1259
- }
1260
- return void 0;
1261
- }
1262
1659
  function errorDetail(error) {
1263
1660
  if (error instanceof Error) {
1264
1661
  return error.message.split("\n")[0] ?? error.message;
@@ -1268,32 +1665,43 @@ function errorDetail(error) {
1268
1665
  function scopeLabel(scope) {
1269
1666
  return scope.kind === "repository" ? "repository secrets" : `environment secrets for ${scope.environment}`;
1270
1667
  }
1271
- async function sinkFindings(project, environment, override) {
1272
- const declared = project.config.sinks?.[environment];
1273
- if (declared === void 0) {
1274
- return [];
1275
- }
1276
- const sink = buildSink(declared, override);
1277
- if (sink === void 0) {
1278
- return [
1279
- {
1280
- check: "sink-unreachable",
1281
- severity: "unknown",
1282
- label: "Sink",
1283
- subject: `sink type \`${declared.type}\` is not one penv knows`,
1284
- detail: "penv cannot check a sink it cannot build"
1285
- }
1286
- ];
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;
1287
1695
  }
1288
1696
  try {
1289
- await sink.verify();
1697
+ await projection.verify();
1290
1698
  } catch (error) {
1291
1699
  return [
1292
1700
  {
1293
- check: "sink-unreachable",
1701
+ check: "projection-unreachable",
1294
1702
  severity: "unknown",
1295
- label: "Sink",
1296
- subject: `could not reach the ${declared.type} sink for ${environment}`,
1703
+ label: "Destination",
1704
+ subject: `could not reach the ${projection.type} destination for ${environment}`,
1297
1705
  detail: errorDetail(error)
1298
1706
  }
1299
1707
  ];
@@ -1301,15 +1709,15 @@ async function sinkFindings(project, environment, override) {
1301
1709
  let repoSecrets;
1302
1710
  let envSecrets;
1303
1711
  try {
1304
- repoSecrets = await sink.list({ kind: "repository" });
1305
- envSecrets = await sink.list({ kind: "environment", environment });
1712
+ repoSecrets = await projection.list({ kind: "repository" });
1713
+ envSecrets = await projection.list({ kind: "environment", environment });
1306
1714
  } catch (error) {
1307
1715
  return [
1308
1716
  {
1309
- check: "sink-unreachable",
1717
+ check: "projection-unreachable",
1310
1718
  severity: "unknown",
1311
- label: "Sink",
1312
- 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}`,
1313
1721
  detail: errorDetail(error)
1314
1722
  }
1315
1723
  ];
@@ -1347,7 +1755,7 @@ async function sinkFindings(project, environment, override) {
1347
1755
  }
1348
1756
  if (!destOf(item.scope).has(key)) {
1349
1757
  nameDrift.push({
1350
- check: "sink-name-drift",
1758
+ check: "projection-name-drift",
1351
1759
  severity: "warning",
1352
1760
  label: "Declared, not pushed",
1353
1761
  subject: item.variable,
@@ -1359,7 +1767,7 @@ async function sinkFindings(project, environment, override) {
1359
1767
  for (const secret of repoSecrets) {
1360
1768
  if (!allVariables.has(upper(secret.name))) {
1361
1769
  nameDrift.push({
1362
- check: "sink-name-drift",
1770
+ check: "projection-name-drift",
1363
1771
  severity: "warning",
1364
1772
  label: "In destination, not declared",
1365
1773
  subject: secret.name,
@@ -1370,7 +1778,7 @@ async function sinkFindings(project, environment, override) {
1370
1778
  for (const secret of envSecrets) {
1371
1779
  if (!expectedEnv.has(upper(secret.name))) {
1372
1780
  nameDrift.push({
1373
- check: "sink-name-drift",
1781
+ check: "projection-name-drift",
1374
1782
  severity: "warning",
1375
1783
  label: "In destination, not declared",
1376
1784
  subject: secret.name,
@@ -1394,7 +1802,7 @@ async function sinkFindings(project, environment, override) {
1394
1802
  continue;
1395
1803
  }
1396
1804
  manualEdits.push({
1397
- check: "sink-manual-edit",
1805
+ check: "projection-manual-edit",
1398
1806
  severity: "warning",
1399
1807
  label: "Edited outside penv",
1400
1808
  subject: item.variable,
@@ -1405,9 +1813,9 @@ async function sinkFindings(project, environment, override) {
1405
1813
  findings.push(
1406
1814
  ...nameDrift.length > 0 ? nameDrift : [
1407
1815
  {
1408
- check: "sink-name-drift",
1816
+ check: "projection-name-drift",
1409
1817
  severity: "pass",
1410
- label: "Sink names",
1818
+ label: "Destination names",
1411
1819
  subject: `every parameter resolving for ${environment} is present, and nothing undeclared is`
1412
1820
  }
1413
1821
  ]
@@ -1415,17 +1823,17 @@ async function sinkFindings(project, environment, override) {
1415
1823
  findings.push(
1416
1824
  ...manualEdits.length > 0 ? manualEdits : [
1417
1825
  {
1418
- check: "sink-manual-edit",
1826
+ check: "projection-manual-edit",
1419
1827
  severity: "pass",
1420
- label: "Sink hand-edits",
1828
+ label: "Destination hand-edits",
1421
1829
  subject: `no secret has changed outside penv since its last push for ${environment}`
1422
1830
  }
1423
1831
  ]
1424
1832
  );
1425
1833
  findings.push({
1426
- check: "sink-value-drift",
1834
+ check: "projection-value-drift",
1427
1835
  severity: "unknown",
1428
- label: "Sink values",
1836
+ label: "Destination values",
1429
1837
  subject: "cannot be read back from a write-only destination",
1430
1838
  detail: "value drift between the tree and the destination is unknowable by design"
1431
1839
  });
@@ -1454,6 +1862,9 @@ async function rotationSubjects(project, environment, local, override) {
1454
1862
  return { kind: "read", subjects: local };
1455
1863
  }
1456
1864
  const source = override ?? await sourceProviderFor(project, environment);
1865
+ if (!holdsRecords(source)) {
1866
+ return { kind: "read", subjects: local };
1867
+ }
1457
1868
  try {
1458
1869
  const subjects = await Promise.all(
1459
1870
  local.map(async ({ resolution }) => ({
@@ -1602,6 +2013,9 @@ async function providerDriftFindings(project, environment, override) {
1602
2013
  ];
1603
2014
  }
1604
2015
  const source = override ?? await sourceProviderFor(project, environment);
2016
+ if (!holdsRecords(source)) {
2017
+ return [];
2018
+ }
1605
2019
  let local;
1606
2020
  let remote;
1607
2021
  try {
@@ -1671,28 +2085,57 @@ async function providerDriftFindings(project, environment, override) {
1671
2085
  }
1672
2086
  ];
1673
2087
  }
2088
+ var GLYPHS = {
2089
+ pass: CHECK,
2090
+ warning: WARN,
2091
+ failure: CROSS,
2092
+ unknown: UNKNOWN
2093
+ };
2094
+ function summarize(report) {
2095
+ const counts = { pass: 0, warning: 0, failure: 0, unknown: 0 };
2096
+ for (const finding of report.findings) {
2097
+ counts[finding.severity] += 1;
2098
+ }
2099
+ const parts = [out.green(`${counts.pass} passed`)];
2100
+ if (counts.warning > 0) {
2101
+ parts.push(out.yellow(`${counts.warning} ${counts.warning === 1 ? "warning" : "warnings"}`));
2102
+ }
2103
+ if (counts.failure > 0) {
2104
+ parts.push(out.red(`${counts.failure} ${counts.failure === 1 ? "failure" : "failures"}`));
2105
+ }
2106
+ if (counts.unknown > 0) {
2107
+ parts.push(out.dim(`${counts.unknown} could not be checked`));
2108
+ }
2109
+ const verdict = report.ok ? out.green(CHECK) : out.red(CROSS);
2110
+ return `${verdict} ${report.findings.length} checks: ${parts.join(out.dim(" \xB7 "))}`;
2111
+ }
1674
2112
  function renderDoctor(report) {
1675
2113
  const rows = report.findings.map((finding) => ({
1676
- glyph: finding.severity === "pass" ? CHECK : finding.severity === "unknown" ? UNKNOWN : WARN,
2114
+ glyph: GLYPHS[finding.severity],
1677
2115
  label: finding.label,
1678
2116
  ...finding.subject === void 0 ? {} : { subject: finding.subject },
1679
2117
  ...finding.detail === void 0 ? {} : { detail: finding.detail }
1680
2118
  }));
1681
- const lines = formatRows(rows);
2119
+ const lines = [heading("penv doctor", `environment ${report.environment}`), ""];
2120
+ lines.push(...formatRows(rows));
1682
2121
  const remedies = [
1683
2122
  ...new Set(
1684
2123
  report.findings.filter((finding) => finding.severity !== "pass").map((finding) => finding.remedy).filter((remedy) => remedy !== void 0)
1685
2124
  )
1686
2125
  ];
1687
- for (const remedy of remedies) {
1688
- lines.push(` ${remedy}`);
2126
+ if (remedies.length > 0) {
2127
+ lines.push("");
2128
+ for (const remedy of remedies) {
2129
+ lines.push(tip(remedy));
2130
+ }
1689
2131
  }
2132
+ lines.push("", summarize(report));
1690
2133
  return lines;
1691
2134
  }
1692
2135
  var doctorCommand = defineCommand3({
1693
2136
  meta: {
1694
2137
  name: "doctor",
1695
- description: "Report missing, weak, unused, fallback, plaintext-secret, and sink-drift issues"
2138
+ description: "Report missing, weak, unused, fallback, plaintext-secret, and drift issues"
1696
2139
  },
1697
2140
  args: {
1698
2141
  env: { type: "string", description: "The environment to report on" }
@@ -1701,7 +2144,8 @@ var doctorCommand = defineCommand3({
1701
2144
  return guard(async () => {
1702
2145
  const report = await runDoctor({
1703
2146
  cwd: process.cwd(),
1704
- ...args.env === void 0 ? {} : { environment: args.env }
2147
+ ...args.env === void 0 ? {} : { environment: args.env },
2148
+ envFlags: shorthandCandidates(args, ["env"])
1705
2149
  });
1706
2150
  write(renderDoctor(report));
1707
2151
  if (!report.ok) {
@@ -1716,14 +2160,14 @@ import {
1716
2160
  formatValueFile as formatValueFile4,
1717
2161
  isSecret as isSecret3,
1718
2162
  openValue as openValue3,
1719
- PenvError as PenvError7,
2163
+ PenvError as PenvError8,
1720
2164
  parameterId as parameterId4,
1721
2165
  sealValue as sealValue2
1722
2166
  } from "@penvhq/core";
1723
2167
  import { defineCommand as defineCommand5 } from "citty";
1724
2168
 
1725
2169
  // src/commands/set.ts
1726
- 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";
1727
2171
  import { defineCommand as defineCommand4 } from "citty";
1728
2172
  function scopeFrom(options) {
1729
2173
  if (options.local === true) {
@@ -1743,7 +2187,7 @@ function targetScope(project, options, key) {
1743
2187
  return scopeFrom(options);
1744
2188
  }
1745
2189
  if (environment.trim().length === 0) {
1746
- throw new PenvError6(
2190
+ throw new PenvError7(
1747
2191
  "ENVIRONMENT_FLAG_EMPTY",
1748
2192
  `\`--env\` for parameter ${key} names no environment`,
1749
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.`
@@ -1756,7 +2200,7 @@ function policyEnvironment(project, options) {
1756
2200
  }
1757
2201
  function sealFor(project, file, value, parameter, environment) {
1758
2202
  if (environment === void 0) {
1759
- throw new PenvError6(
2203
+ throw new PenvError7(
1760
2204
  "SECRET_SCOPE_AMBIGUOUS",
1761
2205
  `Parameter ${parameter} is a secret, and ${PENV_DIR}/${formatValueFile3(file)} names no environment`,
1762
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."
@@ -1858,7 +2302,7 @@ function twins(project, key, options) {
1858
2302
  }
1859
2303
  function environmentFor(project, options, verb) {
1860
2304
  if (options.environment === void 0) {
1861
- throw new PenvError7(
2305
+ throw new PenvError8(
1862
2306
  "SECRET_SCOPE_AMBIGUOUS",
1863
2307
  `\`penv ${verb}\` names no environment, and keys are declared per environment`,
1864
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."
@@ -1877,7 +2321,7 @@ async function runEncrypt(options) {
1877
2321
  const value = await readOne(project, plain);
1878
2322
  if (value === void 0) {
1879
2323
  const already = await readOne(project, sealed);
1880
- throw new PenvError7(
2324
+ throw new PenvError8(
1881
2325
  "PARAMETER_ABSENT",
1882
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)}`,
1883
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."
@@ -1898,7 +2342,7 @@ async function runDecrypt(options) {
1898
2342
  const [plain, sealed] = twins(project, options.key, options);
1899
2343
  const parameter = parameterId4(plain);
1900
2344
  if (isSecret3(await project.provider.readMeta(plain), environment)) {
1901
- throw new PenvError7(
2345
+ throw new PenvError8(
1902
2346
  "SECRET_DECRYPT_REFUSED",
1903
2347
  `Parameter ${parameter} is declared a secret for environment ${environment}, so penv will not write it in plaintext`,
1904
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."
@@ -1906,7 +2350,7 @@ async function runDecrypt(options) {
1906
2350
  }
1907
2351
  const stored = await readOne(project, sealed);
1908
2352
  if (stored === void 0) {
1909
- throw new PenvError7(
2353
+ throw new PenvError8(
1910
2354
  "PARAMETER_ABSENT",
1911
2355
  `Parameter ${parameter} has no encrypted value file at ${PENV_DIR}/${formatValueFile4(sealed)}`,
1912
2356
  `Nothing to decrypt. \`penv get ${options.key} --env ${environment} --explain\` shows every file penv looked at.`
@@ -1929,7 +2373,7 @@ async function runDecrypt(options) {
1929
2373
  removed: formatValueFile4(sealed)
1930
2374
  };
1931
2375
  }
1932
- var UndecryptableAt = class extends PenvError7 {
2376
+ var UndecryptableAt = class extends PenvError8 {
1933
2377
  constructor(parameter, environment, location, detail) {
1934
2378
  super(
1935
2379
  "VALUE_UNDECRYPTABLE",
@@ -1984,14 +2428,14 @@ var decryptCommand = defineCommand5({
1984
2428
  });
1985
2429
 
1986
2430
  // src/commands/fill.ts
1987
- import { createInterface } from "readline/promises";
1988
- import { PenvError as PenvError8 } from "@penvhq/core";
2431
+ import { PenvError as PenvError9 } from "@penvhq/core";
1989
2432
  import { defineCommand as defineCommand6 } from "citty";
1990
2433
  var BLOCKING = /* @__PURE__ */ new Set(["config", "collision", "reserved"]);
1991
2434
  async function runFill(options) {
1992
2435
  const validation = await runValidate({
1993
2436
  cwd: options.cwd,
1994
- ...options.environment === void 0 ? {} : { environment: options.environment }
2437
+ ...options.environment === void 0 ? {} : { environment: options.environment },
2438
+ ...options.envFlags === void 0 ? {} : { envFlags: options.envFlags }
1995
2439
  });
1996
2440
  const environment = validation.environment;
1997
2441
  const blockers = validation.issues.filter((issue) => BLOCKING.has(issue.kind));
@@ -1999,7 +2443,7 @@ async function runFill(options) {
1999
2443
  const detail = blockers.map(
2000
2444
  (issue) => ` - ${issue.message}${issue.remedy === void 0 ? "" : ` (${issue.remedy})`}`
2001
2445
  ).join("\n");
2002
- throw new PenvError8(
2446
+ throw new PenvError9(
2003
2447
  "FILL_BLOCKED",
2004
2448
  `penv fill cannot run: environment ${environment} has ${blockers.length} unresolved configuration ${blockers.length === 1 ? "issue" : "issues"}:
2005
2449
  ${detail}`,
@@ -2008,6 +2452,7 @@ ${detail}`,
2008
2452
  }
2009
2453
  const written = [];
2010
2454
  const skipped2 = [];
2455
+ const kept = [];
2011
2456
  const unreachable = [];
2012
2457
  for (const drift of validation.drift.declared) {
2013
2458
  if (drift.ref === void 0) {
@@ -2016,7 +2461,12 @@ ${detail}`,
2016
2461
  }
2017
2462
  const ref = drift.ref;
2018
2463
  const key = [...ref.namespace, ref.name].join("/");
2019
- const value = await options.ask({ parameter: key, environment, secret: false });
2464
+ const value = await options.ask({
2465
+ parameter: key,
2466
+ environment,
2467
+ secret: false,
2468
+ optional: false
2469
+ });
2020
2470
  if (value === void 0 || value === "") {
2021
2471
  skipped2.push(drift.subject);
2022
2472
  continue;
@@ -2028,17 +2478,45 @@ ${detail}`,
2028
2478
  encrypted: result2.encrypted
2029
2479
  });
2030
2480
  }
2031
- return { environment, written, skipped: skipped2, unreachable };
2481
+ for (const item of validation.drift.optional) {
2482
+ if (item.ref === void 0) {
2483
+ unreachable.push({ subject: item.subject, remedy: item.remedy });
2484
+ continue;
2485
+ }
2486
+ const ref = item.ref;
2487
+ const key = [...ref.namespace, ref.name].join("/");
2488
+ const value = await options.ask({
2489
+ parameter: key,
2490
+ environment,
2491
+ secret: false,
2492
+ optional: true,
2493
+ ...item.defaultValue === void 0 ? {} : { defaultValue: item.defaultValue }
2494
+ });
2495
+ if (value === void 0 || value === "") {
2496
+ kept.push(item.subject);
2497
+ continue;
2498
+ }
2499
+ const result2 = await runSet({ cwd: options.cwd, key, value, environment });
2500
+ written.push({
2501
+ parameter: item.subject,
2502
+ location: result2.location,
2503
+ encrypted: result2.encrypted
2504
+ });
2505
+ }
2506
+ return { environment, written, skipped: skipped2, kept, unreachable };
2032
2507
  }
2033
2508
  function summaryLine(result2) {
2034
2509
  const filled = result2.written.length;
2035
- if (filled === 0 && result2.skipped.length === 0 && result2.unreachable.length === 0) {
2036
- return `Nothing to fill for environment ${result2.environment}: every declared parameter has a value`;
2510
+ if (filled === 0 && result2.skipped.length === 0 && result2.kept.length === 0 && result2.unreachable.length === 0) {
2511
+ return `${out.green(CHECK)} Nothing to fill for environment ${result2.environment}: every declared parameter has a value`;
2037
2512
  }
2038
2513
  const parts = [`${filled} written`];
2039
2514
  if (result2.skipped.length > 0) {
2040
2515
  parts.push(`${result2.skipped.length} skipped`);
2041
2516
  }
2517
+ if (result2.kept.length > 0) {
2518
+ parts.push(`${result2.kept.length} left to the schema's defaults`);
2519
+ }
2042
2520
  if (result2.unreachable.length > 0) {
2043
2521
  parts.push(`${result2.unreachable.length} unreachable`);
2044
2522
  }
@@ -2059,6 +2537,16 @@ function renderFill(result2) {
2059
2537
  lines.push(summaryLine(result2));
2060
2538
  return lines;
2061
2539
  }
2540
+ function contextFor(prompt2) {
2541
+ if (!prompt2.optional) {
2542
+ return `${prompt2.environment}, Enter to skip`;
2543
+ }
2544
+ if (prompt2.defaultValue === void 0) {
2545
+ return `${prompt2.environment} \xB7 optional, Enter leaves it unset`;
2546
+ }
2547
+ const shown = prompt2.defaultValue.length > 24 ? `${prompt2.defaultValue.slice(0, 23)}\u2026` : prompt2.defaultValue;
2548
+ return `${prompt2.environment} \xB7 optional, Enter keeps ${shown}`;
2549
+ }
2062
2550
  var fillCommand = defineCommand6({
2063
2551
  meta: {
2064
2552
  name: "fill",
@@ -2069,20 +2557,21 @@ var fillCommand = defineCommand6({
2069
2557
  },
2070
2558
  run({ args }) {
2071
2559
  return guard(async () => {
2072
- const rl = createInterface({ input: process.stdin, output: process.stdout });
2073
- const ask = (prompt) => rl.question(`${prompt.parameter} (${prompt.environment}): `);
2560
+ const reader = lineReader();
2561
+ const ask = (prompt2) => reader.ask(prompt(prompt2.parameter, contextFor(prompt2)));
2074
2562
  try {
2075
2563
  write(
2076
2564
  renderFill(
2077
2565
  await runFill({
2078
2566
  cwd: process.cwd(),
2079
2567
  ...args.env === void 0 ? {} : { environment: args.env },
2080
- ask
2568
+ ask,
2569
+ envFlags: shorthandCandidates(args, ["env"])
2081
2570
  })
2082
2571
  )
2083
2572
  );
2084
2573
  } finally {
2085
- rl.close();
2574
+ reader.close();
2086
2575
  }
2087
2576
  });
2088
2577
  }
@@ -2094,7 +2583,7 @@ import { isAbsolute, relative, resolve as resolve3 } from "path";
2094
2583
  import {
2095
2584
  checkNameCollisions as checkNameCollisions3,
2096
2585
  effectiveMeta as effectiveMeta2,
2097
- PenvError as PenvError9,
2586
+ PenvError as PenvError10,
2098
2587
  requireValue as requireValue2,
2099
2588
  serializeDotenv,
2100
2589
  variableName as variableName4
@@ -2118,7 +2607,7 @@ function entriesFor(project, environment, allowDecrypt) {
2118
2607
  const winner = resolution.winner;
2119
2608
  if (winner?.file.encrypted === true) {
2120
2609
  if (!allowDecrypt) {
2121
- throw new PenvError9(
2610
+ throw new PenvError10(
2122
2611
  "ENCRYPTED_VALUE_REFUSED",
2123
2612
  `Parameter ${resolution.parameter} for environment ${environment} resolves to the encrypted value file ${PENV_DIR}/${winner.location}, and \`penv generate\` writes plaintext`,
2124
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.`
@@ -2142,12 +2631,12 @@ function entriesFor(project, environment, allowDecrypt) {
2142
2631
  }
2143
2632
  function generateDotenv(options) {
2144
2633
  const project = openProject(options.cwd);
2145
- const environment = targetEnvironment(project, options.environment);
2634
+ const environment = targetEnvironment(project, options.environment, options.envFlags);
2146
2635
  return serializeDotenv(entriesFor(project, environment, options.allowDecrypt === true).entries);
2147
2636
  }
2148
2637
  function runGenerate(options) {
2149
2638
  const project = openProject(options.cwd);
2150
- const environment = targetEnvironment(project, options.environment);
2639
+ const environment = targetEnvironment(project, options.environment, options.envFlags);
2151
2640
  const { entries, decrypted } = entriesFor(project, environment, options.allowDecrypt === true);
2152
2641
  const file = options.out === void 0 ? resolve3(project.root, DEFAULT_OUTPUT) : isAbsolute(options.out) ? options.out : resolve3(options.cwd, options.out);
2153
2642
  writeFileSync(file, serializeDotenv(entries), "utf8");
@@ -2193,7 +2682,8 @@ var generateCommand = defineCommand7({
2193
2682
  cwd,
2194
2683
  ...args.env === void 0 ? {} : { environment: args.env },
2195
2684
  ...args.out === void 0 ? {} : { out: args.out },
2196
- ...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"])
2197
2687
  });
2198
2688
  write(renderGenerate(result2, cwd));
2199
2689
  });
@@ -2201,7 +2691,7 @@ var generateCommand = defineCommand7({
2201
2691
  });
2202
2692
 
2203
2693
  // src/commands/get.ts
2204
- import { PenvError as PenvError10, requireValue as requireValue3, resolveParameter } from "@penvhq/core";
2694
+ import { PenvError as PenvError11, requireValue as requireValue3, resolveParameter } from "@penvhq/core";
2205
2695
  import { defineCommand as defineCommand8 } from "citty";
2206
2696
  async function runGet(options) {
2207
2697
  const project = openProject(options.cwd);
@@ -2211,7 +2701,7 @@ async function runGet(options) {
2211
2701
  const resolution = await resolveParameter(ref, environment, project.provider, keys);
2212
2702
  const value = requireValue3(resolution, environment);
2213
2703
  if (value === void 0) {
2214
- throw new PenvError10(
2704
+ throw new PenvError11(
2215
2705
  "PARAMETER_ABSENT",
2216
2706
  `Parameter ${resolution.parameter} resolves to no value for environment ${environment}`,
2217
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.`
@@ -2251,12 +2741,14 @@ async function runExplain(options) {
2251
2741
  function renderExplain(explanation) {
2252
2742
  const target = explanation.location === void 0 ? "nothing" : `${PENV_DIR}/${explanation.location}`;
2253
2743
  const rows = explanation.candidates.map((candidate) => [
2254
- candidate.location,
2255
- candidate.wins ? "present, wins" : candidate.present ? candidate.skipped ?? "present" : candidate.skipped ?? "absent"
2744
+ candidate.wins ? candidate.location : out.dim(candidate.location),
2745
+ candidate.wins ? out.green("present, wins") : out.dim(
2746
+ candidate.present ? candidate.skipped ?? "present" : candidate.skipped ?? "absent"
2747
+ )
2256
2748
  ]);
2257
2749
  return [
2258
- `${explanation.parameter} resolves to ${target} for environment ${explanation.environment}`,
2259
- ...explanation.undecryptable === void 0 ? [] : [` penv cannot decrypt it: ${explanation.undecryptable}`],
2750
+ `${out.bold(explanation.parameter)} resolves to ${target} for environment ${explanation.environment}`,
2751
+ ...explanation.undecryptable === void 0 ? [] : [` ${out.red(CROSS)} penv cannot decrypt it: ${explanation.undecryptable}`],
2260
2752
  "",
2261
2753
  ...columns(rows).map((line) => ` ${line}`)
2262
2754
  ];
@@ -2296,7 +2788,7 @@ import {
2296
2788
  isReservedToken as isReservedToken3,
2297
2789
  loadConfigFrom as loadConfigFrom2,
2298
2790
  lookupEnvironment,
2299
- PenvError as PenvError12,
2791
+ PenvError as PenvError13,
2300
2792
  parseDotenv,
2301
2793
  ReservedTokenError as ReservedTokenError3,
2302
2794
  refFromVariable,
@@ -2416,7 +2908,7 @@ import {
2416
2908
  DEFAULT_SCHEMA_FILE as DEFAULT_SCHEMA_FILE2,
2417
2909
  isLegalEnvironmentName,
2418
2910
  loadConfigFrom,
2419
- PenvError as PenvError11,
2911
+ PenvError as PenvError12,
2420
2912
  RESERVED_TOKENS,
2421
2913
  schemaFileOf as schemaFileOf2,
2422
2914
  schemaInsideTree,
@@ -2460,7 +2952,7 @@ function suggestEnvironments(root) {
2460
2952
  return [...found].sort();
2461
2953
  }
2462
2954
  function emptyFlag(flag) {
2463
- return new PenvError11(
2955
+ return new PenvError12(
2464
2956
  "INIT_FLAG_EMPTY",
2465
2957
  `\`--${flag}\` was given without a value`,
2466
2958
  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."
@@ -2523,7 +3015,7 @@ function planInit(root, flags = {}) {
2523
3015
  throw emptyFlag("alias");
2524
3016
  }
2525
3017
  if (!ALIAS_NAME.test(alias)) {
2526
- throw new PenvError11(
3018
+ throw new PenvError12(
2527
3019
  "INIT_ALIAS_INVALID",
2528
3020
  `\`${alias}\` is not an alias penv can write`,
2529
3021
  `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.`
@@ -2561,23 +3053,27 @@ function planInit(root, flags = {}) {
2561
3053
  function renderPlan(plan2) {
2562
3054
  const rows = [];
2563
3055
  rows.push([
2564
- " environments",
2565
- plan2.suggestedEnvironments.length === 0 ? "" : `[${plan2.suggestedEnvironments.join(", ")}]`,
2566
- plan2.suggestedEnvironments.length === 0 ? "<- name them, or Enter to leave the whitelist empty" : "<- from your .env files; edit, or Enter to accept"
3056
+ ` ${out.dim("environments")}`,
3057
+ plan2.suggestedEnvironments.length === 0 ? "" : out.cyan(`[${plan2.suggestedEnvironments.join(", ")}]`),
3058
+ out.dim(
3059
+ plan2.suggestedEnvironments.length === 0 ? "\u2190 name them, or Enter to leave the whitelist empty" : "\u2190 from your .env files; edit, or Enter to accept"
3060
+ )
2567
3061
  ]);
2568
3062
  rows.push([
2569
- " schemaFile",
3063
+ ` ${out.dim("schemaFile")}`,
2570
3064
  plan2.decisions.schemaFile,
2571
- plan2.decisions.schemaFile === DEFAULT_SCHEMA_FILE2 ? "" : `(default: ${DEFAULT_SCHEMA_FILE2})`
3065
+ plan2.decisions.schemaFile === DEFAULT_SCHEMA_FILE2 ? "" : out.dim(`(default: ${DEFAULT_SCHEMA_FILE2})`)
2572
3066
  ]);
2573
3067
  for (const prefix of plan2.decisions.publicPrefixes) {
2574
- rows.push([" publicPrefix", prefix, ""]);
3068
+ rows.push([` ${out.dim("publicPrefix")}`, prefix, ""]);
2575
3069
  }
2576
- const headline = plan2.detected === void 0 ? "No framework detected in package.json." : `Detected ${plan2.detected.name}.`;
3070
+ const headline = out.bold(
3071
+ plan2.detected === void 0 ? "No framework detected in package.json." : `Detected ${plan2.detected.name}.`
3072
+ );
2577
3073
  return [headline, "", ...columns(rows), ""];
2578
3074
  }
2579
3075
  function environmentsHint(plan2) {
2580
- return plan2.suggestedEnvironments.length === 0 ? "environments (comma-separated, Enter for none) > " : 'environments (Enter to accept, "none" for an empty whitelist) > ';
3076
+ return plan2.suggestedEnvironments.length === 0 ? prompt("environments", "comma-separated, Enter for none") : prompt("environments", 'Enter to accept, "none" for an empty whitelist');
2581
3077
  }
2582
3078
  async function promptForDecisions(plan2, io) {
2583
3079
  for (const line of renderPlan(plan2)) {
@@ -2592,7 +3088,7 @@ async function promptForDecisions(plan2, io) {
2592
3088
  );
2593
3089
  io.write("");
2594
3090
  }
2595
- const proceed = (await io.ask("Proceed? [Y/n] ")).trim().toLowerCase();
3091
+ const proceed = (await io.ask(prompt("Proceed?", "Y/n"))).trim().toLowerCase();
2596
3092
  if (proceed.length > 0 && proceed !== "y" && proceed !== "yes") {
2597
3093
  return void 0;
2598
3094
  }
@@ -2615,6 +3111,15 @@ ${body}
2615
3111
  // code. Importing it loads configuration and throws (naming the parameter and
2616
3112
  // environment) if anything required is missing or invalid.
2617
3113
  export const env = load(schema);
3114
+
3115
+ // Registers the schema's shape with penv's types (erased at runtime, so
3116
+ // nothing cycles). This is what makes \`override\` keys in penv.config.ts
3117
+ // autocomplete from this schema \u2014 a typo'd parameter id is a compile error.
3118
+ declare module "@penvhq/core" {
3119
+ interface PenvSchemaShape {
3120
+ readonly shape: z.infer<typeof schema>;
3121
+ }
3122
+ }
2618
3123
  `;
2619
3124
  }
2620
3125
  function renderEnvironments(decisions) {
@@ -2624,14 +3129,14 @@ function renderEnvironments(decisions) {
2624
3129
  // can read off your codebase, and an environment you do not have is worse
2625
3130
  // than one you have not declared yet. Name yours, and give each a provider:
2626
3131
  // environments: ["development", "production"],
2627
- // providers: { development: { type: "filesystem" }, production: { type: "filesystem" } },
3132
+ // providers: { development: { type: "@penvhq/provider-filesystem" }, production: { type: "@penvhq/provider-filesystem" } },
2628
3133
  environments: [],
2629
3134
 
2630
3135
  providers: {},
2631
3136
  `;
2632
3137
  }
2633
3138
  const names = decisions.environments.map((name) => JSON.stringify(name)).join(", ");
2634
- const providers = decisions.environments.map((name) => ` ${JSON.stringify(name)}: { type: "filesystem" },
3139
+ const providers = decisions.environments.map((name) => ` ${JSON.stringify(name)}: { type: "@penvhq/provider-filesystem" },
2635
3140
  `).join("");
2636
3141
  return `${shared} environments: [${names}],
2637
3142
 
@@ -2810,7 +3315,7 @@ ${objectIndent}${source.slice(close)}`;
2810
3315
  ${entryIndent}${member},${source.slice(open + 1)}`;
2811
3316
  }
2812
3317
  function shapeError(what, target, alias) {
2813
- return new PenvError11(
3318
+ return new PenvError12(
2814
3319
  "TSCONFIG_SHAPE",
2815
3320
  `penv cannot add the \`${alias}\` path alias to tsconfig.json: ${what}`,
2816
3321
  `Add it by hand: \`{ "compilerOptions": { "paths": { "${alias}": ["${target}"] } } }\`.`
@@ -2997,7 +3502,8 @@ function renderInit(result2) {
2997
3502
  return [
2998
3503
  ...formatSteps(steps),
2999
3504
  "",
3000
- `Done. Declare your parameters in ${result2.decisions.schemaFile}, then \`penv set <key>\`.`,
3505
+ `${out.green(CHECK)} ${out.bold("Done.")} Declare your parameters in ${result2.decisions.schemaFile}, then:`,
3506
+ tip(out.cyan("penv set <key>")),
3001
3507
  ...result2.decisions.environments.length === 0 ? [
3002
3508
  `Then declare your environments in ${CONFIG_FILE}: penv leaves the whitelist empty rather than inventing one, and every command needs it.`
3003
3509
  ] : []
@@ -3089,7 +3595,7 @@ function assertImportable(ref, variable) {
3089
3595
  if (!ref.name.includes(".")) {
3090
3596
  return;
3091
3597
  }
3092
- throw new PenvError12(
3598
+ throw new PenvError13(
3093
3599
  "IMPORT_UNPARSEABLE_NAME",
3094
3600
  `The variable ${variable} becomes the parameter \`${ref.name}\`, whose \`.\` would be read as a scope`,
3095
3601
  `Filenames are split on \`.\`. Rename ${variable} in the source file, then import it again.`
@@ -3108,10 +3614,10 @@ function assertRoundTrips(ref, variable, config) {
3108
3614
  return;
3109
3615
  }
3110
3616
  const generated = variableName5(ref, config);
3111
- throw new PenvError12(
3617
+ throw new PenvError13(
3112
3618
  "IMPORT_LOSSY_NAME",
3113
3619
  `The variable ${variable} becomes the parameter \`${ref.name}\`, which regenerates as ${generated}`,
3114
- `\`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.`
3620
+ `\`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.`
3115
3621
  );
3116
3622
  }
3117
3623
  function collisionsIn(refs, config) {
@@ -3192,7 +3698,7 @@ function explicitEnvironment(options, source, config) {
3192
3698
  if (value.length > 0) {
3193
3699
  return value;
3194
3700
  }
3195
- throw new PenvError12(
3701
+ throw new PenvError13(
3196
3702
  "IMPORT_ENV_FLAG_EMPTY",
3197
3703
  `\`--env\` for the import of ${source} names no environment`,
3198
3704
  `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.`
@@ -3202,7 +3708,7 @@ function assertEnvironmentAgrees(derived, explicit, source) {
3202
3708
  if (derived === void 0 || explicit === void 0 || derived === explicit) {
3203
3709
  return;
3204
3710
  }
3205
- throw new PenvError12(
3711
+ throw new PenvError13(
3206
3712
  "IMPORT_ENV_CONFLICT",
3207
3713
  `The file ${source} is scoped to environment ${derived}, but \`--env ${explicit}\` names ${explicit}`,
3208
3714
  `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.`
@@ -3246,7 +3752,7 @@ function importDotenv(options) {
3246
3752
  const cwd = resolve5(options.cwd);
3247
3753
  const file = isAbsolute2(options.file) ? options.file : resolve5(cwd, options.file);
3248
3754
  if (!existsSync3(file)) {
3249
- throw new PenvError12(
3755
+ throw new PenvError13(
3250
3756
  "IMPORT_FILE_MISSING",
3251
3757
  `There is no file at ${file} to import`,
3252
3758
  "Point `penv import` at an existing dotenv file, e.g. `penv import .env`."
@@ -3391,7 +3897,7 @@ var importCommand = defineCommand10({
3391
3897
 
3392
3898
  // src/commands/key.ts
3393
3899
  import { randomBytes } from "crypto";
3394
- import { KEY_BYTES, KEYCHAIN_SERVICE, PenvError as PenvError13 } from "@penvhq/core";
3900
+ import { KEY_BYTES, KEYCHAIN_SERVICE, PenvError as PenvError14 } from "@penvhq/core";
3395
3901
  import { defineCommand as defineCommand11 } from "citty";
3396
3902
 
3397
3903
  // src/keychain.ts
@@ -3424,7 +3930,7 @@ function runKeyCreate(options) {
3424
3930
  const environment = targetEnvironment(project, options.environment);
3425
3931
  const declared = project.config.keys?.[environment];
3426
3932
  if (declared === void 0) {
3427
- throw new PenvError13(
3933
+ throw new PenvError14(
3428
3934
  "KEY_SOURCE_UNDECLARED",
3429
3935
  `Environment ${environment} declares no key source, so penv does not know what a key for it would be`,
3430
3936
  `Add a \`keys\` entry to penv.config.ts \u2014 e.g. \`keys: { ${environment}: { source: "env", id: "${environment}" } }\` \u2014 then run this again.`
@@ -3438,14 +3944,14 @@ function runKeyCreate(options) {
3438
3944
  try {
3439
3945
  existing = keychain.getPassword(KEYCHAIN_SERVICE, declared.id);
3440
3946
  } catch (cause) {
3441
- throw new PenvError13(
3947
+ throw new PenvError14(
3442
3948
  "KEYCHAIN_UNAVAILABLE",
3443
3949
  `penv could not read your OS keychain to check for an existing key \`${declared.id}\``,
3444
3950
  `Unlock your keychain and run this again. Original error: ${cause instanceof Error ? cause.message : String(cause)}`
3445
3951
  );
3446
3952
  }
3447
3953
  if (existing !== null) {
3448
- throw new PenvError13(
3954
+ throw new PenvError14(
3449
3955
  "KEY_EXISTS",
3450
3956
  `Environment ${environment} already has a key \`${declared.id}\` in your OS keychain`,
3451
3957
  "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."
@@ -3466,20 +3972,28 @@ function runKeyCreate(options) {
3466
3972
  function renderKeyCreate(result2) {
3467
3973
  if (result2.source === "keychain") {
3468
3974
  return [
3469
- `A new key for environment ${result2.environment}, stored in your OS keychain as \`${result2.id}\`.`,
3975
+ `${out.green(CHECK)} A new key for environment ${result2.environment}, stored in your OS keychain as \`${result2.id}\`.`,
3470
3976
  "",
3471
- "penv kept no copy. Anything sealed under it is unreadable without your keychain, and running",
3472
- "`penv key create` again would replace it \u2014 so it lives in exactly one place, on this machine."
3977
+ out.dim(
3978
+ "penv kept no copy. Anything sealed under it is unreadable without your keychain, and running"
3979
+ ),
3980
+ out.dim(
3981
+ "`penv key create` again would replace it \u2014 so it lives in exactly one place, on this machine."
3982
+ )
3473
3983
  ];
3474
3984
  }
3475
3985
  return [
3476
- `A new key for environment ${result2.environment}. penv did not store it.`,
3986
+ `${out.green(CHECK)} A new key for environment ${result2.environment}. penv did not store it.`,
3477
3987
  "",
3478
- ` ${result2.variable}=${result2.key}`,
3988
+ ` ${out.cyan(`${result2.variable}=${result2.key}`)}`,
3479
3989
  "",
3480
- "Export it where penv runs, and put it wherever this environment's secrets already live \u2014",
3481
- "a KMS, your CI's secret store, a password manager. Anything sealed under it is unreadable",
3482
- "without it, and penv keeps no copy to fall back on."
3990
+ out.dim(
3991
+ "Export it where penv runs, and put it wherever this environment's secrets already live \u2014"
3992
+ ),
3993
+ out.dim(
3994
+ "a KMS, your CI's secret store, a password manager. Anything sealed under it is unreadable"
3995
+ ),
3996
+ out.dim("without it, and penv keeps no copy to fall back on.")
3483
3997
  ];
3484
3998
  }
3485
3999
  var keyCommand = defineCommand11({
@@ -3547,11 +4061,31 @@ async function runList(options) {
3547
4061
  }
3548
4062
  return { environment, parameters };
3549
4063
  }
4064
+ function paintScope(entry) {
4065
+ if (entry.scope === "absent" || entry.viaUnscopedFallback) {
4066
+ return out.yellow(entry.scope);
4067
+ }
4068
+ return out.green(entry.scope);
4069
+ }
3550
4070
  function renderList(result2) {
3551
4071
  if (result2.parameters.length === 0) {
3552
- return [`No parameters in ${PENV_DIR}/ for environment ${result2.environment}.`];
4072
+ return [
4073
+ `No parameters in ${PENV_DIR}/ for environment ${result2.environment}.`,
4074
+ tip(`penv set <key> --env ${result2.environment}`)
4075
+ ];
3553
4076
  }
3554
- return columns(result2.parameters.map((entry) => [entry.parameter, entry.scope, entry.variable]));
4077
+ const header = ["parameter", "scope", "variable", ""].map((cell) => out.dim(cell));
4078
+ const rows = result2.parameters.map((entry) => [
4079
+ entry.parameter,
4080
+ paintScope(entry),
4081
+ entry.variable,
4082
+ entry.encrypted ? out.dim("encrypted") : ""
4083
+ ]);
4084
+ return [
4085
+ heading("penv list", `environment ${result2.environment}`),
4086
+ "",
4087
+ ...columns([header, ...rows])
4088
+ ];
3555
4089
  }
3556
4090
  var listCommand = defineCommand12({
3557
4091
  meta: { name: "list", description: "List parameters" },
@@ -3576,7 +4110,7 @@ import {
3576
4110
  formatMetaFile,
3577
4111
  formatValueFile as formatValueFile5,
3578
4112
  openValue as openValue4,
3579
- PenvError as PenvError14,
4113
+ PenvError as PenvError15,
3580
4114
  parameterId as parameterId5,
3581
4115
  sealValue as sealValue3
3582
4116
  } from "@penvhq/core";
@@ -3595,7 +4129,7 @@ async function planFile(project, source, target, parameter) {
3595
4129
  }
3596
4130
  const environment = environmentOf2(source);
3597
4131
  if (environment === void 0) {
3598
- throw new PenvError14(
4132
+ throw new PenvError15(
3599
4133
  "SECRET_SCOPE_AMBIGUOUS",
3600
4134
  `${PENV_DIR}/${formatValueFile5(source)} is encrypted at a scope that names no environment, so penv cannot tell which key would re-seal it`,
3601
4135
  "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."
@@ -3604,7 +4138,7 @@ async function planFile(project, source, target, parameter) {
3604
4138
  const keys = keySourceFor(project, environment);
3605
4139
  const opened = openValue4(source, stored, keys);
3606
4140
  if (opened.kind === "failed") {
3607
- throw new PenvError14(
4141
+ throw new PenvError15(
3608
4142
  "VALUE_UNDECRYPTABLE",
3609
4143
  `${PENV_DIR}/${formatValueFile5(source)} could not be decrypted, so penv cannot re-seal it at its new address: ${opened.failure.detail}`,
3610
4144
  "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."
@@ -3627,7 +4161,7 @@ async function runMove(options) {
3627
4161
  assertWritableKey(options.to);
3628
4162
  const to = refFromKey(options.to, project.config);
3629
4163
  if (parameterId5(from) === parameterId5(to)) {
3630
- throw new PenvError14(
4164
+ throw new PenvError15(
3631
4165
  "PARAMETER_UNCHANGED",
3632
4166
  `\`${options.from}\` and \`${options.to}\` are the same parameter`,
3633
4167
  "Name a different destination, e.g. `penv mv redis-password redis/password`."
@@ -3637,7 +4171,7 @@ async function runMove(options) {
3637
4171
  const sources = filesOf(all, from);
3638
4172
  const meta = await project.provider.readMeta(from);
3639
4173
  if (sources.length === 0 && meta === void 0) {
3640
- throw new PenvError14(
4174
+ throw new PenvError15(
3641
4175
  "PARAMETER_ABSENT",
3642
4176
  `Parameter ${parameterId5(from)} has no value files and no meta, so there is nothing to move`,
3643
4177
  `\`penv list\` shows every parameter penv holds.`
@@ -3645,7 +4179,7 @@ async function runMove(options) {
3645
4179
  }
3646
4180
  const occupied2 = filesOf(all, to);
3647
4181
  if (occupied2.length > 0 || await project.provider.readMeta(to) !== void 0) {
3648
- throw new PenvError14(
4182
+ throw new PenvError15(
3649
4183
  "PARAMETER_EXISTS",
3650
4184
  `Parameter ${parameterId5(to)} already exists, and penv will not merge two parameters into one`,
3651
4185
  `Remove or rename ${parameterId5(to)} first. \`penv get ${options.to} --explain\` shows every file it holds.`
@@ -3699,8 +4233,9 @@ function renderMove(result2) {
3699
4233
  const lines = formatRows(rows);
3700
4234
  lines.push(
3701
4235
  "",
3702
- ` .penv/env.ts still declares \`${result2.schema.was}\`. Rename it to \`${result2.schema.now}\`,`,
3703
- " or `penv validate` will report the value as unused and the declaration as unset."
4236
+ tip(
4237
+ `.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.`
4238
+ )
3704
4239
  );
3705
4240
  return lines;
3706
4241
  }
@@ -3726,14 +4261,45 @@ var mvCommand = defineCommand13({
3726
4261
  });
3727
4262
 
3728
4263
  // src/commands/pull.ts
4264
+ import { holdsProjection as holdsProjection4, refFromVariable as refFromVariable2 } from "@penvhq/core";
3729
4265
  import { defineCommand as defineCommand14 } from "citty";
4266
+ async function pullProjection(project, source, environment) {
4267
+ const tree = localTree(project);
4268
+ const names = /* @__PURE__ */ new Set();
4269
+ for (const secret of await source.list({ kind: "repository" })) {
4270
+ names.add(secret.name);
4271
+ }
4272
+ for (const secret of await source.list({ kind: "environment", environment })) {
4273
+ names.add(secret.name);
4274
+ }
4275
+ let meta = 0;
4276
+ for (const name of [...names].sort()) {
4277
+ const ref = refFromVariable2(name);
4278
+ if (tree.readMetaSync(ref) === void 0) {
4279
+ tree.writeMetaSync(ref, {});
4280
+ meta += 1;
4281
+ }
4282
+ }
4283
+ return {
4284
+ environment,
4285
+ source: source.type,
4286
+ localSource: false,
4287
+ values: 0,
4288
+ meta,
4289
+ refs: names.size,
4290
+ valuesUnreadable: true
4291
+ };
4292
+ }
3730
4293
  async function runPull(options) {
3731
4294
  const project = openProject(options.cwd);
3732
- const environment = targetEnvironment(project, options.environment);
3733
- const source = await sourceProviderFor(project, environment);
4295
+ const environment = targetEnvironment(project, options.environment, options.envFlags);
4296
+ const source = options.source ?? await sourceProviderFor(project, environment);
3734
4297
  if (source.type === LOCAL_TREE_TYPE) {
3735
4298
  return { environment, source: source.type, localSource: true, values: 0, meta: 0, refs: 0 };
3736
4299
  }
4300
+ if (holdsProjection4(source)) {
4301
+ return pullProjection(project, source, environment);
4302
+ }
3737
4303
  const tree = localTree(project);
3738
4304
  const files = await source.list();
3739
4305
  let values = 0;
@@ -3768,6 +4334,22 @@ function renderPull(result2) {
3768
4334
  }
3769
4335
  ]);
3770
4336
  }
4337
+ if (result2.valuesUnreadable === true) {
4338
+ return formatRows([
4339
+ {
4340
+ glyph: CHECK,
4341
+ label: "Pulled",
4342
+ subject: `${result2.refs} ${result2.refs === 1 ? "parameter name" : "parameter names"}`,
4343
+ detail: `from ${result2.source} for environment ${result2.environment}`
4344
+ },
4345
+ {
4346
+ glyph: WARN,
4347
+ label: "Values not readable",
4348
+ subject: "this destination never returns a secret's value",
4349
+ detail: "fill them locally with `penv set` or `penv fill` \u2014 `penv validate` names every gap"
4350
+ }
4351
+ ]);
4352
+ }
3771
4353
  return formatRows([
3772
4354
  {
3773
4355
  glyph: CHECK,
@@ -3789,13 +4371,17 @@ var pullCommand = defineCommand14({
3789
4371
  description: "Materialise the local .penv tree from an environment's source-of-truth provider"
3790
4372
  },
3791
4373
  args: {
3792
- env: { type: "string", description: "The environment to pull" }
4374
+ env: {
4375
+ type: "string",
4376
+ description: "The environment to pull (or pass it as a bare flag: --production)"
4377
+ }
3793
4378
  },
3794
4379
  run({ args }) {
3795
4380
  return guard(async () => {
3796
4381
  const result2 = await runPull({
3797
4382
  cwd: process.cwd(),
3798
- ...args.env === void 0 ? {} : { environment: args.env }
4383
+ ...args.env === void 0 ? {} : { environment: args.env },
4384
+ envFlags: shorthandCandidates(args, ["env"])
3799
4385
  });
3800
4386
  write(renderPull(result2));
3801
4387
  });
@@ -3878,7 +4464,8 @@ var removeCommand = defineCommand15({
3878
4464
  import {
3879
4465
  beginRotation,
3880
4466
  completeRotation,
3881
- PenvError as PenvError15,
4467
+ holdsRecords as holdsRecords2,
4468
+ PenvError as PenvError16,
3882
4469
  retainsPrevious,
3883
4470
  rotationOf as rotationOf2
3884
4471
  } from "@penvhq/core";
@@ -3907,7 +4494,7 @@ async function writeRotatedValue(project, provider, ref, environment, value) {
3907
4494
  }
3908
4495
  function requireNewValue(value, phase, key) {
3909
4496
  if (value === void 0) {
3910
- throw new PenvError15(
4497
+ throw new PenvError16(
3911
4498
  "ROTATION_NO_VALUE",
3912
4499
  `A ${phase} rotation of ${key} writes a new value, and none was given`,
3913
4500
  "Pass the new value as the argument \u2014 `penv rotate <key> <value>` \u2014 or pipe it in on stdin."
@@ -3917,7 +4504,7 @@ function requireNewValue(value, phase, key) {
3917
4504
  }
3918
4505
  function requireRetaining(provider, environment) {
3919
4506
  if (!retainsPrevious(provider)) {
3920
- throw new PenvError15(
4507
+ throw new PenvError16(
3921
4508
  "ROTATION_NOT_RETAINING",
3922
4509
  `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`,
3923
4510
  "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."
@@ -3929,12 +4516,20 @@ async function runRotate(options) {
3929
4516
  const project = openProject(options.cwd);
3930
4517
  const environment = targetEnvironment(project, options.environment);
3931
4518
  const ref = refFromKey(options.key, project.config);
3932
- const provider = await sourceProviderFor(project, environment);
4519
+ const source = await sourceProviderFor(project, environment);
4520
+ if (!holdsRecords2(source)) {
4521
+ throw new PenvError16(
4522
+ "ROTATION_NOT_RECORDS",
4523
+ `Environment ${environment} is backed by \`${source.type}\`, which holds a resolved projection penv cannot rotate in place`,
4524
+ "Rotate the parameter in the environment that holds the records (the local tree, Vault, SSM), then `penv push` the result to this destination."
4525
+ );
4526
+ }
4527
+ const provider = source;
3933
4528
  const nowIso = options.now ?? (/* @__PURE__ */ new Date()).toISOString();
3934
4529
  const before = await provider.readMeta(ref);
3935
4530
  const { mechanism } = rotationOf2(before, environment);
3936
4531
  if (mechanism === void 0) {
3937
- throw new PenvError15(
4532
+ throw new PenvError16(
3938
4533
  "ROTATION_NO_MECHANISM",
3939
4534
  `Parameter ${options.key} declares no rotation mechanism for environment ${environment}, so penv does not know how to rotate it`,
3940
4535
  '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.'
@@ -3944,7 +4539,7 @@ async function runRotate(options) {
3944
4539
  const complete = options.complete === true;
3945
4540
  if (mechanism === "atomic-cutover") {
3946
4541
  if (begin || complete) {
3947
- throw new PenvError15(
4542
+ throw new PenvError16(
3948
4543
  "ROTATION_MECHANISM_MISMATCH",
3949
4544
  `Parameter ${options.key} is atomic-cutover, which flips in one step, so \`--begin\`/\`--complete\` do not apply`,
3950
4545
  "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."
@@ -3958,7 +4553,7 @@ async function runRotate(options) {
3958
4553
  }
3959
4554
  const retaining = requireRetaining(provider, environment);
3960
4555
  if (begin === complete) {
3961
- throw new PenvError15(
4556
+ throw new PenvError16(
3962
4557
  "ROTATION_PHASE_REQUIRED",
3963
4558
  `A dual-valid rotation of ${options.key} needs exactly one of \`--begin\` or \`--complete\``,
3964
4559
  "`--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."
@@ -4235,9 +4830,9 @@ function renderDrift(drift, environment) {
4235
4830
  if (rows.length === 0) {
4236
4831
  return [];
4237
4832
  }
4238
- const lines = ["", `Schema and tree differ for ${environment}:`, ...formatRows(rows)];
4833
+ const lines = ["", out.bold(`Schema and tree differ for ${environment}:`), ...formatRows(rows)];
4239
4834
  for (const remedy of new Set(drift.declared.map((item) => item.remedy))) {
4240
- lines.push(` ${remedy}`);
4835
+ lines.push(tip(remedy));
4241
4836
  }
4242
4837
  return lines;
4243
4838
  }
@@ -4268,7 +4863,7 @@ var watchCommand = defineCommand17({
4268
4863
  process.exitCode = previous;
4269
4864
  }
4270
4865
  });
4271
- write(["Watching .penv/ and penv.config.ts. Ctrl-C to stop."]);
4866
+ write([`Watching .penv/ and penv.config.ts. ${out.dim("Ctrl-C to stop.")}`]);
4272
4867
  await new Promise((resolve7) => {
4273
4868
  process.once("SIGINT", () => {
4274
4869
  handle.close();