@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.
@@ -0,0 +1,526 @@
1
+ import * as citty from 'citty';
2
+ import { Sink, ParameterRef, Scope } from '@penvhq/core';
3
+
4
+ /**
5
+ * A check reports one of four verdicts. `unknown` — a check that ran but could
6
+ * not reach a verdict — is never rendered as a pass: "I looked and found nothing
7
+ * wrong" and "I could not look" are opposite situations with opposite remedies,
8
+ * and a write-only sink makes most of what doctor can say the second kind.
9
+ */
10
+ type DoctorSeverity = "pass" | "warning" | "failure" | "unknown";
11
+ type DoctorCheck = "schema" | "missing" | "declared" | "weak" | "unused" | "unscoped-fallback" | "plaintext-secret" | "public-secret" | "encryption" | "provider" | "sink-unreachable" | "sink-name-drift" | "sink-manual-edit" | "sink-value-drift";
12
+ interface DoctorFinding {
13
+ readonly check: DoctorCheck;
14
+ readonly severity: DoctorSeverity;
15
+ readonly label: string;
16
+ readonly subject?: string;
17
+ readonly detail?: string;
18
+ /** A line the reader can act on — the `penv set` to paste, where there is one. */
19
+ readonly remedy?: string;
20
+ }
21
+ interface DoctorReport {
22
+ readonly environment: string;
23
+ readonly findings: readonly DoctorFinding[];
24
+ /** False when any finding is a failure. Warnings and unknowns do not fail the run. */
25
+ readonly ok: boolean;
26
+ }
27
+ interface DoctorOptions {
28
+ readonly cwd: string;
29
+ readonly environment?: string;
30
+ /** Injected in tests: the sink to check against. Defaults to the one the config declares. */
31
+ readonly sink?: Sink;
32
+ }
33
+ declare function runDoctor(options: DoctorOptions): Promise<DoctorReport>;
34
+ declare function renderDoctor(report: DoctorReport): string[];
35
+
36
+ interface ScopeOptions {
37
+ /** The environment scope. Combined with `local`, the environment-scoped override. */
38
+ readonly environment?: string;
39
+ readonly local?: boolean;
40
+ }
41
+ interface SetOptions extends ScopeOptions {
42
+ readonly cwd: string;
43
+ readonly key: string;
44
+ readonly value: string;
45
+ }
46
+ interface SetResult {
47
+ readonly parameter: string;
48
+ /** The value file written, relative to `.penv/`. */
49
+ readonly location: string;
50
+ /** Whether meta's policy sealed it. Reported, so the marker is never a surprise. */
51
+ readonly encrypted: boolean;
52
+ }
53
+ /**
54
+ * Writes one value file, sealing it when meta says the parameter is a secret.
55
+ *
56
+ * There is no `--encrypt` flag, deliberately. A flag would make the command line
57
+ * the authority on what is secret, and meta is (invariant 14) — the `.enc` marker
58
+ * is validated *against* the policy, so a marker chosen at the keyboard would
59
+ * invert the direction the check runs in. The policy decides; `set` obeys.
60
+ */
61
+ declare function runSet(options: SetOptions): Promise<SetResult>;
62
+
63
+ interface ResealOptions extends ScopeOptions {
64
+ readonly cwd: string;
65
+ readonly key: string;
66
+ }
67
+ interface ResealResult {
68
+ readonly parameter: string;
69
+ /** The file that now holds the value. */
70
+ readonly location: string;
71
+ /** The file that no longer exists, because its twin replaced it. */
72
+ readonly removed: string;
73
+ }
74
+ declare function runEncrypt(options: ResealOptions): Promise<ResealResult>;
75
+ declare function runDecrypt(options: ResealOptions): Promise<ResealResult>;
76
+
77
+ interface GenerateOptions {
78
+ readonly cwd: string;
79
+ readonly environment?: string;
80
+ /** Where to write, absolute or relative to `cwd`. Defaults to `.env` at the project root. */
81
+ readonly out?: string;
82
+ /** Permits sealed values to be written into the artifact as plaintext. */
83
+ readonly allowDecrypt?: boolean;
84
+ }
85
+ interface GenerateResult {
86
+ readonly file: string;
87
+ readonly environment: string;
88
+ readonly entries: number;
89
+ /** How many of them were sealed and are now plaintext in the artifact. */
90
+ readonly decrypted: number;
91
+ }
92
+ /** The `.env` text for one environment — what `penv generate` writes. */
93
+ declare function generateDotenv(options: Omit<GenerateOptions, "out">): string;
94
+ declare function runGenerate(options: GenerateOptions): GenerateResult;
95
+
96
+ /**
97
+ * `penv get <key>` — read a parameter, or explain which file wins and why.
98
+ *
99
+ * Fallback is never silent, and neither is precedence: `--explain` prints every
100
+ * candidate in the order the cascade considered them, so a value quietly coming
101
+ * from a shared default has nowhere to hide.
102
+ */
103
+ interface GetOptions {
104
+ readonly cwd: string;
105
+ readonly key: string;
106
+ readonly environment?: string;
107
+ }
108
+ interface GetExplanation {
109
+ readonly parameter: string;
110
+ readonly environment: string;
111
+ /** `undefined` when no candidate was present. */
112
+ readonly location: string | undefined;
113
+ /**
114
+ * Why the winning file did not open, when it is `.enc` and did not.
115
+ *
116
+ * A winner that cannot be decrypted is not a skipped candidate — it won, and
117
+ * the cascade is over. Reporting it as a skip would say a lower scope should
118
+ * have been reached, which is the scope-widening answer the cascade refuses.
119
+ */
120
+ readonly undecryptable?: string;
121
+ readonly candidates: readonly GetCandidate[];
122
+ }
123
+ interface GetCandidate {
124
+ readonly location: string;
125
+ readonly present: boolean;
126
+ readonly wins: boolean;
127
+ /** Why a present candidate did not win, or why it was never considered. */
128
+ readonly skipped: string | undefined;
129
+ }
130
+ /**
131
+ * The value, or a named error.
132
+ *
133
+ * `requireValue` answers first, so a winner that exists but did not decrypt is
134
+ * reported as undecryptable rather than as absent. Only a genuine absence — no
135
+ * candidate at any scope — reaches the refusal below, which is what keeps `penv
136
+ * set` from being offered as the fix for a secret the user still has.
137
+ */
138
+ declare function runGet(options: GetOptions): Promise<string>;
139
+ /**
140
+ * Which file wins, and why — never a value, so this must not be stopped by the
141
+ * winner being unreadable. Core describes an `.enc` winner rather than refusing
142
+ * it, so `--explain` is the same walk every other command does.
143
+ */
144
+ declare function runExplain(options: GetOptions): Promise<GetExplanation>;
145
+
146
+ /** What init touched, so a caller can report it and a test can assert it. */
147
+ type InitTarget = "penv-dir" | "schema" | "config" | "tsconfig" | "gitignore";
148
+ /**
149
+ * `conflicted` is the one that is not a success. penv wanted to write something,
150
+ * found the user's file already saying something else about the same thing, and
151
+ * left it alone — so the step is reported with a warning rather than a ✓, and the
152
+ * text says what will not work until the user decides.
153
+ */
154
+ type InitAction = "created" | "kept" | "updated" | "conflicted";
155
+ interface InitStep {
156
+ readonly target: InitTarget;
157
+ readonly action: InitAction;
158
+ /** The reported line, in the docs' voice. */
159
+ readonly text: string;
160
+ readonly note?: string;
161
+ }
162
+ /**
163
+ * The answers init writes down. Every one of these is a decision a human either
164
+ * made or consented to — never an identity penv recorded to reinterpret later.
165
+ * There is deliberately no `framework` here: `schemaFile` and `publicPrefixes`
166
+ * still mean exactly what they say after the project is rewritten in something
167
+ * else, and `framework: "next"` would not.
168
+ */
169
+ interface InitDecisions {
170
+ /** The whitelist. Empty unless a human named them — penv never infers one. */
171
+ readonly environments: readonly string[];
172
+ /** The schema module, relative to the project root, POSIX. */
173
+ readonly schemaFile: string;
174
+ /** The prefixes the framework inlines into its client bundle. */
175
+ readonly publicPrefixes: readonly string[];
176
+ /**
177
+ * How the user's code names the schema module — `@env` or `#env`.
178
+ *
179
+ * Two forms, resolved by two different things: `@env` is a tsconfig `paths`
180
+ * entry that a bundler resolves and plain Node does not, and `#env` is a
181
+ * package.json `imports` entry that Node resolves itself. Which one a project
182
+ * wants is a fact about the project, so penv reads what it already does and
183
+ * offers that.
184
+ */
185
+ readonly alias: string;
186
+ }
187
+ interface InitResult {
188
+ readonly root: string;
189
+ readonly decisions: InitDecisions;
190
+ readonly steps: readonly InitStep[];
191
+ }
192
+ interface InitOptions {
193
+ readonly cwd: string;
194
+ /** What to write. Omitted means the plan's defaults, as `--yes` takes them. */
195
+ readonly decisions?: InitDecisions;
196
+ }
197
+ interface AliasEdit {
198
+ readonly source: string;
199
+ readonly changed: boolean;
200
+ /**
201
+ * What the alias already points at, when that is not penv's schema.
202
+ *
203
+ * The alias is how the user's code reaches penv, so an alias that resolves
204
+ * somewhere else is not a small problem: `import { env } from "@env"` compiles,
205
+ * runs, and hands back another module's export. Reporting "kept the alias"
206
+ * because the *key* was present is how penv would say that was fine — a silent
207
+ * seam, in the scaffolder of the tool whose subject is silent seams.
208
+ *
209
+ * Left as the user's, never rewritten: penv cannot tell a stale mapping from a
210
+ * deliberate one, and the file is theirs.
211
+ */
212
+ readonly conflict?: string;
213
+ }
214
+ /**
215
+ * `tsconfig.json` with the `@env` alias present, everything else untouched.
216
+ * Already-aliased input comes back unchanged rather than gaining a duplicate.
217
+ *
218
+ * The alias is why the schema can live anywhere: application code imports
219
+ * `@env`, and this line is the only thing that has to know where that is.
220
+ */
221
+ declare function insertEnvAlias(source: string, target?: string, name?: string): AliasEdit;
222
+ declare function runInit(options: InitOptions): InitResult;
223
+
224
+ /**
225
+ * Reading the user's schema, and the distance between it and the parameter tree.
226
+ *
227
+ * `.penv/env.ts` declares what must exist and the tree holds what does. The gap
228
+ * between them is the signal `penv validate` exists to raise; this module makes
229
+ * it legible without closing it. Nothing here writes or deletes a value file —
230
+ * a declaration has no value, so materialising one could only invent it, and an
231
+ * invented value is the silent-value-reaching-runtime failure penv exists to
232
+ * delete. `penv set` stays the only writer.
233
+ *
234
+ * The introspection below lives here, not in `doctor`, because `doctor` and
235
+ * `watch` both report drift and two readers of the same schema would be two
236
+ * answers to one question.
237
+ *
238
+ * Every helper answers "I cannot tell" rather than guessing. A report is only
239
+ * worth reading if every line in it is true, so a field this module cannot
240
+ * understand produces no line at all.
241
+ */
242
+
243
+ /** A parameter `.penv/env.ts` declares that the tree has no value for. */
244
+ interface DeclaredDrift {
245
+ /** The parameter id, or the dotted schema path when no filename could reach it. */
246
+ readonly subject: string;
247
+ /** Absent when no filename reaches this key, which is drift `penv set` cannot close. */
248
+ readonly ref?: ParameterRef;
249
+ /** The line to paste: the `penv set` that closes this, or the rename that must precede it. */
250
+ readonly remedy: string;
251
+ readonly detail: string;
252
+ }
253
+ /** A parameter the tree holds a value for that `.penv/env.ts` does not declare. */
254
+ interface UndeclaredDrift {
255
+ readonly ref: ParameterRef;
256
+ /** The generated variable, which is the name the application would have read. */
257
+ readonly variable: string;
258
+ }
259
+ /**
260
+ * The distance between `.penv/env.ts` and the tree, in both directions. Named
261
+ * `declared`/`undeclared` for the side that has it, not for a verdict: neither
262
+ * direction is by itself an error, and only `validate` decides that.
263
+ */
264
+ interface DriftReport {
265
+ readonly declared: readonly DeclaredDrift[];
266
+ readonly undeclared: readonly UndeclaredDrift[];
267
+ }
268
+
269
+ type ValidateIssueKind = "config" | "reserved" | "collision" | "schema" | "undecryptable";
270
+ interface ValidateIssue {
271
+ readonly kind: ValidateIssueKind;
272
+ /** What the line is about: a parameter, a variable, a token, or a file. */
273
+ readonly subject: string;
274
+ readonly message: string;
275
+ readonly remedy?: string;
276
+ }
277
+ interface ValidateResult {
278
+ readonly ok: boolean;
279
+ readonly environment: string;
280
+ readonly parameters: number;
281
+ readonly issues: readonly ValidateIssue[];
282
+ /**
283
+ * The distance between the schema and the tree, carried for the callers
284
+ * that report it (`watch`). Never folded into `ok` and never rendered by
285
+ * `renderValidate`: drift is a report, and CI's verdict must not move because
286
+ * a parameter the schema tolerates is absent. Empty when the schema did not
287
+ * load, since there is nothing to measure against.
288
+ */
289
+ readonly drift: DriftReport;
290
+ }
291
+ interface ValidateOptions {
292
+ readonly cwd: string;
293
+ readonly environment?: string;
294
+ }
295
+ declare function runValidate(options: ValidateOptions): Promise<ValidateResult>;
296
+
297
+ interface ImportOptions {
298
+ readonly cwd: string;
299
+ /** The dotenv file to adopt, absolute or relative to `cwd`. */
300
+ readonly file: string;
301
+ /**
302
+ * `--env`. It reads as "these are <environment>'s values", so for a file whose
303
+ * name carries no environment it names the *scope* as well as the environment
304
+ * to run against: `penv import prod-secrets.txt --env production` writes
305
+ * `<name>.production`, and `--env production` on `.env.local` writes
306
+ * `<name>.production.local`. The filename supplies both when it carries an
307
+ * environment, so the flag is needed only for a file that does not — and
308
+ * contradicting the filename is an error rather than a silent choice between
309
+ * the two.
310
+ */
311
+ readonly environment?: string;
312
+ }
313
+ interface ImportReport {
314
+ readonly root: string;
315
+ readonly file: string;
316
+ readonly backup: string;
317
+ /** The scope the source named — filename, `--env`, or both — and the scope every value was written at. */
318
+ readonly scope: Scope;
319
+ /**
320
+ * The environment the import ran against, or `undefined` when none is set.
321
+ *
322
+ * Undefined only ever accompanies the unscoped default: any other scope names
323
+ * an environment, so it always has one. It means the values were written and
324
+ * the closing validation was skipped, which the output states.
325
+ */
326
+ readonly environment: string | undefined;
327
+ /** The declared environments, so a skipped validation can name one to pass. */
328
+ readonly environments: readonly string[];
329
+ readonly variables: number;
330
+ /**
331
+ * Comment blocks that belonged to no variable. Reported rather than discarded
332
+ * silently: a file header has no parameter to describe, but that is not a
333
+ * reason to pretend it was never there.
334
+ */
335
+ readonly orphanComments: number;
336
+ readonly steps: readonly InitStep[];
337
+ }
338
+ /**
339
+ * Adopts the file: parses it, scaffolds the project, writes one value file per
340
+ * variable and each attached comment into that parameter's meta, and backs the
341
+ * source up. Validation is the caller's next step rather than part of adoption —
342
+ * an inferred schema is a draft, and a draft that needs correcting has still
343
+ * imported every value correctly.
344
+ *
345
+ * Adoption is all or nothing. Every name is checked against the config, and any
346
+ * environment the source names resolved, before the tree is scaffolded or a
347
+ * value written. The two names that fail here fail *destructively*: a reserved
348
+ * name bricks every later command, and a lossy name renames the user's variable
349
+ * behind their back. What the source names is resolved here rather than left to
350
+ * the closing `validate` because a command that writes a tree and *then*
351
+ * discovers it cannot name an environment has already half-adopted the project
352
+ * it just refused. A half-imported tree would be the drift penv exists to
353
+ * remove, introduced by penv itself.
354
+ *
355
+ * An environment nothing names is a different case, and not an error: an
356
+ * unscoped import writes at the unscoped default, which needs no environment.
357
+ * Only the validation that follows needs one, so it is skipped and said to be
358
+ * skipped. Requiring one here would fail `penv import .env` on a greenfield
359
+ * project — the first command the quickstart gives, where no environment could
360
+ * plausibly be set yet — to satisfy a step that is the caller's next one.
361
+ */
362
+ declare function importDotenv(options: ImportOptions): ImportReport;
363
+
364
+ /**
365
+ * `penv list` — every parameter, and the scope that wins for one environment.
366
+ *
367
+ * The winning scope is the point: `production` and `default` are both "it
368
+ * resolves", and only one of them means the value was written for production.
369
+ */
370
+ interface ListOptions {
371
+ readonly cwd: string;
372
+ readonly environment?: string;
373
+ }
374
+ interface ListEntry {
375
+ readonly parameter: string;
376
+ /** The generated `.env` variable, so the two names are legible side by side. */
377
+ readonly variable: string;
378
+ /** `<env>.local`, `local`, an environment name, `default`, or `absent`. */
379
+ readonly scope: string;
380
+ /** The winning value file relative to `.penv/`, or `undefined` when nothing wins. */
381
+ readonly location: string | undefined;
382
+ readonly encrypted: boolean;
383
+ readonly viaUnscopedFallback: boolean;
384
+ }
385
+ interface ListResult {
386
+ readonly environment: string;
387
+ readonly parameters: readonly ListEntry[];
388
+ }
389
+ declare function runList(options: ListOptions): Promise<ListResult>;
390
+
391
+ /**
392
+ * `penv mv <from> <to>` — rename a parameter, every scope at once.
393
+ *
394
+ * A parameter is not one file. It is up to eight — four cascade levels, each
395
+ * with a plaintext and an encrypted address — plus its meta, and a rename that
396
+ * moved some of them would split one parameter into two. So this moves all of
397
+ * them or none of them, and the whole plan is checked before a single byte is
398
+ * written.
399
+ *
400
+ * **This is the only correct way to move an encrypted value.** A ciphertext is
401
+ * sealed against the address it lives at, so `mv redis-password.production.enc
402
+ * redis/password.production.enc` at the shell produces a file that will never
403
+ * open again — the value is not moved, it is destroyed, and the shell reports
404
+ * success. Re-sealing at the new address is the whole reason this command
405
+ * exists: penv asked for namespacing to be "a deliberate refactor afterwards"
406
+ * and then, once values could be encrypted, made doing it by hand a way to lose
407
+ * them.
408
+ *
409
+ * It moves the tree and never the schema. `.penv/env.ts` is yours (invariant 2),
410
+ * so renaming `database-url` to `database/url` leaves it declaring the old access
411
+ * path — and the drift report is what says so. penv names the distance; you close
412
+ * it. This command's report says which line to change rather than changing it.
413
+ */
414
+ interface MoveOptions {
415
+ readonly cwd: string;
416
+ readonly from: string;
417
+ readonly to: string;
418
+ }
419
+ interface MovedFile {
420
+ readonly from: string;
421
+ readonly to: string;
422
+ /** True when the value was opened and sealed again for its new address. */
423
+ readonly resealed: boolean;
424
+ }
425
+ interface MoveResult {
426
+ readonly from: string;
427
+ readonly to: string;
428
+ readonly files: readonly MovedFile[];
429
+ /** The meta file's new location, or `undefined` when the parameter had none. */
430
+ readonly meta: string | undefined;
431
+ /** The access path the schema still declares, and the one it should now. */
432
+ readonly schema: {
433
+ readonly was: string;
434
+ readonly now: string;
435
+ };
436
+ }
437
+ declare function runMove(options: MoveOptions): Promise<MoveResult>;
438
+ declare function renderMove(result: MoveResult): string[];
439
+
440
+ /** The per-environment meta field recording penv's last push, compared against the destination's `updatedAt`. */
441
+ declare const LAST_PUSHED_KEY = "lastPushedAt";
442
+ interface PushOptions {
443
+ readonly cwd: string;
444
+ readonly environment?: string;
445
+ /** Permits sealed values to be decrypted locally and pushed as plaintext for the destination to re-seal. */
446
+ readonly allowDecrypt?: boolean;
447
+ /** Injected in tests: the sink to push to. Defaults to the one the config declares. */
448
+ readonly sink?: Sink;
449
+ /** Injected in tests: the wall-clock reading recorded in meta. Defaults to now. */
450
+ readonly now?: string;
451
+ }
452
+ interface PushResult {
453
+ readonly environment: string;
454
+ /** The `owner/repo` targeted, when the config named one. */
455
+ readonly repo: string | undefined;
456
+ readonly pushed: number;
457
+ readonly repositorySecrets: number;
458
+ readonly environmentSecrets: number;
459
+ /** How many were sealed and crossed as plaintext for the destination to re-seal. */
460
+ readonly decrypted: number;
461
+ }
462
+ declare function runPush(options: PushOptions): Promise<PushResult>;
463
+ declare function renderPush(result: PushResult): string[];
464
+
465
+ interface RemoveOptions extends ScopeOptions {
466
+ readonly cwd: string;
467
+ readonly key: string;
468
+ }
469
+ interface RemoveResult {
470
+ readonly parameter: string;
471
+ /** The value files that existed and are now gone, relative to `.penv/`. */
472
+ readonly removed: readonly string[];
473
+ /** Both files penv looked at, whether or not they were there. */
474
+ readonly considered: readonly string[];
475
+ }
476
+ declare function runRemove(options: RemoveOptions): Promise<RemoveResult>;
477
+
478
+ interface WatchOptions {
479
+ readonly cwd: string;
480
+ readonly environment?: string;
481
+ /** Defaults to {@link DEBOUNCE_MS}. */
482
+ readonly debounceMs?: number;
483
+ /** Called with every completed validation, starting with the initial one. */
484
+ readonly onResult?: (result: ValidateResult) => void;
485
+ /**
486
+ * Called when a cycle could not produce a result at all — an unreadable
487
+ * config, a watcher the platform dropped. Never called for a *failing*
488
+ * validation: that is a result, and it goes to `onResult`.
489
+ */
490
+ readonly onError?: (error: unknown) => void;
491
+ }
492
+ interface WatchHandle {
493
+ /** Stops watching. Idempotent, and safe to call from inside a callback. */
494
+ close(): void;
495
+ }
496
+ /**
497
+ * Watches, and re-validates on change.
498
+ *
499
+ * Returns a handle rather than blocking, so the loop is a plain object a test
500
+ * can drive and close instead of a live process it would have to spawn. The
501
+ * command below is the only thing that turns it into a process that waits.
502
+ */
503
+ declare function runWatch(options: WatchOptions): WatchHandle;
504
+ /**
505
+ * One cycle's report: `penv validate`'s, with a rule above it — on a loop, the
506
+ * reader's first question is where the last run ended — and the drift below it.
507
+ *
508
+ * Drift comes last because it is the part that is not a verdict. The rows above
509
+ * say whether the configuration is valid; these say what the schema and the tree
510
+ * disagree about, which is often *why*, and is worth reading even on a run that
511
+ * passed.
512
+ */
513
+ declare function renderWatch(result: ValidateResult): string[];
514
+
515
+ /**
516
+ * penv's command line.
517
+ *
518
+ * The wiring here is deliberately thin: every command's real work is a plain
519
+ * exported function that takes a `cwd` and returns a result, and citty only
520
+ * parses arguments, calls it, and prints what it returned. That is what lets the
521
+ * tests call the commands rather than spawn them.
522
+ */
523
+ declare const main: citty.CommandDef<citty.ArgsDef>;
524
+ declare function runMain(): Promise<void>;
525
+
526
+ export { type DoctorCheck, type DoctorFinding, type DoctorReport, type DoctorSeverity, type GenerateResult, type GetExplanation, type ImportReport, type InitResult, type InitStep, LAST_PUSHED_KEY, type ListResult, type MoveResult, type PushOptions, type PushResult, type RemoveResult, type ResealResult, type SetResult, type ValidateIssue, type ValidateResult, type WatchHandle, type WatchOptions, generateDotenv, importDotenv, insertEnvAlias, main, renderDoctor, renderMove, renderPush, renderWatch, runDecrypt, runDoctor, runEncrypt, runExplain, runGenerate, runGet, runInit, runList, runMain, runMove, runPush, runRemove, runSet, runValidate, runWatch };