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