@penvhq/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs ADDED
@@ -0,0 +1,3515 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ LAST_PUSHED_KEY: () => LAST_PUSHED_KEY,
24
+ generateDotenv: () => generateDotenv,
25
+ importDotenv: () => importDotenv,
26
+ insertEnvAlias: () => insertEnvAlias,
27
+ main: () => main,
28
+ renderDoctor: () => renderDoctor,
29
+ renderMove: () => renderMove,
30
+ renderPush: () => renderPush,
31
+ renderWatch: () => renderWatch,
32
+ runDecrypt: () => runDecrypt,
33
+ runDoctor: () => runDoctor,
34
+ runEncrypt: () => runEncrypt,
35
+ runExplain: () => runExplain,
36
+ runGenerate: () => runGenerate,
37
+ runGet: () => runGet,
38
+ runInit: () => runInit,
39
+ runList: () => runList,
40
+ runMain: () => runMain,
41
+ runMove: () => runMove,
42
+ runPush: () => runPush,
43
+ runRemove: () => runRemove,
44
+ runSet: () => runSet,
45
+ runValidate: () => runValidate,
46
+ runWatch: () => runWatch
47
+ });
48
+ module.exports = __toCommonJS(index_exports);
49
+ var import_core20 = require("@penvhq/core");
50
+ var import_citty15 = require("citty");
51
+
52
+ // src/commands/doctor.ts
53
+ var import_core7 = require("@penvhq/core");
54
+ var import_sink_github2 = require("@penvhq/sink-github");
55
+ var import_citty3 = require("citty");
56
+
57
+ // src/project.ts
58
+ var import_node_path = require("path");
59
+ var import_core2 = require("@penvhq/core");
60
+ var import_provider_filesystem2 = require("@penvhq/provider-filesystem");
61
+
62
+ // src/registry.ts
63
+ var import_core = require("@penvhq/core");
64
+ var import_provider_filesystem = require("@penvhq/provider-filesystem");
65
+ var LOCAL_TREE_TYPE = "filesystem";
66
+ var REGISTRY = /* @__PURE__ */ new Map([
67
+ [LOCAL_TREE_TYPE, ({ root, config }) => (0, import_provider_filesystem.createFilesystemProvider)({ root, config })]
68
+ ]);
69
+ function isProviderRegistered(type) {
70
+ return REGISTRY.has(type);
71
+ }
72
+ function createProvider(type, context) {
73
+ const factory = REGISTRY.get(type);
74
+ if (factory === void 0) {
75
+ throw unknownProvider(type);
76
+ }
77
+ return factory(context);
78
+ }
79
+ function assertProvidersRegistered(config) {
80
+ for (const [environment, provider] of Object.entries(config.providers)) {
81
+ if (!isProviderRegistered(provider.type)) {
82
+ throw unknownProvider(provider.type, environment);
83
+ }
84
+ }
85
+ }
86
+ function unknownProvider(type, environment) {
87
+ const known = [...REGISTRY.keys()].map((name) => `\`${name}\``).join(", ");
88
+ const where = environment === void 0 ? "" : ` for environment ${environment}`;
89
+ return new import_core.PenvError(
90
+ "UNKNOWN_PROVIDER",
91
+ `The provider type \`${type}\`${where} in penv.config.ts is not one this penv build carries`,
92
+ `This build registers ${known}. Name a registered provider, or install the build that carries \`${type}\`.`
93
+ );
94
+ }
95
+
96
+ // src/project.ts
97
+ var PENV_DIR = ".penv";
98
+ function openProject(cwd) {
99
+ const { config, file } = (0, import_core2.loadConfig)(cwd);
100
+ const root = (0, import_node_path.dirname)(file);
101
+ const penvDir = (0, import_node_path.resolve)(root, PENV_DIR);
102
+ assertProvidersRegistered(config);
103
+ return {
104
+ root,
105
+ configFile: file,
106
+ config,
107
+ penvDir,
108
+ provider: createProvider(LOCAL_TREE_TYPE, { root: penvDir, config })
109
+ };
110
+ }
111
+ function localTree(project) {
112
+ if (!(project.provider instanceof import_provider_filesystem2.FilesystemProvider)) {
113
+ throw new import_core2.PenvError(
114
+ "PROVIDER_NOT_LOCAL",
115
+ `This command reads the local .penv tree synchronously, which the \`${project.provider.type}\` provider is not`,
116
+ "Run this against a filesystem-backed project, or use a command that speaks the async provider contract."
117
+ );
118
+ }
119
+ return project.provider;
120
+ }
121
+ function targetEnvironment(project, explicit) {
122
+ return (0, import_core2.resolveEnvironment)(project.config, explicit);
123
+ }
124
+ var KEY_SEPARATOR = /[./\\]/;
125
+ var NO_ENVIRONMENTS = { environments: [], providers: {} };
126
+ function refFromKey(key, config) {
127
+ const segments = key.split(KEY_SEPARATOR).filter((segment) => segment.length > 0);
128
+ const name = segments[segments.length - 1];
129
+ if (name === void 0) {
130
+ throw new import_core2.PenvError(
131
+ "PARAMETER_KEY",
132
+ `\`${key}\` names no parameter`,
133
+ "A key is `<namespace>/<name>` or `<namespace>.<name>`, e.g. `redis/password`."
134
+ );
135
+ }
136
+ if ((0, import_core2.isReservedToken)(name, config ?? NO_ENVIRONMENTS)) {
137
+ throw new import_core2.ReservedTokenError("parameter", name, key);
138
+ }
139
+ return { namespace: segments.slice(0, -1), name };
140
+ }
141
+ function keySourceFor(project, environment) {
142
+ return (0, import_core2.resolveKeySource)(project.config, environment);
143
+ }
144
+ function resolveSync(provider, ref, environment, keys, skipPersonal) {
145
+ for (const file of (0, import_core2.candidatesFor)(ref, environment, skipPersonal)) {
146
+ const read = provider.readSync(file);
147
+ if (read === void 0) {
148
+ continue;
149
+ }
150
+ const opened = (0, import_core2.openValue)(file, read, keys);
151
+ return {
152
+ ref,
153
+ parameter: (0, import_core2.parameterId)(ref),
154
+ value: opened.kind === "plaintext" ? opened.value : void 0,
155
+ ...opened.kind === "failed" ? { undecryptable: opened.failure } : {},
156
+ winner: { file, location: (0, import_core2.formatValueFile)(file), present: true }
157
+ };
158
+ }
159
+ return { ref, parameter: (0, import_core2.parameterId)(ref), value: void 0, winner: void 0 };
160
+ }
161
+ function resolveAllSync(provider, environment, keys, skipPersonal) {
162
+ return refsFrom(provider.listSync()).map(
163
+ (ref) => resolveSync(provider, ref, environment, keys, skipPersonal)
164
+ );
165
+ }
166
+ function refsFrom(files) {
167
+ const refs = /* @__PURE__ */ new Map();
168
+ for (const file of files) {
169
+ const ref = { namespace: file.namespace, name: file.name };
170
+ const id = (0, import_core2.parameterId)(ref);
171
+ if (!refs.has(id)) {
172
+ refs.set(id, ref);
173
+ }
174
+ }
175
+ return [...refs.values()].sort((a, b) => {
176
+ const left = (0, import_core2.parameterId)(a);
177
+ const right = (0, import_core2.parameterId)(b);
178
+ return left < right ? -1 : left > right ? 1 : 0;
179
+ });
180
+ }
181
+
182
+ // src/schema.ts
183
+ var import_core3 = require("@penvhq/core");
184
+ function defOf(node) {
185
+ if (typeof node !== "object" || node === null) {
186
+ return void 0;
187
+ }
188
+ const def = node.def;
189
+ return typeof def === "object" && def !== null ? def : void 0;
190
+ }
191
+ function typeOf(node) {
192
+ const type = defOf(node)?.type;
193
+ return typeof type === "string" ? type : void 0;
194
+ }
195
+ function unwrap(node) {
196
+ let current = node;
197
+ for (let depth = 0; depth < 8; depth += 1) {
198
+ const inner = defOf(current)?.innerType;
199
+ if (inner === void 0) {
200
+ return current;
201
+ }
202
+ current = inner;
203
+ }
204
+ return current;
205
+ }
206
+ function shapeOf(node) {
207
+ if (typeOf(node) !== "object") {
208
+ return void 0;
209
+ }
210
+ const shape = node.shape;
211
+ return typeof shape === "object" && shape !== null ? shape : void 0;
212
+ }
213
+ function lookup(root, path) {
214
+ let node = unwrap(root);
215
+ for (const key of path) {
216
+ const shape = shapeOf(node);
217
+ if (shape === void 0) {
218
+ return { kind: "unknown" };
219
+ }
220
+ if (!Object.hasOwn(shape, key)) {
221
+ return { kind: "absent" };
222
+ }
223
+ node = unwrap(shape[key]);
224
+ }
225
+ return { kind: "found", node };
226
+ }
227
+ function minLengthOf(node) {
228
+ if (typeOf(node) !== "string") {
229
+ return void 0;
230
+ }
231
+ const min = node.minLength;
232
+ return typeof min === "number" ? min : void 0;
233
+ }
234
+ var ABSENCE_PERMITTED = /* @__PURE__ */ new Set(["optional", "default", "catch", "prefault"]);
235
+ var ABSENCE_REFUSED = /* @__PURE__ */ new Set(["nullable", "nonoptional", "readonly"]);
236
+ function permitsAbsence(node) {
237
+ let current = node;
238
+ for (let depth = 0; depth < 8; depth += 1) {
239
+ const type = typeOf(current);
240
+ if (type !== void 0 && ABSENCE_PERMITTED.has(type)) {
241
+ return true;
242
+ }
243
+ const inner = defOf(current)?.innerType;
244
+ if (inner === void 0) {
245
+ return type === void 0 ? void 0 : false;
246
+ }
247
+ if (type === void 0 || !ABSENCE_REFUSED.has(type)) {
248
+ return void 0;
249
+ }
250
+ current = inner;
251
+ }
252
+ return void 0;
253
+ }
254
+ function leaves(node, path, inherited, out) {
255
+ const own = permitsAbsence(node);
256
+ const absencePermitted = inherited === true || own === true ? true : combine(inherited, own);
257
+ const shape = shapeOf(unwrap(node));
258
+ if (shape === void 0) {
259
+ if (path.length > 0) {
260
+ out.push({ path, absencePermitted });
261
+ }
262
+ return;
263
+ }
264
+ for (const key of Object.keys(shape)) {
265
+ leaves(shape[key], [...path, key], absencePermitted, out);
266
+ }
267
+ }
268
+ function combine(left, right) {
269
+ return left === void 0 || right === void 0 ? void 0 : false;
270
+ }
271
+ function declaredLeaves(schema) {
272
+ const out = [];
273
+ leaves(schema, [], false, out);
274
+ return out;
275
+ }
276
+ var EMPTY_DRIFT = { declared: [], undeclared: [] };
277
+ function hasValue(resolution) {
278
+ return resolution.winner !== void 0;
279
+ }
280
+ function computeDrift(input) {
281
+ const { schema, resolutions, config, environment } = input;
282
+ const valued = new Set(resolutions.filter(hasValue).map((resolution) => resolution.parameter));
283
+ const declared = [];
284
+ for (const leaf of declaredLeaves(schema)) {
285
+ if (leaf.absencePermitted !== false) {
286
+ continue;
287
+ }
288
+ const path = leaf.path.join(".");
289
+ const ref = (0, import_core3.refFromAccessPath)(leaf.path);
290
+ if (ref === void 0 || (0, import_core3.isReservedToken)(ref.name, config)) {
291
+ declared.push({
292
+ subject: path,
293
+ 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.`,
294
+ detail: "declared, no filename reaches it"
295
+ });
296
+ continue;
297
+ }
298
+ if (valued.has((0, import_core3.parameterId)(ref))) {
299
+ continue;
300
+ }
301
+ declared.push({
302
+ subject: (0, import_core3.parameterId)(ref),
303
+ ref,
304
+ remedy: `penv set ${[...ref.namespace, ref.name].join("/")} --env ${environment}`,
305
+ detail: `declared in .penv/env.ts, no value for ${environment}`
306
+ });
307
+ }
308
+ const undeclared = [];
309
+ for (const resolution of resolutions) {
310
+ if (lookup(schema, (0, import_core3.accessPath)(resolution.ref)).kind !== "absent") {
311
+ continue;
312
+ }
313
+ undeclared.push({
314
+ ref: resolution.ref,
315
+ variable: (0, import_core3.variableName)(resolution.ref, config)
316
+ });
317
+ }
318
+ return { declared, undeclared };
319
+ }
320
+
321
+ // src/ui.ts
322
+ var import_core4 = require("@penvhq/core");
323
+ var CHECK = "\u2713";
324
+ var WARN = "\u26A0";
325
+ var UNKNOWN = "?";
326
+ var NOTE_COLUMN = 29;
327
+ function widest(values) {
328
+ return values.reduce((max, value) => Math.max(max, value.length), 0);
329
+ }
330
+ function formatRows(rows) {
331
+ const labelWidth = widest(rows.map((row) => row.label)) + 2;
332
+ const detailed = rows.filter((row) => row.detail !== void 0);
333
+ const subjectWidth = widest(detailed.map((row) => row.subject ?? "")) + 1;
334
+ return rows.map((row) => {
335
+ const head = `${row.glyph} ${row.label.padEnd(labelWidth)}`;
336
+ if (row.detail === void 0) {
337
+ return `${head}${row.subject ?? ""}`.trimEnd();
338
+ }
339
+ return `${head}${(row.subject ?? "").padEnd(subjectWidth)}${row.detail}`.trimEnd();
340
+ });
341
+ }
342
+ function columns(rows, gap = 2) {
343
+ const count = rows.reduce((max, row) => Math.max(max, row.length), 0);
344
+ const widths = [];
345
+ for (let column = 0; column < count; column += 1) {
346
+ widths.push(widest(rows.map((row) => row[column] ?? "")) + gap);
347
+ }
348
+ return rows.map(
349
+ (row) => row.map((cell, index) => index === row.length - 1 ? cell : cell.padEnd(widths[index] ?? 0)).join("").trimEnd()
350
+ );
351
+ }
352
+ function formatSteps(steps) {
353
+ return steps.map((step) => {
354
+ if (step.note === void 0) {
355
+ return `${step.glyph} ${step.text}`;
356
+ }
357
+ const text = step.text.length >= NOTE_COLUMN ? `${step.text} ` : step.text.padEnd(NOTE_COLUMN);
358
+ return `${step.glyph} ${text}${step.note}`;
359
+ });
360
+ }
361
+ function write(lines) {
362
+ for (const line of lines) {
363
+ process.stdout.write(`${line}
364
+ `);
365
+ }
366
+ }
367
+ function reportError(error) {
368
+ if (error instanceof import_core4.PenvError) {
369
+ process.stderr.write(`${error.message}
370
+ `);
371
+ } else if (error instanceof Error) {
372
+ process.stderr.write(`${error.stack ?? error.message}
373
+ `);
374
+ } else {
375
+ process.stderr.write(`${String(error)}
376
+ `);
377
+ }
378
+ process.exitCode = 1;
379
+ }
380
+ async function guard(run) {
381
+ try {
382
+ await run();
383
+ } catch (error) {
384
+ reportError(error);
385
+ }
386
+ }
387
+
388
+ // src/commands/push.ts
389
+ var import_core5 = require("@penvhq/core");
390
+ var import_sink_github = require("@penvhq/sink-github");
391
+ var import_citty = require("citty");
392
+ var LAST_PUSHED_KEY = "lastPushedAt";
393
+ function sinkFor(project, environment, override) {
394
+ const declared = project.config.sinks?.[environment];
395
+ if (declared === void 0) {
396
+ throw new import_core5.PenvError(
397
+ "NO_SINK",
398
+ `Environment ${environment} declares no sink in penv.config.ts, so penv has nowhere to push`,
399
+ `Add a \`sinks\` entry, e.g. \`sinks: { ${environment}: { type: "github" } }\`, then run \`penv push --env ${environment}\` again.`
400
+ );
401
+ }
402
+ const repo = declared.repo;
403
+ if (override !== void 0) {
404
+ return { sink: override, repo };
405
+ }
406
+ if (declared.type === "github") {
407
+ return { sink: (0, import_sink_github.createGithubSink)(repo === void 0 ? {} : { repo }), repo };
408
+ }
409
+ throw new import_core5.PenvError(
410
+ "UNKNOWN_SINK",
411
+ `Environment ${environment} declares sink type \`${declared.type}\`, which penv does not know`,
412
+ 'The only sink in this release is `github`. Set `type: "github"`.'
413
+ );
414
+ }
415
+ function plan(resolutions, config, environment, allowDecrypt) {
416
+ const outbound = [];
417
+ for (const resolution of resolutions) {
418
+ const winner = resolution.winner;
419
+ if (winner === void 0) {
420
+ continue;
421
+ }
422
+ let encrypted = false;
423
+ if (winner.file.encrypted) {
424
+ if (!allowDecrypt) {
425
+ throw new import_core5.PenvError(
426
+ "ENCRYPTED_VALUE_REFUSED",
427
+ `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`,
428
+ "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."
429
+ );
430
+ }
431
+ (0, import_core5.requireValue)(resolution, environment);
432
+ encrypted = true;
433
+ }
434
+ if (resolution.value === void 0) {
435
+ continue;
436
+ }
437
+ const scope = winner.file.scope.kind === "unscoped" ? { kind: "repository" } : { kind: "environment", environment };
438
+ outbound.push({
439
+ ref: resolution.ref,
440
+ variable: (0, import_core5.variableName)(resolution.ref, config),
441
+ value: resolution.value,
442
+ scope,
443
+ encrypted
444
+ });
445
+ }
446
+ return outbound;
447
+ }
448
+ function withLastPushed(meta, environment, iso) {
449
+ const base = meta ?? {};
450
+ const environments = { ...base.environments ?? {} };
451
+ environments[environment] = { ...environments[environment] ?? {}, [LAST_PUSHED_KEY]: iso };
452
+ return { ...base, environments };
453
+ }
454
+ function recordPush(tree, ref, environment, iso) {
455
+ const meta = withLastPushed(tree.readMetaSync(ref), environment, iso);
456
+ tree.writeMetaSync(ref, meta);
457
+ }
458
+ async function runPush(options) {
459
+ const project = openProject(options.cwd);
460
+ const environment = targetEnvironment(project, options.environment);
461
+ const { sink, repo } = sinkFor(project, environment, options.sink);
462
+ const tree = localTree(project);
463
+ const keys = keySourceFor(project, environment);
464
+ const resolutions = resolveAllSync(tree, environment, keys, true);
465
+ const refs = refsFrom(resolutions.map((resolution) => resolution.ref));
466
+ const collision = (0, import_core5.checkNameCollisions)(refs, project.config)[0];
467
+ if (collision !== void 0) {
468
+ throw collision;
469
+ }
470
+ const nameError = (0, import_sink_github.checkGithubNames)(refs, project.config)[0];
471
+ if (nameError !== void 0) {
472
+ throw nameError;
473
+ }
474
+ const outbound = plan(resolutions, project.config, environment, options.allowDecrypt === true);
475
+ await sink.verify();
476
+ let repositorySecrets = 0;
477
+ let environmentSecrets = 0;
478
+ for (const item of outbound) {
479
+ await sink.push(item.variable, item.value, item.scope);
480
+ if (item.scope.kind === "repository") {
481
+ repositorySecrets += 1;
482
+ } else {
483
+ environmentSecrets += 1;
484
+ }
485
+ recordPush(tree, item.ref, environment, options.now ?? (/* @__PURE__ */ new Date()).toISOString());
486
+ }
487
+ return {
488
+ environment,
489
+ repo,
490
+ pushed: outbound.length,
491
+ repositorySecrets,
492
+ environmentSecrets,
493
+ decrypted: outbound.filter((item) => item.encrypted).length
494
+ };
495
+ }
496
+ function renderPush(result) {
497
+ if (result.pushed === 0) {
498
+ return formatRows([
499
+ {
500
+ glyph: CHECK,
501
+ label: "Nothing to push",
502
+ subject: `no values resolve for environment ${result.environment}`
503
+ }
504
+ ]);
505
+ }
506
+ const target = `GitHub Actions for environment ${result.environment}${result.repo === void 0 ? "" : ` (${result.repo})`}`;
507
+ const rows = [
508
+ {
509
+ glyph: CHECK,
510
+ label: "Pushed",
511
+ subject: `${result.pushed} ${result.pushed === 1 ? "secret" : "secrets"}`,
512
+ detail: `to ${target}`
513
+ },
514
+ {
515
+ glyph: CHECK,
516
+ label: "Scopes",
517
+ subject: `${result.environmentSecrets} environment, ${result.repositorySecrets} repository`,
518
+ detail: "environment secrets override repository secrets, as penv's env scope overrides the default"
519
+ }
520
+ ];
521
+ if (result.decrypted > 0) {
522
+ rows.push({
523
+ glyph: WARN,
524
+ label: "Decrypted",
525
+ subject: `${result.decrypted} ${result.decrypted === 1 ? "secret" : "secrets"}`,
526
+ detail: "sent as plaintext for GitHub to re-seal under its own key"
527
+ });
528
+ }
529
+ return formatRows(rows);
530
+ }
531
+ var pushCommand = (0, import_citty.defineCommand)({
532
+ meta: {
533
+ name: "push",
534
+ description: "Push an environment's resolved values to its sink (GitHub Actions Secrets)"
535
+ },
536
+ args: {
537
+ env: { type: "string", description: "The environment to push" },
538
+ "allow-decrypt": {
539
+ type: "boolean",
540
+ description: "Decrypt sealed values locally and push them as plaintext for GitHub to re-seal"
541
+ }
542
+ },
543
+ run({ args }) {
544
+ return guard(async () => {
545
+ const result = await runPush({
546
+ cwd: process.cwd(),
547
+ ...args.env === void 0 ? {} : { environment: args.env },
548
+ ...args["allow-decrypt"] === void 0 ? {} : { allowDecrypt: args["allow-decrypt"] }
549
+ });
550
+ write(renderPush(result));
551
+ });
552
+ }
553
+ });
554
+
555
+ // src/commands/validate.ts
556
+ var import_node_path2 = require("path");
557
+ var import_node_url = require("url");
558
+ var import_core6 = require("@penvhq/core");
559
+ var import_citty2 = require("citty");
560
+ var import_jiti = require("jiti");
561
+ var SCHEMA_EXPORT = "schema";
562
+ var LABELS = {
563
+ config: "Config",
564
+ reserved: "Reserved token",
565
+ collision: "Name collision",
566
+ schema: "Invalid parameter",
567
+ undecryptable: "Undecryptable value"
568
+ };
569
+ function firstLine(text) {
570
+ return text.split("\n")[0] ?? text;
571
+ }
572
+ function issueFrom(error, fallbackSubject) {
573
+ const base = {
574
+ message: firstLine(error.message),
575
+ ...error.remedy === void 0 ? {} : { remedy: error.remedy }
576
+ };
577
+ if (error instanceof import_core6.ReservedTokenError) {
578
+ return { kind: "reserved", subject: error.token, ...base };
579
+ }
580
+ if (error instanceof import_core6.NameCollisionError) {
581
+ return { kind: "collision", subject: error.variable, ...base };
582
+ }
583
+ return { kind: "config", subject: fallbackSubject, ...base };
584
+ }
585
+ function place(root, path, value) {
586
+ const leaf = path[path.length - 1];
587
+ if (leaf === void 0) {
588
+ return;
589
+ }
590
+ let node = root;
591
+ for (const key of path.slice(0, -1)) {
592
+ const existing = node[key];
593
+ if (typeof existing === "object" && existing !== null) {
594
+ node = existing;
595
+ continue;
596
+ }
597
+ const child = {};
598
+ node[key] = child;
599
+ node = child;
600
+ }
601
+ node[leaf] = value;
602
+ }
603
+ function isZodType(value) {
604
+ return typeof value === "object" && value !== null && "safeParse" in value && typeof value.safeParse === "function";
605
+ }
606
+ function validationIssuesOf(cause, schemaPath) {
607
+ if (typeof cause !== "object" || cause === null) {
608
+ return void 0;
609
+ }
610
+ const error = cause;
611
+ if (error.code !== "VALIDATION_FAILED" || !Array.isArray(error.issues)) {
612
+ return void 0;
613
+ }
614
+ return error.issues.map((issue) => {
615
+ const { parameter, message } = issue;
616
+ return {
617
+ kind: "schema",
618
+ subject: typeof parameter === "string" && parameter !== "" ? parameter : schemaPath,
619
+ message: typeof message === "string" ? message : String(issue),
620
+ remedy: `Fix the value, or adjust the schema in ${schemaPath} if the shape is wrong.`
621
+ };
622
+ });
623
+ }
624
+ var schemaLoads = Promise.resolve();
625
+ function exclusively(work) {
626
+ const result = schemaLoads.then(work, work);
627
+ schemaLoads = result.then(
628
+ () => void 0,
629
+ () => void 0
630
+ );
631
+ return result;
632
+ }
633
+ function loadSchema(project, environment) {
634
+ const schemaPath = (0, import_core6.schemaFileOf)(project.config);
635
+ const file = (0, import_node_url.pathToFileURL)((0, import_node_path2.resolve)(project.root, schemaPath)).href;
636
+ return exclusively(() => loadSchemaExclusively(file, schemaPath, environment));
637
+ }
638
+ async function loadSchemaExclusively(file, schemaPath, environment) {
639
+ const jiti = (0, import_jiti.createJiti)(file, { interopDefault: false, moduleCache: false });
640
+ const previous = process.env.PENV_ENV;
641
+ process.env.PENV_ENV = environment;
642
+ let loaded;
643
+ try {
644
+ loaded = await jiti.import(file);
645
+ } catch (cause) {
646
+ const issues = validationIssuesOf(cause, schemaPath);
647
+ if (issues !== void 0) {
648
+ return { issues };
649
+ }
650
+ const detail = cause instanceof Error ? firstLine(cause.message) : String(cause);
651
+ return {
652
+ issues: [
653
+ {
654
+ kind: "config",
655
+ subject: schemaPath,
656
+ message: `${schemaPath} could not be loaded: ${detail}`,
657
+ remedy: `Fix the error above. penv reads the \`${SCHEMA_EXPORT}\` export of ${schemaPath}, which is yours to edit.`
658
+ }
659
+ ]
660
+ };
661
+ } finally {
662
+ if (previous === void 0) {
663
+ delete process.env.PENV_ENV;
664
+ } else {
665
+ process.env.PENV_ENV = previous;
666
+ }
667
+ }
668
+ const exported = typeof loaded === "object" && loaded !== null ? loaded[SCHEMA_EXPORT] : void 0;
669
+ if (!isZodType(exported)) {
670
+ return {
671
+ issues: [
672
+ {
673
+ kind: "config",
674
+ subject: schemaPath,
675
+ message: `${schemaPath} exports no \`${SCHEMA_EXPORT}\``,
676
+ remedy: `Export the shape as \`export const ${SCHEMA_EXPORT} = z.object({ ... })\`. One schema drives both validation and types.`
677
+ }
678
+ ]
679
+ };
680
+ }
681
+ return { schema: exported, issues: [] };
682
+ }
683
+ async function runValidate(options) {
684
+ const project = openProject(options.cwd);
685
+ const environment = targetEnvironment(project, options.environment);
686
+ const schemaPath = (0, import_core6.schemaFileOf)(project.config);
687
+ const issues = [];
688
+ let files;
689
+ try {
690
+ files = await project.provider.list();
691
+ } catch (error) {
692
+ if (!(error instanceof import_core6.PenvError)) {
693
+ throw error;
694
+ }
695
+ return {
696
+ ok: false,
697
+ environment,
698
+ parameters: 0,
699
+ issues: [issueFrom(error, schemaPath)],
700
+ drift: EMPTY_DRIFT
701
+ };
702
+ }
703
+ const refs = refsFrom(files);
704
+ for (const error of (0, import_core6.validateConfig)(project.config)) {
705
+ issues.push(issueFrom(error, "penv.config.ts"));
706
+ }
707
+ for (const collision of (0, import_core6.checkNameCollisions)(refs, project.config)) {
708
+ issues.push(issueFrom(collision, collision.variable));
709
+ }
710
+ const { schema, issues: schemaIssues } = await loadSchema(project, environment);
711
+ issues.push(...schemaIssues);
712
+ let drift = EMPTY_DRIFT;
713
+ if (schema !== void 0) {
714
+ const resolutions = await (0, import_core6.resolveAll)(
715
+ environment,
716
+ project.provider,
717
+ keySourceFor(project, environment)
718
+ );
719
+ const object = {};
720
+ const undecryptable = /* @__PURE__ */ new Set();
721
+ for (const resolution of resolutions) {
722
+ if (resolution.undecryptable !== void 0) {
723
+ undecryptable.add((0, import_core6.accessPath)(resolution.ref).join("."));
724
+ issues.push({
725
+ kind: "undecryptable",
726
+ subject: resolution.parameter,
727
+ message: `${resolution.winner?.location ?? "the winning value file"} could not be decrypted: ${resolution.undecryptable.detail}`,
728
+ remedy: "Make the key available, or re-seal the value under a key you hold with `penv encrypt`. The value is there \u2014 penv cannot read it."
729
+ });
730
+ continue;
731
+ }
732
+ if (resolution.value !== void 0) {
733
+ place(object, (0, import_core6.accessPath)(resolution.ref), resolution.value);
734
+ }
735
+ }
736
+ drift = computeDrift({ schema, resolutions, config: project.config, environment });
737
+ const result = schema.safeParse(object);
738
+ if (!result.success) {
739
+ for (const problem of result.error.issues) {
740
+ const path = problem.path.join(".");
741
+ if (undecryptable.has(path)) {
742
+ continue;
743
+ }
744
+ issues.push({
745
+ kind: "schema",
746
+ subject: path || schemaPath,
747
+ message: problem.message,
748
+ remedy: `Fix the value, or adjust the schema in ${schemaPath} if the shape is wrong.`
749
+ });
750
+ }
751
+ }
752
+ }
753
+ return { ok: issues.length === 0, environment, parameters: refs.length, issues, drift };
754
+ }
755
+ function renderValidate(result) {
756
+ if (result.ok) {
757
+ return formatRows([
758
+ {
759
+ glyph: CHECK,
760
+ label: "Schema valid",
761
+ subject: `${result.parameters} parameters for environment ${result.environment}`
762
+ }
763
+ ]);
764
+ }
765
+ const rows = result.issues.map((issue) => ({
766
+ glyph: WARN,
767
+ label: LABELS[issue.kind],
768
+ subject: issue.subject,
769
+ detail: issue.message
770
+ }));
771
+ const lines = formatRows(rows);
772
+ const remedies = [...new Set(result.issues.map((issue) => issue.remedy))];
773
+ for (const remedy of remedies) {
774
+ if (remedy !== void 0) {
775
+ lines.push(` ${remedy}`);
776
+ }
777
+ }
778
+ return lines;
779
+ }
780
+ var validateCommand = (0, import_citty2.defineCommand)({
781
+ meta: {
782
+ name: "validate",
783
+ description: "Validate configuration against the schema; non-zero on failure"
784
+ },
785
+ args: {
786
+ env: { type: "string", description: "The environment to validate" }
787
+ },
788
+ run({ args }) {
789
+ return guard(async () => {
790
+ const result = await runValidate({
791
+ cwd: process.cwd(),
792
+ ...args.env === void 0 ? {} : { environment: args.env }
793
+ });
794
+ write(renderValidate(result));
795
+ if (!result.ok) {
796
+ process.exitCode = 1;
797
+ }
798
+ });
799
+ }
800
+ });
801
+
802
+ // src/commands/doctor.ts
803
+ function skipped(check, label) {
804
+ return {
805
+ check,
806
+ severity: "unknown",
807
+ label,
808
+ subject: "not checked",
809
+ detail: "the schema did not load, so this check could not run"
810
+ };
811
+ }
812
+ async function runDoctor(options) {
813
+ const project = openProject(options.cwd);
814
+ const environment = targetEnvironment(project, options.environment);
815
+ const findings = [];
816
+ const { schema, issues } = await loadSchema(project, environment);
817
+ if (schema !== void 0) {
818
+ findings.push({ check: "schema", severity: "pass", label: "Schema valid" });
819
+ } else {
820
+ for (const issue of issues) {
821
+ findings.push({
822
+ check: "schema",
823
+ severity: "failure",
824
+ label: "Schema",
825
+ subject: issue.subject,
826
+ detail: issue.message
827
+ });
828
+ }
829
+ }
830
+ const resolutions = await (0, import_core7.resolveAll)(
831
+ environment,
832
+ project.provider,
833
+ keySourceFor(project, environment)
834
+ );
835
+ const subjects = await Promise.all(
836
+ resolutions.map(async (resolution) => ({
837
+ resolution,
838
+ meta: await project.provider.readMeta(resolution.ref)
839
+ }))
840
+ );
841
+ const missing = missingFindings(subjects, environment);
842
+ findings.push(...missing);
843
+ if (schema === void 0) {
844
+ findings.push(
845
+ skipped("declared", "Schema coverage"),
846
+ skipped("weak", "Secret strength"),
847
+ skipped("unused", "Value coverage")
848
+ );
849
+ } else {
850
+ const drift = computeDrift({
851
+ schema,
852
+ resolutions: subjects.map(({ resolution }) => resolution),
853
+ config: project.config,
854
+ environment
855
+ });
856
+ findings.push(...declaredFindings(drift, missing, environment));
857
+ findings.push(...weakFindings(subjects, schema));
858
+ findings.push(...unusedFindings(drift));
859
+ }
860
+ findings.push(...fallbackFindings(subjects, environment));
861
+ findings.push(...plaintextSecretFindings(subjects, environment));
862
+ findings.push(...publicSecretFindings(subjects, environment, project.config));
863
+ findings.push(...encryptionFindings(subjects, environment));
864
+ findings.push(...await sinkFindings(project, environment, options.sink));
865
+ findings.push({
866
+ check: "provider",
867
+ severity: "pass",
868
+ label: "Provider",
869
+ subject: project.config.providers[environment]?.type ?? project.provider.type
870
+ });
871
+ return {
872
+ environment,
873
+ findings,
874
+ ok: !findings.some((finding) => finding.severity === "failure")
875
+ };
876
+ }
877
+ function missingFindings(subjects, environment) {
878
+ const required = subjects.filter(({ meta }) => (0, import_core7.isRequired)(meta, environment));
879
+ const findings = required.filter(({ resolution }) => resolution.winner === void 0).map(({ resolution }) => ({
880
+ check: "missing",
881
+ severity: "failure",
882
+ label: "Missing parameter",
883
+ subject: resolution.parameter,
884
+ detail: `required for ${environment}, absent`,
885
+ remedy: `penv set ${[...resolution.ref.namespace, resolution.ref.name].join("/")} --env ${environment}`
886
+ }));
887
+ if (findings.length > 0) {
888
+ return findings;
889
+ }
890
+ return [
891
+ {
892
+ check: "missing",
893
+ severity: "pass",
894
+ label: "Required parameters",
895
+ subject: required.length === 0 ? `none required for ${environment}` : `${required.length} required for ${environment}, all present`
896
+ }
897
+ ];
898
+ }
899
+ function declaredFindings(drift, missing, environment) {
900
+ const reported = new Set(missing.filter((f) => f.severity !== "pass").map((f) => f.subject));
901
+ const findings = drift.declared.filter((item) => !reported.has(item.subject)).map((item) => ({
902
+ check: "declared",
903
+ severity: "warning",
904
+ label: "Declared, no value",
905
+ subject: item.subject,
906
+ detail: item.detail,
907
+ remedy: item.remedy
908
+ }));
909
+ if (findings.length > 0) {
910
+ return findings;
911
+ }
912
+ return [
913
+ {
914
+ check: "declared",
915
+ severity: "pass",
916
+ label: "Schema coverage",
917
+ subject: `every parameter .penv/env.ts requires has a value for ${environment}`
918
+ }
919
+ ];
920
+ }
921
+ function weakFindings(subjects, schema) {
922
+ const findings = [];
923
+ let checked = 0;
924
+ for (const { resolution } of subjects) {
925
+ const value = resolution.value;
926
+ if (value === void 0) {
927
+ continue;
928
+ }
929
+ const field = lookup(schema, (0, import_core7.accessPath)(resolution.ref));
930
+ if (field.kind !== "found") {
931
+ continue;
932
+ }
933
+ const minimum = minLengthOf(field.node);
934
+ if (minimum === void 0) {
935
+ continue;
936
+ }
937
+ checked += 1;
938
+ if (value.length >= minimum) {
939
+ continue;
940
+ }
941
+ findings.push({
942
+ check: "weak",
943
+ severity: "failure",
944
+ label: "Weak secret",
945
+ subject: resolution.parameter,
946
+ detail: `${value.length} chars, schema requires \u2265${minimum}`
947
+ });
948
+ }
949
+ if (findings.length > 0) {
950
+ return findings;
951
+ }
952
+ return [
953
+ {
954
+ check: "weak",
955
+ severity: "pass",
956
+ label: "Secret strength",
957
+ subject: checked === 0 ? "no schema field declares a minimum length" : checked === 1 ? "1 value meets the schema minimum" : `${checked} values meet the schema minimum`
958
+ }
959
+ ];
960
+ }
961
+ function unusedFindings(drift) {
962
+ const findings = drift.undeclared.map((item) => ({
963
+ check: "unused",
964
+ severity: "warning",
965
+ label: "Unused parameter",
966
+ subject: item.variable,
967
+ detail: "present, not in schema"
968
+ }));
969
+ if (findings.length > 0) {
970
+ return findings;
971
+ }
972
+ return [
973
+ {
974
+ check: "unused",
975
+ severity: "pass",
976
+ label: "Value coverage",
977
+ subject: "every value file has a schema key"
978
+ }
979
+ ];
980
+ }
981
+ function fallbackFindings(subjects, environment) {
982
+ const findings = subjects.filter(({ resolution }) => resolution.viaUnscopedFallback).map(({ resolution }) => ({
983
+ check: "unscoped-fallback",
984
+ severity: "warning",
985
+ label: "Unscoped fallback in use",
986
+ subject: resolution.parameter,
987
+ detail: `${environment} resolving to default`
988
+ }));
989
+ if (findings.length > 0) {
990
+ return findings;
991
+ }
992
+ return [
993
+ {
994
+ check: "unscoped-fallback",
995
+ severity: "pass",
996
+ label: "Scoped resolution",
997
+ subject: `no parameter falls back to the unscoped default for ${environment}`
998
+ }
999
+ ];
1000
+ }
1001
+ function plaintextSecretFindings(subjects, environment) {
1002
+ const secrets = subjects.filter(({ meta }) => (0, import_core7.isSecret)(meta, environment));
1003
+ const findings = [];
1004
+ for (const { resolution } of secrets) {
1005
+ const winner = resolution.winner;
1006
+ if (winner === void 0 || winner.file.encrypted) {
1007
+ continue;
1008
+ }
1009
+ findings.push({
1010
+ check: "plaintext-secret",
1011
+ severity: "failure",
1012
+ label: "Plaintext secret",
1013
+ subject: winner.location,
1014
+ detail: "value file is not encrypted"
1015
+ });
1016
+ }
1017
+ if (findings.length > 0) {
1018
+ return findings;
1019
+ }
1020
+ return [
1021
+ {
1022
+ check: "plaintext-secret",
1023
+ severity: "pass",
1024
+ label: "Encryption policy",
1025
+ subject: secrets.length === 0 ? `no parameter is declared secret for ${environment}` : `every secret resolving for ${environment} is encrypted`
1026
+ }
1027
+ ];
1028
+ }
1029
+ function publicSecretFindings(subjects, environment, config) {
1030
+ const prefixes = config.publicPrefixes ?? [];
1031
+ if (prefixes.length === 0) {
1032
+ return [
1033
+ {
1034
+ check: "public-secret",
1035
+ severity: "unknown",
1036
+ label: "Browser exposure",
1037
+ subject: "not checked \u2014 penv.config.ts declares no `publicPrefixes`",
1038
+ detail: "penv cannot tell which variables a framework inlines into the browser"
1039
+ }
1040
+ ];
1041
+ }
1042
+ const secrets = subjects.filter(({ meta }) => (0, import_core7.isSecret)(meta, environment));
1043
+ const findings = [];
1044
+ for (const { resolution } of secrets) {
1045
+ const variable = (0, import_core7.variableName)(resolution.ref, config);
1046
+ if (!(0, import_core7.isPublicVariable)(variable, config)) {
1047
+ continue;
1048
+ }
1049
+ const prefix = prefixes.find((candidate) => variable.startsWith(candidate));
1050
+ findings.push({
1051
+ check: "public-secret",
1052
+ severity: "failure",
1053
+ label: "Secret exposed to the browser",
1054
+ subject: variable,
1055
+ detail: prefix === void 0 ? "meta declares this a secret, and its public prefix makes it public" : `meta declares this a secret, and the \`${prefix}\` prefix makes it public`,
1056
+ remedy: "rename the parameter so it carries no public prefix, or drop `secret` from its meta if it is not one"
1057
+ });
1058
+ }
1059
+ if (findings.length > 0) {
1060
+ return findings;
1061
+ }
1062
+ return [
1063
+ {
1064
+ check: "public-secret",
1065
+ severity: "pass",
1066
+ label: "Browser exposure",
1067
+ subject: `no secret is exposed to the browser for ${environment}`
1068
+ }
1069
+ ];
1070
+ }
1071
+ function encryptionFindings(subjects, environment) {
1072
+ const sealed = subjects.filter(({ resolution }) => resolution.winner?.file.encrypted === true);
1073
+ const findings = [];
1074
+ for (const { resolution } of sealed) {
1075
+ const failure = resolution.undecryptable;
1076
+ if (failure === void 0) {
1077
+ continue;
1078
+ }
1079
+ findings.push({
1080
+ check: "encryption",
1081
+ severity: "failure",
1082
+ label: "Undecryptable value",
1083
+ subject: resolution.winner?.location ?? resolution.parameter,
1084
+ detail: failure.detail
1085
+ });
1086
+ }
1087
+ if (findings.length > 0) {
1088
+ return findings;
1089
+ }
1090
+ return [
1091
+ {
1092
+ check: "encryption",
1093
+ severity: "pass",
1094
+ label: "Encryption",
1095
+ subject: sealed.length === 0 ? `no encrypted value resolves for ${environment}` : `every encrypted value resolving for ${environment} decrypts`
1096
+ }
1097
+ ];
1098
+ }
1099
+ var EDIT_SKEW_MS = 12e4;
1100
+ function buildSink(declared, override) {
1101
+ if (override !== void 0) {
1102
+ return override;
1103
+ }
1104
+ if (declared.type === "github") {
1105
+ return (0, import_sink_github2.createGithubSink)(declared.repo === void 0 ? {} : { repo: declared.repo });
1106
+ }
1107
+ return void 0;
1108
+ }
1109
+ function errorDetail(error) {
1110
+ if (error instanceof Error) {
1111
+ return error.message.split("\n")[0] ?? error.message;
1112
+ }
1113
+ return String(error);
1114
+ }
1115
+ function scopeLabel(scope) {
1116
+ return scope.kind === "repository" ? "repository secrets" : `environment secrets for ${scope.environment}`;
1117
+ }
1118
+ async function sinkFindings(project, environment, override) {
1119
+ const declared = project.config.sinks?.[environment];
1120
+ if (declared === void 0) {
1121
+ return [];
1122
+ }
1123
+ const sink = buildSink(declared, override);
1124
+ if (sink === void 0) {
1125
+ return [
1126
+ {
1127
+ check: "sink-unreachable",
1128
+ severity: "unknown",
1129
+ label: "Sink",
1130
+ subject: `sink type \`${declared.type}\` is not one penv knows`,
1131
+ detail: "penv cannot check a sink it cannot build"
1132
+ }
1133
+ ];
1134
+ }
1135
+ try {
1136
+ await sink.verify();
1137
+ } catch (error) {
1138
+ return [
1139
+ {
1140
+ check: "sink-unreachable",
1141
+ severity: "unknown",
1142
+ label: "Sink",
1143
+ subject: `could not reach the ${declared.type} sink for ${environment}`,
1144
+ detail: errorDetail(error)
1145
+ }
1146
+ ];
1147
+ }
1148
+ let repoSecrets;
1149
+ let envSecrets;
1150
+ try {
1151
+ repoSecrets = await sink.list({ kind: "repository" });
1152
+ envSecrets = await sink.list({ kind: "environment", environment });
1153
+ } catch (error) {
1154
+ return [
1155
+ {
1156
+ check: "sink-unreachable",
1157
+ severity: "unknown",
1158
+ label: "Sink",
1159
+ subject: `could not list secrets in the ${declared.type} sink for ${environment}`,
1160
+ detail: errorDetail(error)
1161
+ }
1162
+ ];
1163
+ }
1164
+ const resolutions = await (0, import_core7.resolveAll)(
1165
+ environment,
1166
+ project.provider,
1167
+ keySourceFor(project, environment),
1168
+ true
1169
+ );
1170
+ const expected = [];
1171
+ for (const resolution of resolutions) {
1172
+ const winner = resolution.winner;
1173
+ if (winner === void 0) {
1174
+ continue;
1175
+ }
1176
+ expected.push({
1177
+ ref: resolution.ref,
1178
+ variable: (0, import_core7.variableName)(resolution.ref, project.config),
1179
+ scope: winner.file.scope.kind === "unscoped" ? { kind: "repository" } : { kind: "environment", environment }
1180
+ });
1181
+ }
1182
+ const upper = (name) => name.toUpperCase();
1183
+ const repoByName = new Map(repoSecrets.map((secret) => [upper(secret.name), secret]));
1184
+ const envByName = new Map(envSecrets.map((secret) => [upper(secret.name), secret]));
1185
+ const destOf = (scope) => scope.kind === "repository" ? repoByName : envByName;
1186
+ const nameDrift = [];
1187
+ const expectedEnv = /* @__PURE__ */ new Set();
1188
+ const allVariables = /* @__PURE__ */ new Set();
1189
+ for (const item of expected) {
1190
+ const key = upper(item.variable);
1191
+ allVariables.add(key);
1192
+ if (item.scope.kind === "environment") {
1193
+ expectedEnv.add(key);
1194
+ }
1195
+ if (!destOf(item.scope).has(key)) {
1196
+ nameDrift.push({
1197
+ check: "sink-name-drift",
1198
+ severity: "warning",
1199
+ label: "Declared, not pushed",
1200
+ subject: item.variable,
1201
+ detail: `resolves for ${environment} but is absent from the ${scopeLabel(item.scope)}`,
1202
+ remedy: `penv push --env ${environment}`
1203
+ });
1204
+ }
1205
+ }
1206
+ for (const secret of repoSecrets) {
1207
+ if (!allVariables.has(upper(secret.name))) {
1208
+ nameDrift.push({
1209
+ check: "sink-name-drift",
1210
+ severity: "warning",
1211
+ label: "In destination, not declared",
1212
+ subject: secret.name,
1213
+ detail: `a repository secret with no parameter penv pushes for ${environment}`
1214
+ });
1215
+ }
1216
+ }
1217
+ for (const secret of envSecrets) {
1218
+ if (!expectedEnv.has(upper(secret.name))) {
1219
+ nameDrift.push({
1220
+ check: "sink-name-drift",
1221
+ severity: "warning",
1222
+ label: "In destination, not declared",
1223
+ subject: secret.name,
1224
+ detail: `an environment secret with no parameter resolving for ${environment}`
1225
+ });
1226
+ }
1227
+ }
1228
+ const manualEdits = [];
1229
+ for (const item of expected) {
1230
+ const secret = destOf(item.scope).get(upper(item.variable));
1231
+ if (secret === void 0) {
1232
+ continue;
1233
+ }
1234
+ const pushed = (0, import_core7.effectiveMeta)(await project.provider.readMeta(item.ref), environment)[LAST_PUSHED_KEY];
1235
+ if (typeof pushed !== "string") {
1236
+ continue;
1237
+ }
1238
+ const destTime = Date.parse(secret.updatedAt);
1239
+ const pushTime = Date.parse(pushed);
1240
+ if (Number.isNaN(destTime) || Number.isNaN(pushTime) || destTime <= pushTime + EDIT_SKEW_MS) {
1241
+ continue;
1242
+ }
1243
+ manualEdits.push({
1244
+ check: "sink-manual-edit",
1245
+ severity: "warning",
1246
+ label: "Edited outside penv",
1247
+ subject: item.variable,
1248
+ detail: `changed in the destination at ${secret.updatedAt}, after penv last pushed it`
1249
+ });
1250
+ }
1251
+ const findings = [];
1252
+ findings.push(
1253
+ ...nameDrift.length > 0 ? nameDrift : [
1254
+ {
1255
+ check: "sink-name-drift",
1256
+ severity: "pass",
1257
+ label: "Sink names",
1258
+ subject: `every parameter resolving for ${environment} is present, and nothing undeclared is`
1259
+ }
1260
+ ]
1261
+ );
1262
+ findings.push(
1263
+ ...manualEdits.length > 0 ? manualEdits : [
1264
+ {
1265
+ check: "sink-manual-edit",
1266
+ severity: "pass",
1267
+ label: "Sink hand-edits",
1268
+ subject: `no secret has changed outside penv since its last push for ${environment}`
1269
+ }
1270
+ ]
1271
+ );
1272
+ findings.push({
1273
+ check: "sink-value-drift",
1274
+ severity: "unknown",
1275
+ label: "Sink values",
1276
+ subject: "cannot be read back from a write-only destination",
1277
+ detail: "value drift between the tree and the destination is unknowable by design"
1278
+ });
1279
+ return findings;
1280
+ }
1281
+ function renderDoctor(report) {
1282
+ const rows = report.findings.map((finding) => ({
1283
+ glyph: finding.severity === "pass" ? CHECK : finding.severity === "unknown" ? UNKNOWN : WARN,
1284
+ label: finding.label,
1285
+ ...finding.subject === void 0 ? {} : { subject: finding.subject },
1286
+ ...finding.detail === void 0 ? {} : { detail: finding.detail }
1287
+ }));
1288
+ const lines = formatRows(rows);
1289
+ const remedies = [
1290
+ ...new Set(
1291
+ report.findings.filter((finding) => finding.severity !== "pass").map((finding) => finding.remedy).filter((remedy) => remedy !== void 0)
1292
+ )
1293
+ ];
1294
+ for (const remedy of remedies) {
1295
+ lines.push(` ${remedy}`);
1296
+ }
1297
+ return lines;
1298
+ }
1299
+ var doctorCommand = (0, import_citty3.defineCommand)({
1300
+ meta: {
1301
+ name: "doctor",
1302
+ description: "Report missing, weak, unused, fallback, plaintext-secret, and sink-drift issues"
1303
+ },
1304
+ args: {
1305
+ env: { type: "string", description: "The environment to report on" }
1306
+ },
1307
+ run({ args }) {
1308
+ return guard(async () => {
1309
+ const report = await runDoctor({
1310
+ cwd: process.cwd(),
1311
+ ...args.env === void 0 ? {} : { environment: args.env }
1312
+ });
1313
+ write(renderDoctor(report));
1314
+ if (!report.ok) {
1315
+ process.exitCode = 1;
1316
+ }
1317
+ });
1318
+ }
1319
+ });
1320
+
1321
+ // src/commands/encrypt.ts
1322
+ var import_core9 = require("@penvhq/core");
1323
+ var import_citty5 = require("citty");
1324
+
1325
+ // src/commands/set.ts
1326
+ var import_core8 = require("@penvhq/core");
1327
+ var import_citty4 = require("citty");
1328
+ function scopeFrom(options) {
1329
+ if (options.local === true) {
1330
+ if (options.environment !== void 0) {
1331
+ return { kind: "environment-local", environment: options.environment };
1332
+ }
1333
+ return { kind: "local" };
1334
+ }
1335
+ if (options.environment !== void 0) {
1336
+ return { kind: "environment", environment: options.environment };
1337
+ }
1338
+ return { kind: "unscoped" };
1339
+ }
1340
+ function targetScope(project, options, key) {
1341
+ const environment = options.environment;
1342
+ if (environment === void 0) {
1343
+ return scopeFrom(options);
1344
+ }
1345
+ if (environment.trim().length === 0) {
1346
+ throw new import_core8.PenvError(
1347
+ "ENVIRONMENT_FLAG_EMPTY",
1348
+ `\`--env\` for parameter ${key} names no environment`,
1349
+ `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.`
1350
+ );
1351
+ }
1352
+ return scopeFrom({ ...options, environment: targetEnvironment(project, environment) });
1353
+ }
1354
+ function policyEnvironment(project, options) {
1355
+ return options.environment === void 0 ? void 0 : targetEnvironment(project, options.environment);
1356
+ }
1357
+ function sealFor(project, file, value, parameter, environment) {
1358
+ if (environment === void 0) {
1359
+ throw new import_core8.PenvError(
1360
+ "SECRET_SCOPE_AMBIGUOUS",
1361
+ `Parameter ${parameter} is a secret, and ${PENV_DIR}/${(0, import_core8.formatValueFile)(file)} names no environment`,
1362
+ "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."
1363
+ );
1364
+ }
1365
+ return (0, import_core8.sealValue)(file, value, keySourceFor(project, environment), parameter, environment);
1366
+ }
1367
+ async function runSet(options) {
1368
+ const project = openProject(options.cwd);
1369
+ const ref = refFromKey(options.key, project.config);
1370
+ const scope = targetScope(project, options, options.key);
1371
+ const environment = policyEnvironment(project, options);
1372
+ const secret = (0, import_core8.isSecret)(await project.provider.readMeta(ref), environment);
1373
+ const file = {
1374
+ namespace: ref.namespace,
1375
+ name: ref.name,
1376
+ scope,
1377
+ encrypted: secret
1378
+ };
1379
+ const stored = secret ? sealFor(project, file, options.value, (0, import_core8.parameterId)(ref), environment) : options.value;
1380
+ await project.provider.write(file, stored);
1381
+ await project.provider.remove({ ...file, encrypted: !secret });
1382
+ return { parameter: options.key, location: (0, import_core8.formatValueFile)(file), encrypted: secret };
1383
+ }
1384
+ function renderSet(result) {
1385
+ return formatRows([
1386
+ {
1387
+ glyph: CHECK,
1388
+ label: "Wrote",
1389
+ subject: `${PENV_DIR}/${result.location}`,
1390
+ // The `.enc` suffix says this already, but only to a reader who knows the
1391
+ // grammar. Meta decided it, not the command line, so the command says so.
1392
+ ...result.encrypted ? { detail: "encrypted, per the parameter's meta policy" } : {}
1393
+ }
1394
+ ]);
1395
+ }
1396
+ async function readStdin() {
1397
+ const chunks = [];
1398
+ for await (const chunk of process.stdin) {
1399
+ chunks.push(Buffer.from(chunk));
1400
+ }
1401
+ const text = Buffer.concat(chunks).toString("utf8");
1402
+ return text.endsWith("\n") ? text.slice(0, -1) : text;
1403
+ }
1404
+ var setCommand = (0, import_citty4.defineCommand)({
1405
+ meta: { name: "set", description: "Update a parameter" },
1406
+ args: {
1407
+ key: { type: "positional", required: true, description: "The parameter, e.g. redis/password" },
1408
+ value: {
1409
+ type: "positional",
1410
+ required: false,
1411
+ description: "The value; read from stdin if omitted"
1412
+ },
1413
+ env: { type: "string", description: "Write the <name>.<env> scope" },
1414
+ local: {
1415
+ type: "boolean",
1416
+ description: "Write the personal override: <name>.<env>.local with --env, else <name>.local"
1417
+ }
1418
+ },
1419
+ run({ args }) {
1420
+ return guard(async () => {
1421
+ const value = args.value ?? await readStdin();
1422
+ write(
1423
+ renderSet(
1424
+ await runSet({
1425
+ cwd: process.cwd(),
1426
+ key: args.key,
1427
+ value,
1428
+ ...args.env === void 0 ? {} : { environment: args.env },
1429
+ ...args.local === void 0 ? {} : { local: args.local }
1430
+ })
1431
+ )
1432
+ );
1433
+ });
1434
+ }
1435
+ });
1436
+
1437
+ // src/commands/encrypt.ts
1438
+ function twins(project, key, options) {
1439
+ const ref = refFromKey(key, project.config);
1440
+ const scope = targetScope(project, options, key);
1441
+ return [
1442
+ { namespace: ref.namespace, name: ref.name, scope, encrypted: false },
1443
+ { namespace: ref.namespace, name: ref.name, scope, encrypted: true }
1444
+ ];
1445
+ }
1446
+ function environmentFor(project, options, verb) {
1447
+ if (options.environment === void 0) {
1448
+ throw new import_core9.PenvError(
1449
+ "SECRET_SCOPE_AMBIGUOUS",
1450
+ `\`penv ${verb}\` names no environment, and keys are declared per environment`,
1451
+ "Pass `--env <environment>`. penv cannot tell which environment's key applies to a file that names none, and will not pick one for you."
1452
+ );
1453
+ }
1454
+ return targetEnvironment(project, options.environment);
1455
+ }
1456
+ async function readOne(project, file) {
1457
+ return project.provider.read(file);
1458
+ }
1459
+ async function runEncrypt(options) {
1460
+ const project = openProject(options.cwd);
1461
+ const environment = environmentFor(project, options, "encrypt");
1462
+ const [plain, sealed] = twins(project, options.key, options);
1463
+ const parameter = (0, import_core9.parameterId)(plain);
1464
+ const value = await readOne(project, plain);
1465
+ if (value === void 0) {
1466
+ const already = await readOne(project, sealed);
1467
+ throw new import_core9.PenvError(
1468
+ "PARAMETER_ABSENT",
1469
+ already === void 0 ? `Parameter ${parameter} has no value file at ${PENV_DIR}/${(0, import_core9.formatValueFile)(plain)}` : `Parameter ${parameter} is already encrypted at ${PENV_DIR}/${(0, import_core9.formatValueFile)(sealed)}`,
1470
+ 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."
1471
+ );
1472
+ }
1473
+ const text = (0, import_core9.sealValue)(sealed, value, keySourceFor(project, environment), parameter, environment);
1474
+ await project.provider.write(sealed, text);
1475
+ await project.provider.remove(plain);
1476
+ return {
1477
+ parameter,
1478
+ location: (0, import_core9.formatValueFile)(sealed),
1479
+ removed: (0, import_core9.formatValueFile)(plain)
1480
+ };
1481
+ }
1482
+ async function runDecrypt(options) {
1483
+ const project = openProject(options.cwd);
1484
+ const environment = environmentFor(project, options, "decrypt");
1485
+ const [plain, sealed] = twins(project, options.key, options);
1486
+ const parameter = (0, import_core9.parameterId)(plain);
1487
+ if ((0, import_core9.isSecret)(await project.provider.readMeta(plain), environment)) {
1488
+ throw new import_core9.PenvError(
1489
+ "SECRET_DECRYPT_REFUSED",
1490
+ `Parameter ${parameter} is declared a secret for environment ${environment}, so penv will not write it in plaintext`,
1491
+ "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."
1492
+ );
1493
+ }
1494
+ const stored = await readOne(project, sealed);
1495
+ if (stored === void 0) {
1496
+ throw new import_core9.PenvError(
1497
+ "PARAMETER_ABSENT",
1498
+ `Parameter ${parameter} has no encrypted value file at ${PENV_DIR}/${(0, import_core9.formatValueFile)(sealed)}`,
1499
+ `Nothing to decrypt. \`penv get ${options.key} --env ${environment} --explain\` shows every file penv looked at.`
1500
+ );
1501
+ }
1502
+ const opened = (0, import_core9.openValue)(sealed, stored, keySourceFor(project, environment));
1503
+ if (opened.kind === "failed") {
1504
+ throw new UndecryptableAt(
1505
+ parameter,
1506
+ environment,
1507
+ (0, import_core9.formatValueFile)(sealed),
1508
+ opened.failure.detail
1509
+ );
1510
+ }
1511
+ await project.provider.write(plain, opened.value);
1512
+ await project.provider.remove(sealed);
1513
+ return {
1514
+ parameter,
1515
+ location: (0, import_core9.formatValueFile)(plain),
1516
+ removed: (0, import_core9.formatValueFile)(sealed)
1517
+ };
1518
+ }
1519
+ var UndecryptableAt = class extends import_core9.PenvError {
1520
+ constructor(parameter, environment, location, detail) {
1521
+ super(
1522
+ "VALUE_UNDECRYPTABLE",
1523
+ `Parameter ${parameter} for environment ${environment} is sealed at ${PENV_DIR}/${location}, and penv could not open it: ${detail}`,
1524
+ "Make the key available and run the command again. penv will not replace a value it cannot read."
1525
+ );
1526
+ }
1527
+ };
1528
+ function renderReseal(result, verb) {
1529
+ return formatRows([
1530
+ {
1531
+ glyph: CHECK,
1532
+ label: verb,
1533
+ subject: `${PENV_DIR}/${result.location}`,
1534
+ detail: `${PENV_DIR}/${result.removed} removed`
1535
+ }
1536
+ ]);
1537
+ }
1538
+ var SCOPE_ARGS = {
1539
+ key: { type: "positional", required: true, description: "The parameter, e.g. redis/password" },
1540
+ env: { type: "string", description: "The environment whose value file to act on" },
1541
+ local: {
1542
+ type: "boolean",
1543
+ description: "Act on the personal override rather than the shared file"
1544
+ }
1545
+ };
1546
+ function scopeOptions(args) {
1547
+ return {
1548
+ ...args.env === void 0 ? {} : { environment: args.env },
1549
+ ...args.local === void 0 ? {} : { local: args.local }
1550
+ };
1551
+ }
1552
+ var encryptCommand = (0, import_citty5.defineCommand)({
1553
+ meta: { name: "encrypt", description: "Encrypt one parameter's value file at one scope" },
1554
+ args: SCOPE_ARGS,
1555
+ run({ args }) {
1556
+ return guard(async () => {
1557
+ const result = await runEncrypt({ cwd: process.cwd(), key: args.key, ...scopeOptions(args) });
1558
+ write(renderReseal(result, "Encrypted"));
1559
+ });
1560
+ }
1561
+ });
1562
+ var decryptCommand = (0, import_citty5.defineCommand)({
1563
+ meta: { name: "decrypt", description: "Decrypt one parameter's value file at one scope" },
1564
+ args: SCOPE_ARGS,
1565
+ run({ args }) {
1566
+ return guard(async () => {
1567
+ const result = await runDecrypt({ cwd: process.cwd(), key: args.key, ...scopeOptions(args) });
1568
+ write(renderReseal(result, "Decrypted"));
1569
+ });
1570
+ }
1571
+ });
1572
+
1573
+ // src/commands/generate.ts
1574
+ var import_node_fs = require("fs");
1575
+ var import_node_path3 = require("path");
1576
+ var import_core10 = require("@penvhq/core");
1577
+ var import_citty6 = require("citty");
1578
+ var DEFAULT_OUTPUT = ".env";
1579
+ function entriesFor(project, environment, allowDecrypt) {
1580
+ const keys = keySourceFor(project, environment);
1581
+ const tree = localTree(project);
1582
+ const resolutions = resolveAllSync(tree, environment, keys);
1583
+ const collision = (0, import_core10.checkNameCollisions)(
1584
+ refsFrom(resolutions.map((resolution) => resolution.ref)),
1585
+ project.config
1586
+ )[0];
1587
+ if (collision !== void 0) {
1588
+ throw collision;
1589
+ }
1590
+ const entries = [];
1591
+ let decrypted = 0;
1592
+ for (const resolution of resolutions) {
1593
+ const winner = resolution.winner;
1594
+ if (winner?.file.encrypted === true) {
1595
+ if (!allowDecrypt) {
1596
+ throw new import_core10.PenvError(
1597
+ "ENCRYPTED_VALUE_REFUSED",
1598
+ `Parameter ${resolution.parameter} for environment ${environment} resolves to the encrypted value file ${PENV_DIR}/${winner.location}, and \`penv generate\` writes plaintext`,
1599
+ `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.`
1600
+ );
1601
+ }
1602
+ (0, import_core10.requireValue)(resolution, environment);
1603
+ decrypted += 1;
1604
+ }
1605
+ if (resolution.value === void 0) {
1606
+ continue;
1607
+ }
1608
+ const description = (0, import_core10.effectiveMeta)(tree.readMetaSync(resolution.ref), environment).description;
1609
+ entries.push({
1610
+ key: (0, import_core10.variableName)(resolution.ref, project.config),
1611
+ value: resolution.value,
1612
+ ...typeof description === "string" ? { description } : {}
1613
+ });
1614
+ }
1615
+ entries.sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
1616
+ return { entries, decrypted };
1617
+ }
1618
+ function generateDotenv(options) {
1619
+ const project = openProject(options.cwd);
1620
+ const environment = targetEnvironment(project, options.environment);
1621
+ return (0, import_core10.serializeDotenv)(entriesFor(project, environment, options.allowDecrypt === true).entries);
1622
+ }
1623
+ function runGenerate(options) {
1624
+ const project = openProject(options.cwd);
1625
+ const environment = targetEnvironment(project, options.environment);
1626
+ const { entries, decrypted } = entriesFor(project, environment, options.allowDecrypt === true);
1627
+ const file = options.out === void 0 ? (0, import_node_path3.resolve)(project.root, DEFAULT_OUTPUT) : (0, import_node_path3.isAbsolute)(options.out) ? options.out : (0, import_node_path3.resolve)(options.cwd, options.out);
1628
+ (0, import_node_fs.writeFileSync)(file, (0, import_core10.serializeDotenv)(entries), "utf8");
1629
+ return { file, environment, entries: entries.length, decrypted };
1630
+ }
1631
+ function displayPath(cwd, file) {
1632
+ const rel = (0, import_node_path3.relative)(cwd, file);
1633
+ return rel === "" || rel.startsWith("..") ? file : rel.split("\\").join("/");
1634
+ }
1635
+ function renderGenerate(result, cwd) {
1636
+ const rows = [
1637
+ {
1638
+ glyph: CHECK,
1639
+ label: "Generated",
1640
+ subject: displayPath(cwd, result.file),
1641
+ detail: `${result.entries} variables for environment ${result.environment}`
1642
+ }
1643
+ ];
1644
+ if (result.decrypted > 0) {
1645
+ rows.push({
1646
+ glyph: WARN,
1647
+ label: "Decrypted",
1648
+ subject: `${result.decrypted} ${result.decrypted === 1 ? "secret" : "secrets"}`,
1649
+ detail: "written as plaintext into the artifact"
1650
+ });
1651
+ }
1652
+ return formatRows(rows);
1653
+ }
1654
+ var generateCommand = (0, import_citty6.defineCommand)({
1655
+ meta: { name: "generate", description: "Write a standard .env artifact for deploy targets" },
1656
+ args: {
1657
+ env: { type: "string", description: "The environment to generate for" },
1658
+ out: { type: "string", description: "Where to write, instead of .env" },
1659
+ "allow-decrypt": {
1660
+ type: "boolean",
1661
+ description: "Write encrypted values into the artifact as plaintext"
1662
+ }
1663
+ },
1664
+ run({ args }) {
1665
+ return guard(async () => {
1666
+ const cwd = process.cwd();
1667
+ const result = runGenerate({
1668
+ cwd,
1669
+ ...args.env === void 0 ? {} : { environment: args.env },
1670
+ ...args.out === void 0 ? {} : { out: args.out },
1671
+ ...args["allow-decrypt"] === void 0 ? {} : { allowDecrypt: args["allow-decrypt"] }
1672
+ });
1673
+ write(renderGenerate(result, cwd));
1674
+ });
1675
+ }
1676
+ });
1677
+
1678
+ // src/commands/get.ts
1679
+ var import_core11 = require("@penvhq/core");
1680
+ var import_citty7 = require("citty");
1681
+ async function runGet(options) {
1682
+ const project = openProject(options.cwd);
1683
+ const environment = targetEnvironment(project, options.environment);
1684
+ const ref = refFromKey(options.key);
1685
+ const keys = keySourceFor(project, environment);
1686
+ const resolution = await (0, import_core11.resolveParameter)(ref, environment, project.provider, keys);
1687
+ const value = (0, import_core11.requireValue)(resolution, environment);
1688
+ if (value === void 0) {
1689
+ throw new import_core11.PenvError(
1690
+ "PARAMETER_ABSENT",
1691
+ `Parameter ${resolution.parameter} resolves to no value for environment ${environment}`,
1692
+ `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.`
1693
+ );
1694
+ }
1695
+ return value;
1696
+ }
1697
+ function skipReason(reason) {
1698
+ if (reason === "lower-precedence") {
1699
+ return "skipped, a higher-precedence file wins";
1700
+ }
1701
+ if (reason === "local-skipped-in-test") {
1702
+ return "skipped, .local never applies in test";
1703
+ }
1704
+ return void 0;
1705
+ }
1706
+ async function runExplain(options) {
1707
+ const project = openProject(options.cwd);
1708
+ const environment = targetEnvironment(project, options.environment);
1709
+ const ref = refFromKey(options.key);
1710
+ const keys = keySourceFor(project, environment);
1711
+ const resolution = await (0, import_core11.resolveParameter)(ref, environment, project.provider, keys);
1712
+ const winner = resolution.winner;
1713
+ return {
1714
+ parameter: resolution.parameter,
1715
+ environment,
1716
+ location: winner === void 0 ? void 0 : winner.location,
1717
+ ...resolution.undecryptable === void 0 ? {} : { undecryptable: resolution.undecryptable.detail },
1718
+ candidates: resolution.candidates.map((candidate) => ({
1719
+ location: candidate.location,
1720
+ present: candidate.present,
1721
+ wins: candidate === winner,
1722
+ skipped: skipReason(candidate.skippedReason)
1723
+ }))
1724
+ };
1725
+ }
1726
+ function renderExplain(explanation) {
1727
+ const target = explanation.location === void 0 ? "nothing" : `${PENV_DIR}/${explanation.location}`;
1728
+ const rows = explanation.candidates.map((candidate) => [
1729
+ candidate.location,
1730
+ candidate.wins ? "present, wins" : candidate.present ? candidate.skipped ?? "present" : candidate.skipped ?? "absent"
1731
+ ]);
1732
+ return [
1733
+ `${explanation.parameter} resolves to ${target} for environment ${explanation.environment}`,
1734
+ ...explanation.undecryptable === void 0 ? [] : [` penv cannot decrypt it: ${explanation.undecryptable}`],
1735
+ "",
1736
+ ...columns(rows).map((line) => ` ${line}`)
1737
+ ];
1738
+ }
1739
+ var getCommand = (0, import_citty7.defineCommand)({
1740
+ meta: { name: "get", description: "Read a parameter" },
1741
+ args: {
1742
+ key: { type: "positional", required: true, description: "The parameter, e.g. redis/password" },
1743
+ env: { type: "string", description: "The environment to read" },
1744
+ explain: { type: "boolean", description: "Print which file wins, and why" }
1745
+ },
1746
+ run({ args }) {
1747
+ return guard(async () => {
1748
+ const options = {
1749
+ cwd: process.cwd(),
1750
+ key: args.key,
1751
+ ...args.env === void 0 ? {} : { environment: args.env }
1752
+ };
1753
+ if (args.explain === true) {
1754
+ write(renderExplain(await runExplain(options)));
1755
+ return;
1756
+ }
1757
+ write([await runGet(options)]);
1758
+ });
1759
+ }
1760
+ });
1761
+
1762
+ // src/commands/import.ts
1763
+ var import_node_fs4 = require("fs");
1764
+ var import_node_path6 = require("path");
1765
+ var import_core14 = require("@penvhq/core");
1766
+ var import_citty9 = require("citty");
1767
+
1768
+ // src/detect.ts
1769
+ var import_node_fs2 = require("fs");
1770
+ var import_node_path4 = require("path");
1771
+ var import_core12 = require("@penvhq/core");
1772
+ var SIGNATURES = [
1773
+ { name: "Next.js", packages: ["next"], publicPrefixes: ["NEXT_PUBLIC_"] },
1774
+ {
1775
+ name: "TanStack Start",
1776
+ packages: ["@tanstack/react-start", "@tanstack/start"],
1777
+ publicPrefixes: ["VITE_"]
1778
+ },
1779
+ { name: "Astro", packages: ["astro"], publicPrefixes: ["PUBLIC_"] },
1780
+ { name: "Vite", packages: ["vite"], publicPrefixes: ["VITE_"] }
1781
+ ];
1782
+ function exportsSchema(file) {
1783
+ let source;
1784
+ try {
1785
+ source = (0, import_node_fs2.readFileSync)(file, "utf8");
1786
+ } catch {
1787
+ return false;
1788
+ }
1789
+ return (
1790
+ // `export const schema = z.object({ ... })` — what penv scaffolds.
1791
+ /export\s+(?:const|let|var)\s+schema\b/.test(source) || // `export { schema }`, `export { shape as schema }`.
1792
+ /export\s*\{[^}]*\bschema\b[^}]*\}/.test(source)
1793
+ );
1794
+ }
1795
+ function occupied(cwd, relative3) {
1796
+ const file = (0, import_node_path4.join)(cwd, ...relative3.split("/"));
1797
+ return (0, import_node_fs2.existsSync)(file) && !exportsSchema(file);
1798
+ }
1799
+ function schemaFileFor(cwd) {
1800
+ const dir = (0, import_node_fs2.existsSync)((0, import_node_path4.join)(cwd, "src")) ? "src/" : "";
1801
+ const preferred = `${dir}env.ts`;
1802
+ if (!occupied(cwd, preferred)) {
1803
+ return { file: preferred };
1804
+ }
1805
+ const beside = `${dir}penv-env.ts`;
1806
+ if (!occupied(cwd, beside)) {
1807
+ return { file: beside, displaced: preferred };
1808
+ }
1809
+ return { file: import_core12.DEFAULT_SCHEMA_FILE, displaced: preferred };
1810
+ }
1811
+ function dependenciesOf(cwd) {
1812
+ const manifest = manifestOf(cwd);
1813
+ if (manifest === void 0) {
1814
+ return void 0;
1815
+ }
1816
+ const names = /* @__PURE__ */ new Set();
1817
+ for (const field of ["dependencies", "devDependencies"]) {
1818
+ const block = manifest[field];
1819
+ if (block !== null && typeof block === "object" && !Array.isArray(block)) {
1820
+ for (const name of Object.keys(block)) {
1821
+ names.add(name);
1822
+ }
1823
+ }
1824
+ }
1825
+ return names;
1826
+ }
1827
+ function manifestOf(cwd) {
1828
+ const file = (0, import_node_path4.join)(cwd, "package.json");
1829
+ if (!(0, import_node_fs2.existsSync)(file)) {
1830
+ return void 0;
1831
+ }
1832
+ let manifest;
1833
+ try {
1834
+ manifest = JSON.parse((0, import_node_fs2.readFileSync)(file, "utf8"));
1835
+ } catch {
1836
+ return void 0;
1837
+ }
1838
+ return manifest === null || typeof manifest !== "object" || Array.isArray(manifest) ? void 0 : manifest;
1839
+ }
1840
+ function detectFramework(cwd) {
1841
+ const dependencies = dependenciesOf(cwd);
1842
+ if (dependencies === void 0) {
1843
+ return void 0;
1844
+ }
1845
+ for (const signature of SIGNATURES) {
1846
+ if (signature.packages.some((name) => dependencies.has(name))) {
1847
+ const schema = schemaFileFor(cwd);
1848
+ return {
1849
+ name: signature.name,
1850
+ schemaFile: schema.file,
1851
+ ...schema.displaced === void 0 ? {} : { displacedFrom: schema.displaced },
1852
+ publicPrefixes: signature.publicPrefixes
1853
+ };
1854
+ }
1855
+ }
1856
+ return void 0;
1857
+ }
1858
+ var DEFAULT_ALIAS = "@env";
1859
+ var IMPORTS_ALIAS = "#env";
1860
+ function detectAlias(cwd) {
1861
+ return hasImportsBlock(cwd) ? IMPORTS_ALIAS : DEFAULT_ALIAS;
1862
+ }
1863
+ function hasImportsBlock(cwd) {
1864
+ const manifest = manifestOf(cwd);
1865
+ const imports = manifest?.imports;
1866
+ return imports !== null && typeof imports === "object" && !Array.isArray(imports);
1867
+ }
1868
+
1869
+ // src/commands/init.ts
1870
+ var import_node_fs3 = require("fs");
1871
+ var import_node_path5 = require("path");
1872
+ var import_promises = require("readline/promises");
1873
+ var import_core13 = require("@penvhq/core");
1874
+ var import_citty8 = require("citty");
1875
+ var CONFIG_FILE = "penv.config.ts";
1876
+ var TSCONFIG_FILE = "tsconfig.json";
1877
+ var GITIGNORE_FILE = ".gitignore";
1878
+ var PENV_DIR2 = ".penv";
1879
+ var ALIAS_NAME = /^[@#][A-Za-z0-9_-]+$/;
1880
+ var IMPORTS_PREFIX = "#";
1881
+ var PACKAGE_FILE = "package.json";
1882
+ var DEFAULT_DECISIONS = {
1883
+ environments: [],
1884
+ schemaFile: import_core13.DEFAULT_SCHEMA_FILE,
1885
+ publicPrefixes: [],
1886
+ alias: DEFAULT_ALIAS
1887
+ };
1888
+ var NOT_ENVIRONMENTS = [...import_core13.RESERVED_TOKENS, "example", "sample", "template"];
1889
+ function suggestEnvironments(root) {
1890
+ let entries;
1891
+ try {
1892
+ entries = (0, import_node_fs3.readdirSync)(root);
1893
+ } catch {
1894
+ return [];
1895
+ }
1896
+ const found = /* @__PURE__ */ new Set();
1897
+ for (const entry of entries) {
1898
+ if (!entry.startsWith(".env.")) {
1899
+ continue;
1900
+ }
1901
+ const segments = entry.slice(".env.".length).split(".");
1902
+ const withoutLocal = segments.at(-1) === "local" ? segments.slice(0, -1) : segments;
1903
+ const name = withoutLocal.length === 1 ? withoutLocal[0] : void 0;
1904
+ if (name === void 0 || !(0, import_core13.isLegalEnvironmentName)(name) || NOT_ENVIRONMENTS.includes(name)) {
1905
+ continue;
1906
+ }
1907
+ found.add(name);
1908
+ }
1909
+ return [...found].sort();
1910
+ }
1911
+ function emptyFlag(flag) {
1912
+ return new import_core13.PenvError(
1913
+ "INIT_FLAG_EMPTY",
1914
+ `\`--${flag}\` was given without a value`,
1915
+ flag === "schema" ? `Name the module that exports the schema, e.g. \`--schema src/env.ts\`, or drop the flag to use ${import_core13.DEFAULT_SCHEMA_FILE}.` : "Name the environment, e.g. `--env production`, or drop the flag to leave the whitelist empty and declare it in penv.config.ts."
1916
+ );
1917
+ }
1918
+ function splitEnvironments(value) {
1919
+ return value.split(",").map((name) => name.trim()).filter((name) => name.length > 0);
1920
+ }
1921
+ function environmentsFromFlag(flag) {
1922
+ if (flag === void 0) {
1923
+ return void 0;
1924
+ }
1925
+ const given = Array.isArray(flag) ? flag : [flag];
1926
+ const names = given.flatMap((value) => splitEnvironments(String(value)));
1927
+ if (names.length === 0) {
1928
+ throw emptyFlag("env");
1929
+ }
1930
+ return [...new Set(names)];
1931
+ }
1932
+ function configOf(decisions) {
1933
+ return { environments: decisions.environments, providers: {}, schemaFile: decisions.schemaFile };
1934
+ }
1935
+ function declaredIn(root) {
1936
+ const file = (0, import_node_path5.join)(root, CONFIG_FILE);
1937
+ if (!(0, import_node_fs3.existsSync)(file)) {
1938
+ return void 0;
1939
+ }
1940
+ return (0, import_core13.loadConfigFrom)(file);
1941
+ }
1942
+ function planInit(root, flags = {}) {
1943
+ const declared = declaredIn(root);
1944
+ const detected = detectFramework(root);
1945
+ const notes = [];
1946
+ if (declared !== void 0) {
1947
+ notes.push(`${CONFIG_FILE} already exists \u2014 init keeps every decision it records.`);
1948
+ } else if (detected === void 0) {
1949
+ notes.push(
1950
+ `No framework detected in package.json \u2014 the schema goes to ${import_core13.DEFAULT_SCHEMA_FILE}.`
1951
+ );
1952
+ } else {
1953
+ notes.push(`Detected ${detected.name}.`);
1954
+ if (detected.displacedFrom !== void 0) {
1955
+ notes.push(
1956
+ `${detected.displacedFrom} is already a module of yours that exports no \`schema\`, so the schema goes to ${detected.schemaFile}. penv never writes over a file it did not write \u2014 delete yours and re-run if you want it there, or pass \`--schema\`.`
1957
+ );
1958
+ }
1959
+ }
1960
+ const schemaFile = flags.schema === void 0 ? declared !== void 0 ? (0, import_core13.schemaFileOf)(declared) : detected?.schemaFile ?? import_core13.DEFAULT_SCHEMA_FILE : flags.schema.trim();
1961
+ if (flags.schema !== void 0) {
1962
+ if (schemaFile.length === 0) {
1963
+ throw emptyFlag("schema");
1964
+ }
1965
+ const error = (0, import_core13.validateSchemaFile)({ environments: [], providers: {}, schemaFile })[0];
1966
+ if (error !== void 0) {
1967
+ throw error;
1968
+ }
1969
+ }
1970
+ const alias = flags.alias === void 0 ? detectAlias(root) : flags.alias.trim();
1971
+ if (flags.alias !== void 0 && alias.length === 0) {
1972
+ throw emptyFlag("alias");
1973
+ }
1974
+ if (!ALIAS_NAME.test(alias)) {
1975
+ throw new import_core13.PenvError(
1976
+ "INIT_ALIAS_INVALID",
1977
+ `\`${alias}\` is not an alias penv can write`,
1978
+ `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.`
1979
+ );
1980
+ }
1981
+ if (flags.alias === void 0 && alias.startsWith(IMPORTS_PREFIX)) {
1982
+ notes.push(
1983
+ `Your ${PACKAGE_FILE} declares \`imports\`, so the alias is \`${alias}\` \u2014 Node resolves it without a bundler.`
1984
+ );
1985
+ }
1986
+ const suggestedEnvironments = suggestEnvironments(root);
1987
+ const environments = flags.environments ?? declared?.environments ?? [];
1988
+ if (environments.length === 0) {
1989
+ notes.push(
1990
+ "No environments declared: penv does not infer them, and a `--yes` run cannot invent them."
1991
+ );
1992
+ if (suggestedEnvironments.length > 0) {
1993
+ notes.push(
1994
+ `Your \`.env\` files mention ${suggestedEnvironments.join(", ")} \u2014 declare the ones you really deploy in ${CONFIG_FILE}, with a provider for each.`
1995
+ );
1996
+ }
1997
+ }
1998
+ return {
1999
+ detected,
2000
+ decisions: {
2001
+ environments,
2002
+ schemaFile,
2003
+ publicPrefixes: declared?.publicPrefixes ?? detected?.publicPrefixes ?? [],
2004
+ alias
2005
+ },
2006
+ suggestedEnvironments,
2007
+ notes
2008
+ };
2009
+ }
2010
+ function renderPlan(plan2) {
2011
+ const rows = [];
2012
+ rows.push([
2013
+ " environments",
2014
+ plan2.suggestedEnvironments.length === 0 ? "" : `[${plan2.suggestedEnvironments.join(", ")}]`,
2015
+ plan2.suggestedEnvironments.length === 0 ? "<- name them, or Enter to leave the whitelist empty" : "<- from your .env files; edit, or Enter to accept"
2016
+ ]);
2017
+ rows.push([
2018
+ " schemaFile",
2019
+ plan2.decisions.schemaFile,
2020
+ plan2.decisions.schemaFile === import_core13.DEFAULT_SCHEMA_FILE ? "" : `(default: ${import_core13.DEFAULT_SCHEMA_FILE})`
2021
+ ]);
2022
+ for (const prefix of plan2.decisions.publicPrefixes) {
2023
+ rows.push([" publicPrefix", prefix, ""]);
2024
+ }
2025
+ const headline = plan2.detected === void 0 ? "No framework detected in package.json." : `Detected ${plan2.detected.name}.`;
2026
+ return [headline, "", ...columns(rows), ""];
2027
+ }
2028
+ function environmentsHint(plan2) {
2029
+ return plan2.suggestedEnvironments.length === 0 ? "environments (comma-separated, Enter for none) > " : 'environments (Enter to accept, "none" for an empty whitelist) > ';
2030
+ }
2031
+ async function promptForDecisions(plan2, io) {
2032
+ for (const line of renderPlan(plan2)) {
2033
+ io.write(line);
2034
+ }
2035
+ const answer = (await io.ask(environmentsHint(plan2))).trim();
2036
+ const environments = answer.length === 0 ? plan2.suggestedEnvironments : answer.toLowerCase() === "none" ? [] : splitEnvironments(answer);
2037
+ if (answer.length > 0) {
2038
+ io.write("");
2039
+ io.write(
2040
+ environments.length === 0 ? " environments [] (declare them later in penv.config.ts)" : ` environments [${environments.join(", ")}]`
2041
+ );
2042
+ io.write("");
2043
+ }
2044
+ const proceed = (await io.ask("Proceed? [Y/n] ")).trim().toLowerCase();
2045
+ if (proceed.length > 0 && proceed !== "y" && proceed !== "yes") {
2046
+ return void 0;
2047
+ }
2048
+ return { ...plan2.decisions, environments };
2049
+ }
2050
+ var EMPTY_SCHEMA_BODY = " // One key per parameter, e.g. `databaseUrl: z.url(),`. Nesting a key nests\n // the parameter: `redis: z.object({ password: z.string() })` is redis/password.";
2051
+ var DRAFT_HEADER = "// DRAFT \u2014 generated by `penv import` from one sample of each value, and yours\n// to correct. Single-sample inference cannot know that a boolean seen as `true`\n// must also accept `1`/`0`, or that a string is really a URL. penv scaffolds\n// this file once and never regenerates it, so edits here are safe.\n";
2052
+ function renderSchemaModule(fields, draft) {
2053
+ const body = fields.length === 0 ? EMPTY_SCHEMA_BODY : fields.map((field) => ` ${field.key}: ${field.type},`).join("\n");
2054
+ return `${draft ? DRAFT_HEADER : ""}import { z } from "zod";
2055
+ import { load } from "@penvhq/penv";
2056
+
2057
+ // The shape. Import this (or z.infer<typeof schema>) when you only need the
2058
+ // type \u2014 tests, tooling \u2014 so you don't trigger config loading.
2059
+ export const schema = z.object({
2060
+ ${body}
2061
+ });
2062
+
2063
+ // The loaded, validated values for the current environment. Import this in app
2064
+ // code. Importing it loads configuration and throws (naming the parameter and
2065
+ // environment) if anything required is missing or invalid.
2066
+ export const env = load(schema);
2067
+ `;
2068
+ }
2069
+ function renderEnvironments(decisions) {
2070
+ const shared = " // Environments are a whitelist. A filename segment is an environment only if\n // it is declared here \u2014 penv never infers one from a folder or a filename.\n";
2071
+ if (decisions.environments.length === 0) {
2072
+ return `${shared} // It starts empty because which environments you deploy is not something penv
2073
+ // can read off your codebase, and an environment you do not have is worse
2074
+ // than one you have not declared yet. Name yours, and give each a provider:
2075
+ // environments: ["development", "production"],
2076
+ // providers: { development: { type: "filesystem" }, production: { type: "filesystem" } },
2077
+ environments: [],
2078
+
2079
+ providers: {},
2080
+ `;
2081
+ }
2082
+ const names = decisions.environments.map((name) => JSON.stringify(name)).join(", ");
2083
+ const providers = decisions.environments.map((name) => ` ${JSON.stringify(name)}: { type: "filesystem" },
2084
+ `).join("");
2085
+ return `${shared} environments: [${names}],
2086
+
2087
+ // One entry per environment: where that environment's values are read from.
2088
+ providers: {
2089
+ ${providers} },
2090
+ `;
2091
+ }
2092
+ function renderConfigModule(decisions) {
2093
+ let body = renderEnvironments(decisions);
2094
+ if (decisions.schemaFile !== import_core13.DEFAULT_SCHEMA_FILE) {
2095
+ body += `
2096
+ // The module that exports the schema. It is yours \u2014 penv scaffolds it once
2097
+ // and never regenerates it \u2014 so this says where you keep it.
2098
+ schemaFile: ${JSON.stringify(decisions.schemaFile)},
2099
+ `;
2100
+ }
2101
+ if (decisions.publicPrefixes.length > 0) {
2102
+ const prefixes = decisions.publicPrefixes.map((prefix) => JSON.stringify(prefix)).join(", ");
2103
+ body += `
2104
+ // The prefixes your framework inlines into the browser bundle. \`penv doctor\`
2105
+ // reports a parameter your meta declares \`secret: true\` whose variable name
2106
+ // starts with one of these \u2014 penv is the only thing holding both facts.
2107
+ publicPrefixes: [${prefixes}],
2108
+ `;
2109
+ }
2110
+ return `import { defineConfig } from "@penvhq/penv";
2111
+
2112
+ export default defineConfig({
2113
+ ${body}});
2114
+ `;
2115
+ }
2116
+ function renderGitignore(decisions) {
2117
+ const inside = (0, import_core13.schemaInsideTree)(configOf(decisions));
2118
+ const listed = inside === void 0 ? "" : `${inside}, `;
2119
+ return `# Written by penv. Value files hold configuration values and are never
2120
+ # committed; only the structure, ${listed}meta, and config are.
2121
+ *
2122
+ !*/
2123
+ !.gitignore
2124
+ ${inside === void 0 ? "" : `!${inside}
2125
+ `}!*.json
2126
+ `;
2127
+ }
2128
+ function skipTrivia(source, index) {
2129
+ let i = index;
2130
+ for (; ; ) {
2131
+ const ch = source.charAt(i);
2132
+ if (ch === " " || ch === " " || ch === "\n" || ch === "\r") {
2133
+ i += 1;
2134
+ continue;
2135
+ }
2136
+ if (ch === "/" && source.charAt(i + 1) === "/") {
2137
+ const end = source.indexOf("\n", i);
2138
+ i = end === -1 ? source.length : end + 1;
2139
+ continue;
2140
+ }
2141
+ if (ch === "/" && source.charAt(i + 1) === "*") {
2142
+ const end = source.indexOf("*/", i + 2);
2143
+ i = end === -1 ? source.length : end + 2;
2144
+ continue;
2145
+ }
2146
+ return i;
2147
+ }
2148
+ }
2149
+ function endOfString(source, index) {
2150
+ let i = index + 1;
2151
+ while (i < source.length) {
2152
+ const ch = source.charAt(i);
2153
+ if (ch === "\\") {
2154
+ i += 2;
2155
+ continue;
2156
+ }
2157
+ if (ch === '"') {
2158
+ return i + 1;
2159
+ }
2160
+ i += 1;
2161
+ }
2162
+ return source.length;
2163
+ }
2164
+ function endOfBracket(source, index) {
2165
+ const open = source.charAt(index);
2166
+ const close = open === "{" ? "}" : "]";
2167
+ let depth = 0;
2168
+ let i = index;
2169
+ while (i < source.length) {
2170
+ const ch = source.charAt(i);
2171
+ if (ch === '"') {
2172
+ i = endOfString(source, i);
2173
+ continue;
2174
+ }
2175
+ if (ch === "/" && (source.charAt(i + 1) === "/" || source.charAt(i + 1) === "*")) {
2176
+ i = skipTrivia(source, i);
2177
+ continue;
2178
+ }
2179
+ if (ch === open) {
2180
+ depth += 1;
2181
+ i += 1;
2182
+ continue;
2183
+ }
2184
+ if (ch === close) {
2185
+ depth -= 1;
2186
+ i += 1;
2187
+ if (depth === 0) {
2188
+ return i;
2189
+ }
2190
+ continue;
2191
+ }
2192
+ i += 1;
2193
+ }
2194
+ return source.length;
2195
+ }
2196
+ function endOfValue(source, index) {
2197
+ const ch = source.charAt(index);
2198
+ if (ch === '"') {
2199
+ return endOfString(source, index);
2200
+ }
2201
+ if (ch === "{" || ch === "[") {
2202
+ return endOfBracket(source, index);
2203
+ }
2204
+ let i = index;
2205
+ while (i < source.length) {
2206
+ const c = source.charAt(i);
2207
+ if (c === "," || c === "}" || c === "]" || c === "\n") {
2208
+ return i;
2209
+ }
2210
+ i += 1;
2211
+ }
2212
+ return source.length;
2213
+ }
2214
+ function findMember(source, open, key) {
2215
+ const close = endOfBracket(source, open) - 1;
2216
+ let i = skipTrivia(source, open + 1);
2217
+ while (i < close) {
2218
+ if (source.charAt(i) !== '"') {
2219
+ return void 0;
2220
+ }
2221
+ const keyEnd = endOfString(source, i);
2222
+ const name = source.slice(i + 1, keyEnd - 1);
2223
+ const colon = skipTrivia(source, keyEnd);
2224
+ if (source.charAt(colon) !== ":") {
2225
+ return void 0;
2226
+ }
2227
+ const valueStart = skipTrivia(source, colon + 1);
2228
+ const valueEnd = endOfValue(source, valueStart);
2229
+ if (name === key) {
2230
+ return { valueStart };
2231
+ }
2232
+ let next = skipTrivia(source, valueEnd);
2233
+ if (source.charAt(next) === ",") {
2234
+ next = skipTrivia(source, next + 1);
2235
+ }
2236
+ i = next;
2237
+ }
2238
+ return void 0;
2239
+ }
2240
+ function lineIndent(source, index) {
2241
+ const lineStart = source.lastIndexOf("\n", index) + 1;
2242
+ const match = /^[ \t]*/.exec(source.slice(lineStart, index));
2243
+ return match?.[0] ?? "";
2244
+ }
2245
+ function indentUnit(source) {
2246
+ const match = /\n([ \t]+)"/.exec(source);
2247
+ return match?.[1] ?? " ";
2248
+ }
2249
+ function insertMember(source, open, member, unit) {
2250
+ const objectIndent = lineIndent(source, open);
2251
+ const entryIndent = objectIndent + unit;
2252
+ const close = endOfBracket(source, open) - 1;
2253
+ if (skipTrivia(source, open + 1) === close) {
2254
+ return `${source.slice(0, open + 1)}
2255
+ ${entryIndent}${member}
2256
+ ${objectIndent}${source.slice(close)}`;
2257
+ }
2258
+ return `${source.slice(0, open + 1)}
2259
+ ${entryIndent}${member},${source.slice(open + 1)}`;
2260
+ }
2261
+ function shapeError(what, target, alias) {
2262
+ return new import_core13.PenvError(
2263
+ "TSCONFIG_SHAPE",
2264
+ `penv cannot add the \`${alias}\` path alias to tsconfig.json: ${what}`,
2265
+ `Add it by hand: \`{ "compilerOptions": { "paths": { "${alias}": ["${target}"] } } }\`.`
2266
+ );
2267
+ }
2268
+ function insertEnvAlias(source, target = import_core13.DEFAULT_SCHEMA_FILE, name = DEFAULT_ALIAS) {
2269
+ const alias = `"${name}": ["${target}"]`;
2270
+ const root = skipTrivia(source, 0);
2271
+ if (source.charAt(root) !== "{") {
2272
+ throw shapeError("its contents are not a JSON object", target, name);
2273
+ }
2274
+ const unit = indentUnit(source);
2275
+ const compilerOptions = findMember(source, root, "compilerOptions");
2276
+ if (compilerOptions === void 0) {
2277
+ return {
2278
+ source: insertMember(source, root, `"compilerOptions": { "paths": { ${alias} } }`, unit),
2279
+ changed: true
2280
+ };
2281
+ }
2282
+ if (source.charAt(compilerOptions.valueStart) !== "{") {
2283
+ throw shapeError("`compilerOptions` is not an object", target, name);
2284
+ }
2285
+ const paths = findMember(source, compilerOptions.valueStart, "paths");
2286
+ if (paths === void 0) {
2287
+ return {
2288
+ source: insertMember(source, compilerOptions.valueStart, `"paths": { ${alias} }`, unit),
2289
+ changed: true
2290
+ };
2291
+ }
2292
+ if (source.charAt(paths.valueStart) !== "{") {
2293
+ throw shapeError("`compilerOptions.paths` is not an object", target, name);
2294
+ }
2295
+ const existing = findMember(source, paths.valueStart, name);
2296
+ if (existing !== void 0) {
2297
+ const points = source.slice(existing.valueStart, endOfValue(source, existing.valueStart));
2298
+ return points.includes(`"${target}"`) ? { source, changed: false } : { source, changed: false, conflict: points.trim() };
2299
+ }
2300
+ return { source: insertMember(source, paths.valueStart, alias, unit), changed: true };
2301
+ }
2302
+ function insertImportsAlias(source, target, name) {
2303
+ const entry = `"${name}": "./${target}"`;
2304
+ const root = skipTrivia(source, 0);
2305
+ if (source.charAt(root) !== "{") {
2306
+ throw shapeError("its contents are not a JSON object", target, name);
2307
+ }
2308
+ const unit = indentUnit(source);
2309
+ const imports = findMember(source, root, "imports");
2310
+ if (imports === void 0) {
2311
+ return { source: insertMember(source, root, `"imports": { ${entry} }`, unit), changed: true };
2312
+ }
2313
+ if (source.charAt(imports.valueStart) !== "{") {
2314
+ throw shapeError("`imports` is not an object", target, name);
2315
+ }
2316
+ const existing = findMember(source, imports.valueStart, name);
2317
+ if (existing !== void 0) {
2318
+ const points = source.slice(existing.valueStart, endOfValue(source, existing.valueStart));
2319
+ return points.includes(`"./${target}"`) ? { source, changed: false } : { source, changed: false, conflict: points.trim() };
2320
+ }
2321
+ return { source: insertMember(source, imports.valueStart, entry, unit), changed: true };
2322
+ }
2323
+ function renderTsconfig(target, alias) {
2324
+ return `{
2325
+ "compilerOptions": {
2326
+ "paths": { "${alias}": ["${target}"] }
2327
+ }
2328
+ }
2329
+ `;
2330
+ }
2331
+ function ensurePenvDir(root) {
2332
+ const dir = (0, import_node_path5.resolve)(root, PENV_DIR2);
2333
+ if ((0, import_node_fs3.existsSync)(dir)) {
2334
+ return { target: "penv-dir", action: "kept", text: `Found ${PENV_DIR2}/` };
2335
+ }
2336
+ (0, import_node_fs3.mkdirSync)(dir, { recursive: true });
2337
+ return { target: "penv-dir", action: "created", text: `Created ${PENV_DIR2}/` };
2338
+ }
2339
+ function writeSchemaFile(root, fields, draft, decisions = DEFAULT_DECISIONS) {
2340
+ const file = (0, import_node_path5.join)(root, ...decisions.schemaFile.split("/"));
2341
+ if ((0, import_node_fs3.existsSync)(file)) {
2342
+ return {
2343
+ target: "schema",
2344
+ action: "kept",
2345
+ text: `Kept ${decisions.schemaFile}`,
2346
+ note: "(yours \u2014 penv never regenerates it)"
2347
+ };
2348
+ }
2349
+ (0, import_node_fs3.mkdirSync)((0, import_node_path5.dirname)(file), { recursive: true });
2350
+ (0, import_node_fs3.writeFileSync)(file, renderSchemaModule(fields, draft), "utf8");
2351
+ return {
2352
+ target: "schema",
2353
+ action: "created",
2354
+ text: `Generated ${decisions.schemaFile}`,
2355
+ note: draft ? "(draft schema \u2014 review it, it's yours)" : "(schema + loader \u2014 yours to edit)"
2356
+ };
2357
+ }
2358
+ function writeConfigFile(root, decisions = DEFAULT_DECISIONS) {
2359
+ const file = (0, import_node_path5.join)(root, CONFIG_FILE);
2360
+ if ((0, import_node_fs3.existsSync)(file)) {
2361
+ return { target: "config", action: "kept", text: `Kept ${CONFIG_FILE}` };
2362
+ }
2363
+ (0, import_node_fs3.writeFileSync)(file, renderConfigModule(decisions), "utf8");
2364
+ return { target: "config", action: "created", text: `Generated ${CONFIG_FILE}` };
2365
+ }
2366
+ function writeTsconfigAlias(root, decisions = DEFAULT_DECISIONS) {
2367
+ const alias = decisions.alias;
2368
+ const imports = alias.startsWith(IMPORTS_PREFIX);
2369
+ const file = (0, import_node_path5.join)(root, imports ? PACKAGE_FILE : TSCONFIG_FILE);
2370
+ const where = imports ? PACKAGE_FILE : TSCONFIG_FILE;
2371
+ if (!(0, import_node_fs3.existsSync)(file)) {
2372
+ if (imports) {
2373
+ return {
2374
+ target: "tsconfig",
2375
+ action: "conflicted",
2376
+ text: `No ${PACKAGE_FILE} to add the ${alias} import to`,
2377
+ note: `(run \`npm init\` first, or use \`--alias @env\` to alias through ${TSCONFIG_FILE})`
2378
+ };
2379
+ }
2380
+ (0, import_node_fs3.writeFileSync)(file, renderTsconfig(decisions.schemaFile, alias), "utf8");
2381
+ return {
2382
+ target: "tsconfig",
2383
+ action: "created",
2384
+ text: `Created ${TSCONFIG_FILE} with the ${alias} path alias`
2385
+ };
2386
+ }
2387
+ const source = (0, import_node_fs3.readFileSync)(file, "utf8");
2388
+ const edit = imports ? insertImportsAlias(source, decisions.schemaFile, alias) : insertEnvAlias(source, decisions.schemaFile, alias);
2389
+ if (edit.conflict !== void 0) {
2390
+ return {
2391
+ target: "tsconfig",
2392
+ action: "conflicted",
2393
+ text: `${where} already maps ${alias} to ${edit.conflict}`,
2394
+ note: `(left alone \u2014 \`import { env } from "${alias}"\` will not reach ${decisions.schemaFile})`
2395
+ };
2396
+ }
2397
+ if (!edit.changed) {
2398
+ return {
2399
+ target: "tsconfig",
2400
+ action: "kept",
2401
+ text: `Kept the ${alias} alias in ${where}`
2402
+ };
2403
+ }
2404
+ (0, import_node_fs3.writeFileSync)(file, edit.source, "utf8");
2405
+ return {
2406
+ target: "tsconfig",
2407
+ action: "updated",
2408
+ text: `Added ${alias} alias to ${where}`
2409
+ };
2410
+ }
2411
+ function writeGitignore(root, decisions = DEFAULT_DECISIONS) {
2412
+ const file = (0, import_node_path5.join)(root, PENV_DIR2, GITIGNORE_FILE);
2413
+ const relative3 = `${PENV_DIR2}/${GITIGNORE_FILE}`;
2414
+ const wanted = renderGitignore(decisions);
2415
+ const existing = (0, import_node_fs3.existsSync)(file) ? (0, import_node_fs3.readFileSync)(file, "utf8") : void 0;
2416
+ if (existing === wanted) {
2417
+ return { target: "gitignore", action: "kept", text: `Kept ${relative3}` };
2418
+ }
2419
+ (0, import_node_fs3.mkdirSync)((0, import_node_path5.join)(root, PENV_DIR2), { recursive: true });
2420
+ (0, import_node_fs3.writeFileSync)(file, wanted, "utf8");
2421
+ return {
2422
+ target: "gitignore",
2423
+ action: existing === void 0 ? "created" : "updated",
2424
+ text: `${existing === void 0 ? "Created" : "Updated"} ${relative3}`
2425
+ };
2426
+ }
2427
+ function scaffold(root, fields, draft, decisions = DEFAULT_DECISIONS) {
2428
+ return [
2429
+ ensurePenvDir(root),
2430
+ writeSchemaFile(root, fields, draft, decisions),
2431
+ writeConfigFile(root, decisions),
2432
+ writeTsconfigAlias(root, decisions),
2433
+ writeGitignore(root, decisions)
2434
+ ];
2435
+ }
2436
+ function runInit(options) {
2437
+ const root = (0, import_node_path5.resolve)(options.cwd);
2438
+ const decisions = options.decisions ?? planInit(root).decisions;
2439
+ return { root, decisions, steps: scaffold(root, [], false, decisions) };
2440
+ }
2441
+ function renderInit(result) {
2442
+ const steps = result.steps.map((step) => {
2443
+ const glyph = step.action === "conflicted" ? WARN : CHECK;
2444
+ return step.note === void 0 ? { glyph, text: step.text } : { glyph, text: step.text, note: step.note };
2445
+ });
2446
+ return [
2447
+ ...formatSteps(steps),
2448
+ "",
2449
+ `Done. Declare your parameters in ${result.decisions.schemaFile}, then \`penv set <key>\`.`,
2450
+ ...result.decisions.environments.length === 0 ? [
2451
+ `Then declare your environments in ${CONFIG_FILE}: penv leaves the whitelist empty rather than inventing one, and every command needs it.`
2452
+ ] : []
2453
+ ];
2454
+ }
2455
+ async function askOnTty(plan2) {
2456
+ const rl = (0, import_promises.createInterface)({ input: process.stdin, output: process.stdout });
2457
+ try {
2458
+ return await promptForDecisions(plan2, {
2459
+ ask: (question) => rl.question(question),
2460
+ write: (line) => process.stdout.write(`${line}
2461
+ `)
2462
+ });
2463
+ } finally {
2464
+ rl.close();
2465
+ }
2466
+ }
2467
+ var initCommand = (0, import_citty8.defineCommand)({
2468
+ meta: { name: "init", description: "Initialize a project (.penv/, env.ts, config, @env alias)" },
2469
+ args: {
2470
+ yes: {
2471
+ type: "boolean",
2472
+ description: "Take the detected defaults without asking. Environments still start empty \u2014 penv cannot see your infrastructure"
2473
+ },
2474
+ schema: {
2475
+ type: "string",
2476
+ description: `Where the schema module goes, e.g. src/env.ts (default: ${import_core13.DEFAULT_SCHEMA_FILE})`
2477
+ },
2478
+ alias: {
2479
+ type: "string",
2480
+ description: "How your code names the schema: @env (tsconfig paths, needs a bundler) or #env (package.json imports, resolved by node itself)"
2481
+ },
2482
+ env: {
2483
+ type: "string",
2484
+ description: "Declare an environment. Repeatable, or comma-separated: --env development,production"
2485
+ }
2486
+ },
2487
+ run({ args }) {
2488
+ return guard(async () => {
2489
+ const root = (0, import_node_path5.resolve)(process.cwd());
2490
+ const environments = environmentsFromFlag(args.env);
2491
+ const plan2 = planInit(root, {
2492
+ ...args.schema === void 0 ? {} : { schema: args.schema },
2493
+ ...args.alias === void 0 ? {} : { alias: args.alias },
2494
+ ...environments === void 0 ? {} : { environments }
2495
+ });
2496
+ const asked = process.stdin.isTTY === true && args.yes !== true && environments === void 0;
2497
+ const decisions = asked ? await askOnTty(plan2) : plan2.decisions;
2498
+ if (decisions === void 0) {
2499
+ write(["Nothing written. Re-run `penv init` when you want to scaffold."]);
2500
+ return;
2501
+ }
2502
+ if (!asked) {
2503
+ write([...plan2.notes, ""]);
2504
+ }
2505
+ write(renderInit(runInit({ cwd: root, decisions })));
2506
+ });
2507
+ }
2508
+ });
2509
+
2510
+ // src/commands/import.ts
2511
+ var BACKUP_SUFFIX = ".backup";
2512
+ var DOTENV_SEGMENT = "env";
2513
+ var LOCAL = "local";
2514
+ var URL_LIKE = /^[a-z][a-z0-9+.-]*:\/\/\S+$/i;
2515
+ var IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
2516
+ function inferType(value) {
2517
+ if (URL_LIKE.test(value)) {
2518
+ return "z.url()";
2519
+ }
2520
+ if (/^(true|false)$/i.test(value)) {
2521
+ return "z.stringbool()";
2522
+ }
2523
+ if (value.trim() !== "" && Number.isFinite(Number(value))) {
2524
+ return "z.coerce.number()";
2525
+ }
2526
+ return "z.string()";
2527
+ }
2528
+ function draftFields(entries) {
2529
+ return entries.map((entry) => {
2530
+ const key = (0, import_core14.accessPath)((0, import_core14.refFromVariable)(entry.key)).join(".");
2531
+ return {
2532
+ key: IDENTIFIER.test(key) ? key : JSON.stringify(key),
2533
+ type: inferType(entry.value)
2534
+ };
2535
+ }).sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
2536
+ }
2537
+ function assertImportable(ref, variable) {
2538
+ if (!ref.name.includes(".")) {
2539
+ return;
2540
+ }
2541
+ throw new import_core14.PenvError(
2542
+ "IMPORT_UNPARSEABLE_NAME",
2543
+ `The variable ${variable} becomes the parameter \`${ref.name}\`, whose \`.\` would be read as a scope`,
2544
+ `Filenames are split on \`.\`. Rename ${variable} in the source file, then import it again.`
2545
+ );
2546
+ }
2547
+ function assertNotReserved(ref, variable, where, config) {
2548
+ if ((0, import_core14.isReservedToken)(ref.name, config)) {
2549
+ throw new import_core14.ReservedTokenError("parameter", variable, where);
2550
+ }
2551
+ }
2552
+ function assertRoundTrips(ref, variable, config) {
2553
+ if ((0, import_core14.roundTripsCleanly)(variable)) {
2554
+ return;
2555
+ }
2556
+ if ((0, import_core14.variableName)(ref, config) === variable) {
2557
+ return;
2558
+ }
2559
+ const generated = (0, import_core14.variableName)(ref, config);
2560
+ throw new import_core14.PenvError(
2561
+ "IMPORT_LOSSY_NAME",
2562
+ `The variable ${variable} becomes the parameter \`${ref.name}\`, which regenerates as ${generated}`,
2563
+ `\`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.`
2564
+ );
2565
+ }
2566
+ function collisionsIn(refs, config) {
2567
+ const errors = (0, import_core14.checkNameCollisions)(refs, config);
2568
+ const first = errors[0];
2569
+ if (first !== void 0) {
2570
+ throw first;
2571
+ }
2572
+ }
2573
+ function assertDeclared(segment, config) {
2574
+ if (config.environments.includes(segment)) {
2575
+ return segment;
2576
+ }
2577
+ throw new import_core14.UnknownEnvironmentError(segment, config.environments);
2578
+ }
2579
+ function scopeFromFilename(file, config) {
2580
+ const name = (0, import_node_path6.basename)(file);
2581
+ const segments = name.split(".");
2582
+ const start = segments.indexOf(DOTENV_SEGMENT);
2583
+ if (start === -1) {
2584
+ return { kind: "unscoped" };
2585
+ }
2586
+ const rest = segments.slice(start + 1).filter((segment) => segment.length > 0);
2587
+ const first = rest[0];
2588
+ const second = rest[1];
2589
+ if (first === void 0) {
2590
+ return { kind: "unscoped" };
2591
+ }
2592
+ if (second === void 0) {
2593
+ if (first === LOCAL) {
2594
+ return { kind: LOCAL };
2595
+ }
2596
+ return { kind: "environment", environment: assertDeclared(first, config) };
2597
+ }
2598
+ if (rest.length === 2 && second === LOCAL && first !== LOCAL) {
2599
+ return { kind: "environment-local", environment: assertDeclared(first, config) };
2600
+ }
2601
+ if (first === LOCAL && rest.length === 2) {
2602
+ throw new import_core14.FilenameGrammarError(
2603
+ name,
2604
+ "`local` precedes the environment segment",
2605
+ `The environment segment always precedes \`local\` \u2014 \`.env.${second}.${LOCAL}\` is the file Next.js and Vite read, and \`.env.${LOCAL}.${second}\` is not a synonym for it. Rename it to \`.env.${second}.${LOCAL}\`, then import it again. Nothing was imported.`
2606
+ );
2607
+ }
2608
+ throw new import_core14.FilenameGrammarError(
2609
+ name,
2610
+ `\`${rest.join("` and `")}\` are ${rest.length} scope segments`,
2611
+ "A dotenv file carries exactly one scope: `.env`, `.env.<environment>`, `.env.local`, or `.env.<environment>.local`. Point `penv import` at one of those. Nothing was imported."
2612
+ );
2613
+ }
2614
+ function environmentOf(scope) {
2615
+ switch (scope.kind) {
2616
+ case "environment":
2617
+ case "environment-local":
2618
+ return scope.environment;
2619
+ case "unscoped":
2620
+ case "local":
2621
+ return void 0;
2622
+ default:
2623
+ return (0, import_core14.assertNever)(scope, "scope");
2624
+ }
2625
+ }
2626
+ function scopeWithEnvironment(scope, environment) {
2627
+ switch (scope.kind) {
2628
+ case "unscoped":
2629
+ return { kind: "environment", environment };
2630
+ case LOCAL:
2631
+ return { kind: "environment-local", environment };
2632
+ case "environment":
2633
+ case "environment-local":
2634
+ return scope;
2635
+ default:
2636
+ return (0, import_core14.assertNever)(scope, "scope");
2637
+ }
2638
+ }
2639
+ function explicitEnvironment(options, source, config) {
2640
+ const value = options.environment?.trim() ?? "";
2641
+ if (value.length > 0) {
2642
+ return value;
2643
+ }
2644
+ throw new import_core14.PenvError(
2645
+ "IMPORT_ENV_FLAG_EMPTY",
2646
+ `\`--env\` for the import of ${source} names no environment`,
2647
+ `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.`
2648
+ );
2649
+ }
2650
+ function assertEnvironmentAgrees(derived, explicit, source) {
2651
+ if (derived === void 0 || explicit === void 0 || derived === explicit) {
2652
+ return;
2653
+ }
2654
+ throw new import_core14.PenvError(
2655
+ "IMPORT_ENV_CONFLICT",
2656
+ `The file ${source} is scoped to environment ${derived}, but \`--env ${explicit}\` names ${explicit}`,
2657
+ `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.`
2658
+ );
2659
+ }
2660
+ function environmentNamed(file, explicit) {
2661
+ if (explicit !== void 0 && explicit.trim().length > 0) {
2662
+ return explicit.trim();
2663
+ }
2664
+ const segments = (0, import_node_path6.basename)(file).split(".");
2665
+ const start = segments.indexOf(DOTENV_SEGMENT);
2666
+ if (start === -1) {
2667
+ return void 0;
2668
+ }
2669
+ const first = segments.slice(start + 1).filter((segment) => segment.length > 0)[0];
2670
+ return first === void 0 || first === LOCAL ? void 0 : first;
2671
+ }
2672
+ function decisionsOf(config, cwd) {
2673
+ return {
2674
+ environments: config.environments,
2675
+ schemaFile: (0, import_core14.schemaFileOf)(config),
2676
+ publicPrefixes: config.publicPrefixes ?? [],
2677
+ alias: detectAlias(cwd)
2678
+ };
2679
+ }
2680
+ function configInEffect(cwd, environment) {
2681
+ const existing = (0, import_core14.findConfigFile)(cwd);
2682
+ if (existing !== void 0) {
2683
+ const config = (0, import_core14.loadConfigFrom)(existing);
2684
+ return { config, decisions: decisionsOf(config, cwd) };
2685
+ }
2686
+ const planned = planInit(cwd).decisions;
2687
+ const decisions = {
2688
+ ...planned,
2689
+ environments: environment === void 0 ? planned.environments : [environment]
2690
+ };
2691
+ writeConfigFile(cwd, decisions);
2692
+ return { config: openProject(cwd).config, decisions };
2693
+ }
2694
+ function importDotenv(options) {
2695
+ const cwd = (0, import_node_path6.resolve)(options.cwd);
2696
+ const file = (0, import_node_path6.isAbsolute)(options.file) ? options.file : (0, import_node_path6.resolve)(cwd, options.file);
2697
+ if (!(0, import_node_fs4.existsSync)(file)) {
2698
+ throw new import_core14.PenvError(
2699
+ "IMPORT_FILE_MISSING",
2700
+ `There is no file at ${file} to import`,
2701
+ "Point `penv import` at an existing dotenv file, e.g. `penv import .env`."
2702
+ );
2703
+ }
2704
+ const parsed = (0, import_core14.parseDotenv)((0, import_node_fs4.readFileSync)(file, "utf8"));
2705
+ const { config, decisions } = configInEffect(cwd, environmentNamed(file, options.environment));
2706
+ const source = displayPath2(cwd, file);
2707
+ const named = scopeFromFilename(file, config);
2708
+ const derived = environmentOf(named);
2709
+ const explicit = options.environment === void 0 ? void 0 : explicitEnvironment(options, source, config);
2710
+ assertEnvironmentAgrees(derived, explicit, source);
2711
+ const scope = explicit === void 0 ? named : scopeWithEnvironment(named, assertDeclared(explicit, config));
2712
+ const environment = (0, import_core14.lookupEnvironment)(config, explicit ?? derived);
2713
+ const refs = [];
2714
+ for (const entry of parsed.entries) {
2715
+ const ref = (0, import_core14.refFromVariable)(entry.key);
2716
+ assertImportable(ref, entry.key);
2717
+ assertNotReserved(ref, entry.key, source, config);
2718
+ assertRoundTrips(ref, entry.key, config);
2719
+ refs.push(ref);
2720
+ }
2721
+ collisionsIn(refs, config);
2722
+ const steps = scaffold(cwd, draftFields(parsed.entries), true, decisions);
2723
+ const project = openProject(cwd);
2724
+ const tree = localTree(project);
2725
+ for (const [index, entry] of parsed.entries.entries()) {
2726
+ const ref = refs[index];
2727
+ if (ref === void 0) {
2728
+ continue;
2729
+ }
2730
+ tree.writeSync(
2731
+ { namespace: ref.namespace, name: ref.name, scope, encrypted: false },
2732
+ entry.value
2733
+ );
2734
+ if (entry.description !== void 0) {
2735
+ const existing = tree.readMetaSync(ref);
2736
+ const meta = { ...existing, description: entry.description };
2737
+ tree.writeMetaSync(ref, meta);
2738
+ }
2739
+ }
2740
+ const backup = `${file}${BACKUP_SUFFIX}`;
2741
+ (0, import_node_fs4.copyFileSync)(file, backup);
2742
+ return {
2743
+ root: project.root,
2744
+ file,
2745
+ backup,
2746
+ scope,
2747
+ environment,
2748
+ environments: config.environments,
2749
+ variables: parsed.entries.length,
2750
+ orphanComments: parsed.orphanComments,
2751
+ steps
2752
+ };
2753
+ }
2754
+ function displayPath2(root, file) {
2755
+ const rel = (0, import_node_path6.relative)(root, file);
2756
+ return rel === "" || rel.startsWith("..") ? file : rel.split("\\").join("/");
2757
+ }
2758
+ function keptSchemaStep(step, variables) {
2759
+ const plural = variables === 1 ? "parameter" : "parameters";
2760
+ return {
2761
+ glyph: WARN,
2762
+ // The step's own text, which names the file that was actually kept. This
2763
+ // line rebuilt it from a hardcoded `.penv/env.ts`, so a project whose schema
2764
+ // lives in `src/` was told penv had kept a file it does not have — the one
2765
+ // line whose whole job is "your schema is untouched" naming the wrong
2766
+ // schema. Only the glyph and the note are this function's business.
2767
+ text: step.text,
2768
+ note: `(yours \u2014 ${variables} imported ${plural} undeclared, draft schema skipped)`
2769
+ };
2770
+ }
2771
+ function skippedValidationStep(environments) {
2772
+ const example = environments[0] ?? "<environment>";
2773
+ return {
2774
+ glyph: WARN,
2775
+ text: "Skipped validation",
2776
+ note: `(no environment set \u2014 run \`penv validate --env ${example}\`)`
2777
+ };
2778
+ }
2779
+ function renderImport(result, validation) {
2780
+ const steps = [{ glyph: CHECK, text: `Found ${result.variables} variables` }];
2781
+ if (result.orphanComments > 0) {
2782
+ const plural = result.orphanComments === 1 ? "comment" : "comments";
2783
+ steps.push({
2784
+ glyph: WARN,
2785
+ text: `Dropped ${result.orphanComments} orphan ${plural}`,
2786
+ note: "attached to no variable, so nothing to describe"
2787
+ });
2788
+ }
2789
+ for (const step of result.steps) {
2790
+ if (step.target === "schema" && step.action === "kept") {
2791
+ steps.push(keptSchemaStep(step, result.variables));
2792
+ continue;
2793
+ }
2794
+ const glyph = step.action === "conflicted" ? WARN : CHECK;
2795
+ steps.push(
2796
+ step.note === void 0 ? { glyph, text: step.text } : { glyph, text: step.text, note: step.note }
2797
+ );
2798
+ }
2799
+ steps.push({ glyph: CHECK, text: `Created ${displayPath2(result.root, result.backup)}` });
2800
+ const lines = formatSteps(steps);
2801
+ if (validation === void 0) {
2802
+ lines.push(...formatSteps([skippedValidationStep(result.environments)]));
2803
+ } else if (validation.ok) {
2804
+ lines.push(...formatSteps([{ glyph: CHECK, text: "Validated configuration" }]));
2805
+ } else {
2806
+ lines.push(...renderValidate(validation));
2807
+ }
2808
+ lines.push("", "Done. .penv/ is now your source of truth.");
2809
+ return lines;
2810
+ }
2811
+ var importCommand = (0, import_citty9.defineCommand)({
2812
+ meta: {
2813
+ name: "import",
2814
+ description: "Import an existing dotenv file; it becomes the source of truth"
2815
+ },
2816
+ args: {
2817
+ file: {
2818
+ type: "positional",
2819
+ required: true,
2820
+ description: "The dotenv file to import, e.g. .env"
2821
+ },
2822
+ env: {
2823
+ type: "string",
2824
+ description: "The environment these are the values of; scopes them to it. The filename supplies it when it carries one"
2825
+ }
2826
+ },
2827
+ run({ args }) {
2828
+ return guard(async () => {
2829
+ const cwd = process.cwd();
2830
+ const report = importDotenv({
2831
+ cwd,
2832
+ file: args.file,
2833
+ ...args.env === void 0 ? {} : { environment: args.env }
2834
+ });
2835
+ const validation = report.environment === void 0 ? void 0 : await runValidate({ cwd, environment: report.environment });
2836
+ write(renderImport(report, validation));
2837
+ });
2838
+ }
2839
+ });
2840
+
2841
+ // src/commands/key.ts
2842
+ var import_node_crypto = require("crypto");
2843
+ var import_core15 = require("@penvhq/core");
2844
+ var import_citty10 = require("citty");
2845
+
2846
+ // src/keychain.ts
2847
+ var import_node_module = require("module");
2848
+ var import_meta = {};
2849
+ var cached;
2850
+ function entryConstructor() {
2851
+ if (cached === void 0) {
2852
+ const require2 = (0, import_node_module.createRequire)(import_meta.url);
2853
+ cached = require2("@napi-rs/keyring").Entry;
2854
+ }
2855
+ return cached;
2856
+ }
2857
+ var defaultKeychain = {
2858
+ getPassword(service, account) {
2859
+ const Entry = entryConstructor();
2860
+ return new Entry(service, account).getPassword();
2861
+ },
2862
+ setPassword(service, account, password) {
2863
+ const Entry = entryConstructor();
2864
+ new Entry(service, account).setPassword(password);
2865
+ }
2866
+ };
2867
+
2868
+ // src/commands/key.ts
2869
+ function envVarFor(id) {
2870
+ return `PENV_KEY_${id.replace(/[^A-Za-z0-9]/g, "_").toUpperCase()}`;
2871
+ }
2872
+ function runKeyCreate(options) {
2873
+ const project = openProject(options.cwd);
2874
+ const environment = targetEnvironment(project, options.environment);
2875
+ const declared = project.config.keys?.[environment];
2876
+ if (declared === void 0) {
2877
+ throw new import_core15.PenvError(
2878
+ "KEY_SOURCE_UNDECLARED",
2879
+ `Environment ${environment} declares no key source, so penv does not know what a key for it would be`,
2880
+ `Add a \`keys\` entry to penv.config.ts \u2014 e.g. \`keys: { ${environment}: { source: "env", id: "${environment}" } }\` \u2014 then run this again.`
2881
+ );
2882
+ }
2883
+ const key = (0, import_node_crypto.randomBytes)(import_core15.KEY_BYTES).toString("base64");
2884
+ if (declared.source === "keychain") {
2885
+ const keychain = options.keychain ?? defaultKeychain;
2886
+ if (options.force !== true) {
2887
+ let existing;
2888
+ try {
2889
+ existing = keychain.getPassword(import_core15.KEYCHAIN_SERVICE, declared.id);
2890
+ } catch (cause) {
2891
+ throw new import_core15.PenvError(
2892
+ "KEYCHAIN_UNAVAILABLE",
2893
+ `penv could not read your OS keychain to check for an existing key \`${declared.id}\``,
2894
+ `Unlock your keychain and run this again. Original error: ${cause instanceof Error ? cause.message : String(cause)}`
2895
+ );
2896
+ }
2897
+ if (existing !== null) {
2898
+ throw new import_core15.PenvError(
2899
+ "KEY_EXISTS",
2900
+ `Environment ${environment} already has a key \`${declared.id}\` in your OS keychain`,
2901
+ "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."
2902
+ );
2903
+ }
2904
+ }
2905
+ keychain.setPassword(import_core15.KEYCHAIN_SERVICE, declared.id, key);
2906
+ return { source: "keychain", environment, id: declared.id };
2907
+ }
2908
+ return {
2909
+ source: "env",
2910
+ environment,
2911
+ id: declared.id,
2912
+ variable: envVarFor(declared.id),
2913
+ key
2914
+ };
2915
+ }
2916
+ function renderKeyCreate(result) {
2917
+ if (result.source === "keychain") {
2918
+ return [
2919
+ `A new key for environment ${result.environment}, stored in your OS keychain as \`${result.id}\`.`,
2920
+ "",
2921
+ "penv kept no copy. Anything sealed under it is unreadable without your keychain, and running",
2922
+ "`penv key create` again would replace it \u2014 so it lives in exactly one place, on this machine."
2923
+ ];
2924
+ }
2925
+ return [
2926
+ `A new key for environment ${result.environment}. penv did not store it.`,
2927
+ "",
2928
+ ` ${result.variable}=${result.key}`,
2929
+ "",
2930
+ "Export it where penv runs, and put it wherever this environment's secrets already live \u2014",
2931
+ "a KMS, your CI's secret store, a password manager. Anything sealed under it is unreadable",
2932
+ "without it, and penv keeps no copy to fall back on."
2933
+ ];
2934
+ }
2935
+ var keyCommand = (0, import_citty10.defineCommand)({
2936
+ meta: { name: "key", description: "Work with encryption keys" },
2937
+ subCommands: {
2938
+ create: (0, import_citty10.defineCommand)({
2939
+ meta: { name: "create", description: "Generate a key for an environment" },
2940
+ args: {
2941
+ env: { type: "string", description: "The environment the key is for" },
2942
+ force: {
2943
+ type: "boolean",
2944
+ description: "Replace an existing keychain key (orphans values sealed under the old one)"
2945
+ }
2946
+ },
2947
+ run({ args }) {
2948
+ return guard(async () => {
2949
+ write(
2950
+ renderKeyCreate(
2951
+ runKeyCreate({
2952
+ cwd: process.cwd(),
2953
+ ...args.env === void 0 ? {} : { environment: args.env },
2954
+ ...args.force === void 0 ? {} : { force: args.force }
2955
+ })
2956
+ )
2957
+ );
2958
+ });
2959
+ }
2960
+ })
2961
+ }
2962
+ });
2963
+
2964
+ // src/commands/list.ts
2965
+ var import_core16 = require("@penvhq/core");
2966
+ var import_citty11 = require("citty");
2967
+ function scopeLabel2(scope) {
2968
+ switch (scope.kind) {
2969
+ case "environment":
2970
+ return scope.environment;
2971
+ case "local":
2972
+ return "local";
2973
+ case "environment-local":
2974
+ return `${scope.environment}.local`;
2975
+ case "unscoped":
2976
+ return "default";
2977
+ default:
2978
+ return (0, import_core16.assertNever)(scope, "scope");
2979
+ }
2980
+ }
2981
+ async function runList(options) {
2982
+ const project = openProject(options.cwd);
2983
+ const environment = targetEnvironment(project, options.environment);
2984
+ const keys = keySourceFor(project, environment);
2985
+ const parameters = [];
2986
+ for (const resolution of await (0, import_core16.resolveAll)(environment, project.provider, keys)) {
2987
+ const winner = resolution.winner;
2988
+ const scope = winner?.file.scope;
2989
+ parameters.push({
2990
+ parameter: resolution.parameter,
2991
+ variable: (0, import_core16.variableName)(resolution.ref, project.config),
2992
+ scope: scope === void 0 ? "absent" : scopeLabel2(scope),
2993
+ location: winner?.location,
2994
+ encrypted: winner?.file.encrypted === true,
2995
+ viaUnscopedFallback: resolution.viaUnscopedFallback
2996
+ });
2997
+ }
2998
+ return { environment, parameters };
2999
+ }
3000
+ function renderList(result) {
3001
+ if (result.parameters.length === 0) {
3002
+ return [`No parameters in ${PENV_DIR}/ for environment ${result.environment}.`];
3003
+ }
3004
+ return columns(result.parameters.map((entry) => [entry.parameter, entry.scope, entry.variable]));
3005
+ }
3006
+ var listCommand = (0, import_citty11.defineCommand)({
3007
+ meta: { name: "list", description: "List parameters" },
3008
+ args: {
3009
+ env: { type: "string", description: "The environment to resolve against" },
3010
+ json: { type: "boolean", description: "Print machine-readable JSON" }
3011
+ },
3012
+ run({ args }) {
3013
+ return guard(async () => {
3014
+ const result = await runList({
3015
+ cwd: process.cwd(),
3016
+ ...args.env === void 0 ? {} : { environment: args.env }
3017
+ });
3018
+ write(args.json === true ? [JSON.stringify(result, null, 2)] : renderList(result));
3019
+ });
3020
+ }
3021
+ });
3022
+
3023
+ // src/commands/mv.ts
3024
+ var import_core17 = require("@penvhq/core");
3025
+ var import_citty12 = require("citty");
3026
+ function environmentOf2(file) {
3027
+ const scope = file.scope;
3028
+ return scope.kind === "environment" || scope.kind === "environment-local" ? scope.environment : void 0;
3029
+ }
3030
+ async function planFile(project, source, target, parameter) {
3031
+ const stored = await project.provider.read(source);
3032
+ if (stored === void 0) {
3033
+ return void 0;
3034
+ }
3035
+ if (!source.encrypted) {
3036
+ return { source, target, contents: stored, resealed: false };
3037
+ }
3038
+ const environment = environmentOf2(source);
3039
+ if (environment === void 0) {
3040
+ throw new import_core17.PenvError(
3041
+ "SECRET_SCOPE_AMBIGUOUS",
3042
+ `${PENV_DIR}/${(0, import_core17.formatValueFile)(source)} is encrypted at a scope that names no environment, so penv cannot tell which key would re-seal it`,
3043
+ "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."
3044
+ );
3045
+ }
3046
+ const keys = keySourceFor(project, environment);
3047
+ const opened = (0, import_core17.openValue)(source, stored, keys);
3048
+ if (opened.kind === "failed") {
3049
+ throw new import_core17.PenvError(
3050
+ "VALUE_UNDECRYPTABLE",
3051
+ `${PENV_DIR}/${(0, import_core17.formatValueFile)(source)} could not be decrypted, so penv cannot re-seal it at its new address: ${opened.failure.detail}`,
3052
+ "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."
3053
+ );
3054
+ }
3055
+ return {
3056
+ source,
3057
+ target,
3058
+ contents: (0, import_core17.sealValue)(target, opened.value, keys, parameter, environment),
3059
+ resealed: true
3060
+ };
3061
+ }
3062
+ function filesOf(all, ref) {
3063
+ const id = (0, import_core17.parameterId)(ref);
3064
+ return all.filter((file) => (0, import_core17.parameterId)(file) === id);
3065
+ }
3066
+ async function runMove(options) {
3067
+ const project = openProject(options.cwd);
3068
+ const from = refFromKey(options.from, project.config);
3069
+ const to = refFromKey(options.to, project.config);
3070
+ if ((0, import_core17.parameterId)(from) === (0, import_core17.parameterId)(to)) {
3071
+ throw new import_core17.PenvError(
3072
+ "PARAMETER_UNCHANGED",
3073
+ `\`${options.from}\` and \`${options.to}\` are the same parameter`,
3074
+ "Name a different destination, e.g. `penv mv redis-password redis/password`."
3075
+ );
3076
+ }
3077
+ const all = await project.provider.list();
3078
+ const sources = filesOf(all, from);
3079
+ const meta = await project.provider.readMeta(from);
3080
+ if (sources.length === 0 && meta === void 0) {
3081
+ throw new import_core17.PenvError(
3082
+ "PARAMETER_ABSENT",
3083
+ `Parameter ${(0, import_core17.parameterId)(from)} has no value files and no meta, so there is nothing to move`,
3084
+ `\`penv list\` shows every parameter penv holds.`
3085
+ );
3086
+ }
3087
+ const occupied2 = filesOf(all, to);
3088
+ if (occupied2.length > 0 || await project.provider.readMeta(to) !== void 0) {
3089
+ throw new import_core17.PenvError(
3090
+ "PARAMETER_EXISTS",
3091
+ `Parameter ${(0, import_core17.parameterId)(to)} already exists, and penv will not merge two parameters into one`,
3092
+ `Remove or rename ${(0, import_core17.parameterId)(to)} first. \`penv get ${options.to} --explain\` shows every file it holds.`
3093
+ );
3094
+ }
3095
+ const planned = [];
3096
+ for (const source of sources) {
3097
+ const target = { ...source, namespace: to.namespace, name: to.name };
3098
+ const one = await planFile(project, source, target, (0, import_core17.parameterId)(to));
3099
+ if (one !== void 0) {
3100
+ planned.push(one);
3101
+ }
3102
+ }
3103
+ for (const file of planned) {
3104
+ await project.provider.write(file.target, file.contents);
3105
+ }
3106
+ if (meta !== void 0) {
3107
+ await project.provider.writeMeta(to, meta);
3108
+ }
3109
+ for (const file of planned) {
3110
+ await project.provider.remove(file.source);
3111
+ }
3112
+ if (meta !== void 0) {
3113
+ await project.provider.removeMeta(from);
3114
+ }
3115
+ return {
3116
+ from: (0, import_core17.parameterId)(from),
3117
+ to: (0, import_core17.parameterId)(to),
3118
+ files: planned.map((file) => ({
3119
+ from: (0, import_core17.formatValueFile)(file.source),
3120
+ to: (0, import_core17.formatValueFile)(file.target),
3121
+ resealed: file.resealed
3122
+ })),
3123
+ // The meta's path, not the parameter's dotted id: `redis.password` is what
3124
+ // the schema calls it and `redis/password.json` is the file, and a report
3125
+ // that printed the first while moving the second names no file on disk.
3126
+ meta: meta === void 0 ? void 0 : (0, import_core17.formatMetaFile)({ ...to, format: "json" }),
3127
+ schema: { was: (0, import_core17.accessPath)(from).join("."), now: (0, import_core17.accessPath)(to).join(".") }
3128
+ };
3129
+ }
3130
+ function renderMove(result) {
3131
+ const rows = result.files.map((file) => ({
3132
+ glyph: CHECK,
3133
+ label: "Moved",
3134
+ subject: `${PENV_DIR}/${file.to}`,
3135
+ ...file.resealed ? { detail: "re-sealed for its new address" } : {}
3136
+ }));
3137
+ if (result.meta !== void 0) {
3138
+ rows.push({ glyph: CHECK, label: "Moved", subject: `${PENV_DIR}/${result.meta}` });
3139
+ }
3140
+ const lines = formatRows(rows);
3141
+ lines.push(
3142
+ "",
3143
+ ` .penv/env.ts still declares \`${result.schema.was}\`. Rename it to \`${result.schema.now}\`,`,
3144
+ " or `penv validate` will report the value as unused and the declaration as unset."
3145
+ );
3146
+ return lines;
3147
+ }
3148
+ var mvCommand = (0, import_citty12.defineCommand)({
3149
+ meta: { name: "mv", description: "Rename a parameter, every scope and its meta at once" },
3150
+ args: {
3151
+ from: {
3152
+ type: "positional",
3153
+ required: true,
3154
+ description: "The parameter now, e.g. redis-password"
3155
+ },
3156
+ to: {
3157
+ type: "positional",
3158
+ required: true,
3159
+ description: "The parameter after, e.g. redis/password"
3160
+ }
3161
+ },
3162
+ run({ args }) {
3163
+ return guard(async () => {
3164
+ write(renderMove(await runMove({ cwd: process.cwd(), from: args.from, to: args.to })));
3165
+ });
3166
+ }
3167
+ });
3168
+
3169
+ // src/commands/remove.ts
3170
+ var import_core18 = require("@penvhq/core");
3171
+ var import_citty13 = require("citty");
3172
+ async function runRemove(options) {
3173
+ const project = openProject(options.cwd);
3174
+ const ref = refFromKey(options.key);
3175
+ const scope = targetScope(project, options, options.key);
3176
+ const files = [false, true].map((encrypted) => ({
3177
+ namespace: ref.namespace,
3178
+ name: ref.name,
3179
+ scope,
3180
+ encrypted
3181
+ }));
3182
+ const removed = [];
3183
+ for (const file of files) {
3184
+ if (await project.provider.read(file) === void 0) {
3185
+ continue;
3186
+ }
3187
+ await project.provider.remove(file);
3188
+ removed.push((0, import_core18.formatValueFile)(file));
3189
+ }
3190
+ return {
3191
+ parameter: options.key,
3192
+ removed,
3193
+ considered: files.map((file) => (0, import_core18.formatValueFile)(file))
3194
+ };
3195
+ }
3196
+ function renderRemove(result) {
3197
+ if (result.removed.length === 0) {
3198
+ const first = result.considered[0] ?? result.parameter;
3199
+ return formatRows([
3200
+ {
3201
+ glyph: WARN,
3202
+ label: "Nothing to remove",
3203
+ subject: `${PENV_DIR}/${first}`,
3204
+ detail: "no value file at that scope"
3205
+ }
3206
+ ]);
3207
+ }
3208
+ const rows = result.removed.map((location) => ({
3209
+ glyph: CHECK,
3210
+ label: "Removed",
3211
+ subject: `${PENV_DIR}/${location}`
3212
+ }));
3213
+ return formatRows(rows);
3214
+ }
3215
+ var removeCommand = (0, import_citty13.defineCommand)({
3216
+ meta: { name: "remove", description: "Delete a parameter" },
3217
+ args: {
3218
+ key: { type: "positional", required: true, description: "The parameter, e.g. redis/password" },
3219
+ env: { type: "string", description: "Remove the <name>.<env> scope" },
3220
+ local: {
3221
+ type: "boolean",
3222
+ description: "Remove the personal override: <name>.<env>.local with --env, else <name>.local"
3223
+ }
3224
+ },
3225
+ run({ args }) {
3226
+ return guard(async () => {
3227
+ write(
3228
+ renderRemove(
3229
+ await runRemove({
3230
+ cwd: process.cwd(),
3231
+ key: args.key,
3232
+ ...args.env === void 0 ? {} : { environment: args.env },
3233
+ ...args.local === void 0 ? {} : { local: args.local }
3234
+ })
3235
+ )
3236
+ );
3237
+ });
3238
+ }
3239
+ });
3240
+
3241
+ // src/commands/watch.ts
3242
+ var import_node_fs5 = require("fs");
3243
+ var import_node_path7 = require("path");
3244
+ var import_core19 = require("@penvhq/core");
3245
+ var import_citty14 = require("citty");
3246
+ var DEBOUNCE_MS = 100;
3247
+ function runWatch(options) {
3248
+ const project = openProject(options.cwd);
3249
+ const configFile = (0, import_node_path7.basename)(project.configFile);
3250
+ const debounceMs = options.debounceMs ?? DEBOUNCE_MS;
3251
+ const watchers = /* @__PURE__ */ new Set();
3252
+ let timer;
3253
+ let running = false;
3254
+ let pending = false;
3255
+ let closed = false;
3256
+ async function validate() {
3257
+ if (closed) {
3258
+ return;
3259
+ }
3260
+ if (running) {
3261
+ pending = true;
3262
+ return;
3263
+ }
3264
+ running = true;
3265
+ try {
3266
+ const result = await runValidate({
3267
+ cwd: options.cwd,
3268
+ ...options.environment === void 0 ? {} : { environment: options.environment }
3269
+ });
3270
+ if (!closed) {
3271
+ options.onResult?.(result);
3272
+ }
3273
+ } catch (error) {
3274
+ if (!closed) {
3275
+ options.onError?.(error);
3276
+ }
3277
+ } finally {
3278
+ running = false;
3279
+ if (pending && !closed) {
3280
+ pending = false;
3281
+ void validate();
3282
+ }
3283
+ }
3284
+ }
3285
+ function schedule() {
3286
+ if (closed) {
3287
+ return;
3288
+ }
3289
+ if (timer !== void 0) {
3290
+ clearTimeout(timer);
3291
+ }
3292
+ timer = setTimeout(() => {
3293
+ timer = void 0;
3294
+ void validate();
3295
+ }, debounceMs);
3296
+ }
3297
+ function stop(watcher) {
3298
+ watchers.delete(watcher);
3299
+ watcher.close();
3300
+ }
3301
+ function armRecovery(target, recursive, only) {
3302
+ const parent = (0, import_node_path7.dirname)(target);
3303
+ const name = (0, import_node_path7.basename)(target);
3304
+ let recovery;
3305
+ try {
3306
+ recovery = (0, import_node_fs5.watch)(parent, { recursive: false }, (_event, filename) => {
3307
+ if (closed || recovery === void 0) {
3308
+ return;
3309
+ }
3310
+ if (!(0, import_node_fs5.existsSync)(parent)) {
3311
+ stop(recovery);
3312
+ return;
3313
+ }
3314
+ if (filename !== null && (0, import_node_path7.basename)(filename) !== name) {
3315
+ return;
3316
+ }
3317
+ if (!(0, import_node_fs5.existsSync)(target)) {
3318
+ return;
3319
+ }
3320
+ stop(recovery);
3321
+ addWatcher(target, recursive, only);
3322
+ schedule();
3323
+ });
3324
+ } catch (error) {
3325
+ options.onError?.(error);
3326
+ return;
3327
+ }
3328
+ recovery.on("error", (error) => {
3329
+ if (!closed) {
3330
+ options.onError?.(error);
3331
+ }
3332
+ });
3333
+ watchers.add(recovery);
3334
+ }
3335
+ function addWatcher(target, recursive, only) {
3336
+ let watcher;
3337
+ const listen = (useRecursive) => (0, import_node_fs5.watch)(target, { recursive: useRecursive }, (_event, filename) => {
3338
+ if (closed) {
3339
+ return;
3340
+ }
3341
+ if (!(0, import_node_fs5.existsSync)(target)) {
3342
+ if (watcher !== void 0) {
3343
+ stop(watcher);
3344
+ }
3345
+ armRecovery(target, recursive, only);
3346
+ schedule();
3347
+ return;
3348
+ }
3349
+ if (only !== void 0 && (filename === null || (0, import_node_path7.basename)(filename) !== only)) {
3350
+ return;
3351
+ }
3352
+ schedule();
3353
+ });
3354
+ try {
3355
+ watcher = listen(recursive);
3356
+ } catch (error) {
3357
+ if (!recursive) {
3358
+ options.onError?.(error);
3359
+ return;
3360
+ }
3361
+ try {
3362
+ watcher = listen(false);
3363
+ } catch (fallbackError) {
3364
+ options.onError?.(fallbackError);
3365
+ return;
3366
+ }
3367
+ }
3368
+ watcher.on("error", (error) => {
3369
+ if (!closed) {
3370
+ options.onError?.(error);
3371
+ }
3372
+ });
3373
+ watchers.add(watcher);
3374
+ }
3375
+ addWatcher(project.penvDir, true);
3376
+ addWatcher((0, import_node_path7.dirname)(project.configFile), false, configFile);
3377
+ if ((0, import_core19.schemaInsideTree)(project.config) === void 0) {
3378
+ const schemaFile = (0, import_node_path7.resolve)(project.root, (0, import_core19.schemaFileOf)(project.config));
3379
+ addWatcher((0, import_node_path7.dirname)(schemaFile), false, (0, import_node_path7.basename)(schemaFile));
3380
+ }
3381
+ void validate();
3382
+ return {
3383
+ close() {
3384
+ closed = true;
3385
+ if (timer !== void 0) {
3386
+ clearTimeout(timer);
3387
+ timer = void 0;
3388
+ }
3389
+ for (const watcher of [...watchers]) {
3390
+ stop(watcher);
3391
+ }
3392
+ }
3393
+ };
3394
+ }
3395
+ function renderWatch(result) {
3396
+ return ["", ...renderValidate(result), ...renderDrift(result.drift, result.environment)];
3397
+ }
3398
+ function renderDrift(drift, environment) {
3399
+ const rows = [
3400
+ ...drift.declared.map((item) => ({
3401
+ glyph: WARN,
3402
+ label: "Declared, no value",
3403
+ subject: item.subject,
3404
+ detail: item.detail
3405
+ })),
3406
+ ...drift.undeclared.map((item) => ({
3407
+ glyph: WARN,
3408
+ label: "Unused parameter",
3409
+ subject: item.variable,
3410
+ detail: "present, not in schema"
3411
+ }))
3412
+ ];
3413
+ if (rows.length === 0) {
3414
+ return [];
3415
+ }
3416
+ const lines = ["", `Schema and tree differ for ${environment}:`, ...formatRows(rows)];
3417
+ for (const remedy of new Set(drift.declared.map((item) => item.remedy))) {
3418
+ lines.push(` ${remedy}`);
3419
+ }
3420
+ return lines;
3421
+ }
3422
+ var watchCommand = (0, import_citty14.defineCommand)({
3423
+ meta: {
3424
+ name: "watch",
3425
+ description: "Re-validate whenever .penv/ or penv.config.ts changes"
3426
+ },
3427
+ args: {
3428
+ env: { type: "string", description: "The environment to validate" }
3429
+ },
3430
+ run({ args }) {
3431
+ return guard(async () => {
3432
+ const handle = runWatch({
3433
+ cwd: process.cwd(),
3434
+ ...args.env === void 0 ? {} : { environment: args.env },
3435
+ onResult: (result) => {
3436
+ write(renderWatch(result));
3437
+ },
3438
+ // A failing cycle is reported and watching continues. `reportError`
3439
+ // marks the process failed, which `validate` wants and a loop does not:
3440
+ // a cycle that failed mid-save ten minutes ago must not decide the exit
3441
+ // code of a session the user ended deliberately. The message is what is
3442
+ // wanted here, not the verdict.
3443
+ onError: (error) => {
3444
+ const previous = process.exitCode;
3445
+ reportError(error);
3446
+ process.exitCode = previous;
3447
+ }
3448
+ });
3449
+ write(["Watching .penv/ and penv.config.ts. Ctrl-C to stop."]);
3450
+ await new Promise((resolve6) => {
3451
+ process.once("SIGINT", () => {
3452
+ handle.close();
3453
+ resolve6();
3454
+ });
3455
+ });
3456
+ });
3457
+ }
3458
+ });
3459
+
3460
+ // src/index.ts
3461
+ var main = (0, import_citty15.defineCommand)({
3462
+ meta: {
3463
+ name: "penv",
3464
+ description: "Configuration that shares a data model with your production secret manager"
3465
+ },
3466
+ subCommands: {
3467
+ init: initCommand,
3468
+ import: importCommand,
3469
+ generate: generateCommand,
3470
+ get: getCommand,
3471
+ set: setCommand,
3472
+ mv: mvCommand,
3473
+ push: pushCommand,
3474
+ remove: removeCommand,
3475
+ list: listCommand,
3476
+ encrypt: encryptCommand,
3477
+ decrypt: decryptCommand,
3478
+ key: keyCommand,
3479
+ validate: validateCommand,
3480
+ doctor: doctorCommand,
3481
+ watch: watchCommand
3482
+ }
3483
+ });
3484
+ function runMain() {
3485
+ (0, import_core20.setKeychain)(defaultKeychain);
3486
+ return (0, import_citty15.runMain)(main);
3487
+ }
3488
+ // Annotate the CommonJS export names for ESM import in node:
3489
+ 0 && (module.exports = {
3490
+ LAST_PUSHED_KEY,
3491
+ generateDotenv,
3492
+ importDotenv,
3493
+ insertEnvAlias,
3494
+ main,
3495
+ renderDoctor,
3496
+ renderMove,
3497
+ renderPush,
3498
+ renderWatch,
3499
+ runDecrypt,
3500
+ runDoctor,
3501
+ runEncrypt,
3502
+ runExplain,
3503
+ runGenerate,
3504
+ runGet,
3505
+ runInit,
3506
+ runList,
3507
+ runMain,
3508
+ runMove,
3509
+ runPush,
3510
+ runRemove,
3511
+ runSet,
3512
+ runValidate,
3513
+ runWatch
3514
+ });
3515
+ //# sourceMappingURL=index.cjs.map