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