@penvhq/cli 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.cts CHANGED
@@ -1,5 +1,53 @@
1
1
  import * as citty from 'citty';
2
- import { ProjectionProvider, Provider, ParameterRef, Scope, AnyProvider, RotationMechanism, RotationState } from '@penvhq/core';
2
+ import { ProjectionProvider, Provider, PenvConfig, DotenvEntry, ParameterRef, Scope, DotenvDiagnostic, Resolution, AnyProvider, RotationMechanism, RotationState } from '@penvhq/core';
3
+ import { z } from 'zod';
4
+
5
+ /**
6
+ * `penv artifact build` — the sealed deployment artifact CI hands a release.
7
+ *
8
+ * It is the third step of the sequence PRD §7 names: pull the target
9
+ * environment, validate it, build the artifact, mount it. Each step is a command
10
+ * with one job, and this one's is narrow on purpose — it does not re-reach a
11
+ * verdict `penv validate` already reaches, because two implementations of "is
12
+ * this configuration good" would eventually let a release be built that CI had
13
+ * already rejected.
14
+ *
15
+ * Two properties are the whole design:
16
+ *
17
+ * **It never decrypts.** A sealed value is copied ciphertext-and-address into
18
+ * the artifact exactly as the record holds it, so building needs no key at all —
19
+ * CI can produce a production artifact without ever being able to read one. The
20
+ * AAD is still the value file's full name, so the ciphertext stays bound to the
21
+ * scope it was sealed at (invariant 17).
22
+ *
23
+ * **It refuses to guess the environment.** `--env` is named explicitly and never
24
+ * defaulted, `--out` likewise. An artifact built for "whatever the default was"
25
+ * is a release that ships the wrong environment's credentials, and the default
26
+ * that made it is one config edit nobody reviewed.
27
+ */
28
+ interface ArtifactBuildOptions {
29
+ readonly cwd: string;
30
+ /** Named explicitly. There is no default, and none is invented. */
31
+ readonly environment?: string;
32
+ /** Named explicitly. Where the release will mount it from. */
33
+ readonly out?: string;
34
+ }
35
+ interface ArtifactBuildResult {
36
+ readonly file: string;
37
+ readonly environment: string;
38
+ readonly engineVersion: string;
39
+ readonly keySource: string;
40
+ /** Delivery mappings with a value. */
41
+ readonly values: number;
42
+ /** How many of those travelled as ciphertext. */
43
+ readonly sealed: number;
44
+ /** Declared mappings the environment has no non-local winner for. */
45
+ readonly absent: number;
46
+ /** True when the artifact was written inside the project — a `doctor` finding. */
47
+ readonly insideRepo: boolean;
48
+ }
49
+ declare function runArtifactBuild(options: ArtifactBuildOptions): Promise<ArtifactBuildResult>;
50
+ declare function renderArtifactBuild(result: ArtifactBuildResult, cwd: string): string[];
3
51
 
4
52
  /**
5
53
  * A check reports one of four verdicts. `unknown` — a check that ran but could
@@ -9,7 +57,7 @@ import { ProjectionProvider, Provider, ParameterRef, Scope, AnyProvider, Rotatio
9
57
  * second kind.
10
58
  */
11
59
  type DoctorSeverity = "pass" | "warning" | "failure" | "unknown";
12
- type DoctorCheck = "schema" | "missing" | "declared" | "weak" | "unused" | "unscoped-fallback" | "plaintext-secret" | "public-secret" | "encryption" | "rotation-overdue" | "rotation-stuck" | "provider-value-drift" | "snapshot-stale" | "bundle-invisible-plaintext" | "provider" | "projection-unreachable" | "projection-name-drift" | "projection-manual-edit" | "projection-value-drift" | "environment-flag-shadow";
60
+ type DoctorCheck = "schema" | "missing" | "declared" | "weak" | "unused" | "unscoped-fallback" | "plaintext-secret" | "public-secret" | "encryption" | "rotation-overdue" | "rotation-stuck" | "provider-value-drift" | "provider" | "projection-unreachable" | "projection-name-drift" | "projection-manual-edit" | "projection-value-drift" | "environment-flag-shadow" | "artifact-in-tree";
13
61
  interface DoctorFinding {
14
62
  readonly check: DoctorCheck;
15
63
  readonly severity: DoctorSeverity;
@@ -47,6 +95,28 @@ interface DoctorOptions {
47
95
  declare function runDoctor(options: DoctorOptions): Promise<DoctorReport>;
48
96
  declare function renderDoctor(report: DoctorReport): string[];
49
97
 
98
+ /**
99
+ * Opening a penv project from a working directory, and the pieces every command
100
+ * needs once it is open: the config, the environment to act on, the provider
101
+ * rooted at the records tree, and the parameter a CLI key names.
102
+ */
103
+
104
+ interface Project {
105
+ /** The directory holding `penv.config.ts`. */
106
+ readonly root: string;
107
+ readonly configFile: string;
108
+ readonly config: PenvConfig;
109
+ /** The parameter tree, absolute — `.penv/state/records/`. */
110
+ readonly recordsDir: string;
111
+ /**
112
+ * The project's provider, as the contract — never the concrete
113
+ * implementation. Shared commands speak the async interface and nothing more;
114
+ * the sync twins a command genuinely needs are reached through `localTree`,
115
+ * which is the one place the filesystem-only surface is named.
116
+ */
117
+ readonly provider: Provider;
118
+ }
119
+
50
120
  interface ScopeOptions {
51
121
  /** The environment scope. Combined with `local`, the environment-scoped override. */
52
122
  readonly environment?: string;
@@ -59,7 +129,7 @@ interface SetOptions extends ScopeOptions {
59
129
  }
60
130
  interface SetResult {
61
131
  readonly parameter: string;
62
- /** The value file written, relative to `.penv/`. */
132
+ /** The value file written, relative to the records tree. */
63
133
  readonly location: string;
64
134
  /** Whether meta's policy sealed it. Reported, so the marker is never a surprise. */
65
135
  readonly encrypted: boolean;
@@ -144,7 +214,7 @@ interface FillResult {
144
214
  /** The value files written, one per answered prompt. */
145
215
  readonly written: ReadonlyArray<{
146
216
  readonly parameter: string;
147
- /** The value file written, relative to `.penv/`. */
217
+ /** The value file written, relative to the records tree. */
148
218
  readonly location: string;
149
219
  readonly encrypted: boolean;
150
220
  }>;
@@ -249,8 +319,136 @@ declare function runGet(options: GetOptions): Promise<string>;
249
319
  */
250
320
  declare function runExplain(options: GetOptions): Promise<GetExplanation>;
251
321
 
322
+ /**
323
+ * What the codebase already says about itself.
324
+ *
325
+ * `penv init` asks a human to confirm a plan, and a plan the human has to fill
326
+ * in from scratch is an interrogation. So penv reads the two facts it can
327
+ * observe — the framework in `package.json`, and whether a `src/` directory
328
+ * exists — and offers them as a suggestion.
329
+ *
330
+ * The line this module does not cross: a framework is an identity, never a
331
+ * config key. Nothing here is written to `penv.config.ts` as `framework: "next"`
332
+ * — the answers become concrete decisions (`schemaFile`, `publicPrefixes`) that
333
+ * mean the same thing in a year, when the project has been rewritten twice and
334
+ * penv would otherwise still be reinterpreting a name it read once.
335
+ *
336
+ * Everything here is a suggestion. The one thing that is never suggested is an
337
+ * environment: deployment topology is not in `package.json`, and invariant 10
338
+ * forbids inferring it.
339
+ */
340
+ /** A framework penv recognised, and what it implies about a project's layout. */
341
+ interface Detected {
342
+ /** The framework's own name, as a human writes it — `"Next.js"`. */
343
+ readonly name: string;
344
+ /** Where this framework's projects keep their modules, relative to the root. */
345
+ readonly schemaFile: string;
346
+ /**
347
+ * The conventional path penv stepped aside from, because a module that is not
348
+ * penv's schema already lives there. Set only when it happened, so the plan can
349
+ * say why the schema is not where the convention would have put it.
350
+ */
351
+ readonly displacedFrom?: string;
352
+ /** The prefixes this framework inlines into its client bundle. */
353
+ readonly publicPrefixes: readonly string[];
354
+ }
355
+
356
+ /**
357
+ * The dotenv files a project's framework reads, and what each one says.
358
+ *
359
+ * One module answers this for both ends of adoption: `penv init` offers these
360
+ * files for the cutover, and `penv run` refuses the ones that come back
361
+ * afterwards. Two readings of "which files are active" would eventually let init
362
+ * move a file run does not watch, or run refuse a file init never offered.
363
+ *
364
+ * The four scopes are invariant 4's, which are Next.js's and Vite's:
365
+ * `.env` > `.env.local` > `.env.<environment>` > `.env.<environment>.local`.
366
+ * Anything else with an `.env` prefix is documentation (`.env.example`), a
367
+ * leftover (`.env.backup`), or a filename penv has no reading of — none of them
368
+ * is configuration a framework loads, so none is offered or refused.
369
+ *
370
+ * Reading an environment out of a filename here is not inference: nothing
371
+ * reaches `penv.config.ts` until a human selects the file, and
372
+ * {@link activeDotenvFiles} judges the segment against the declared whitelist
373
+ * (invariant 10) rather than believing it.
374
+ */
375
+
376
+ /** Where a dotenv file sits in the cascade. */
377
+ type DotenvKind = "shared" | "local" | "environment" | "environment-local";
378
+ interface DotenvFile {
379
+ /** The filename exactly as it is on disk — what undo restores. */
380
+ readonly name: string;
381
+ readonly kind: DotenvKind;
382
+ /** The environment the filename names, for the two environment-scoped kinds. */
383
+ readonly environment?: string;
384
+ /** How the file is described in the selection table. */
385
+ readonly label: string;
386
+ }
387
+
388
+ /**
389
+ * The draft schema penv writes when it adopts a dotenv file.
390
+ *
391
+ * It is a draft and says so in the file it lands in: single-sample inference
392
+ * cannot know that a boolean seen as `true` must also accept `1`, or that a
393
+ * string is really a URL. penv scaffolds `penv.schema.ts` once and never
394
+ * regenerates it (invariant 2), so every correction the reader makes is safe.
395
+ *
396
+ * Requiredness across several files is the one judgement here, and it is
397
+ * deliberately coarse: a field observed in every adopted environment starts
398
+ * required, and a field missing from any of them starts optional. That is not
399
+ * per-environment requiredness — there is one schema, never one per environment
400
+ * (invariant 1) — it is the weakest shape that every adopted environment
401
+ * satisfies, which is what makes the first `penv run` after a cutover pass with
402
+ * no edits.
403
+ */
404
+
405
+ /** One schema field, rendered into `penv.schema.ts` as `<key>: <type>,`. */
406
+ interface SchemaField {
407
+ readonly key: string;
408
+ /** The Zod expression, e.g. `z.url()` — `.optional()` already applied when it belongs. */
409
+ readonly type: string;
410
+ }
411
+ /** A drafted field, plus the verdict a report prints. */
412
+ interface DraftField extends SchemaField {
413
+ readonly required: boolean;
414
+ }
415
+
416
+ /**
417
+ * The one runtime dependency an adopted project takes, and how it gets there.
418
+ *
419
+ * PRD §3: an adopted project depends on exactly `@penvhq/penv` at the engine's
420
+ * own version — the typed `@env` surface, not a CLI distribution. `penv init`
421
+ * installs it with the package manager the project already uses, and only after
422
+ * showing the exact `package.json` and lockfile change: an install is the one
423
+ * step of adoption that reaches outside the repository, so it is the one step
424
+ * that is shown before it happens rather than reported after.
425
+ *
426
+ * The install itself is a seam. It shells out to a package manager, which the
427
+ * tests must never do — and a fake here is not a weaker test, because what init
428
+ * has to get right is the plan, the consent, and the refusal when the install
429
+ * does not happen.
430
+ */
431
+
432
+ type PackageManager = "pnpm" | "npm" | "yarn" | "bun";
433
+ interface InstallPlan {
434
+ readonly root: string;
435
+ readonly manager: PackageManager;
436
+ readonly package: string;
437
+ readonly version: string;
438
+ /** The command, argv-shaped — what runs, and what a refusal tells the user to run. */
439
+ readonly command: readonly string[];
440
+ /** The lockfile the manager will rewrite, when the project has one. */
441
+ readonly lockfile?: string;
442
+ /** What `package.json` already says about the package, when it says anything. */
443
+ readonly declared?: string;
444
+ /** True when `package.json` already pins this exact version — nothing to install. */
445
+ readonly satisfied: boolean;
446
+ }
447
+ /** Runs an install plan, or throws. Replaced in tests; never spawns there. */
448
+ type InstallRuntime = (plan: InstallPlan) => Promise<void>;
449
+
252
450
  /** What init touched, so a caller can report it and a test can assert it. */
253
- type InitTarget = "penv-dir" | "schema" | "env" | "config" | "snapshot" | "tsconfig" | "gitignore" | "seam";
451
+ type InitTarget = "penv-dir" | "schema" | "env" | "config" | "tsconfig" | "gitignore" | "seam";
254
452
  /**
255
453
  * `conflicted` is the one that is not a success. penv wanted to write something,
256
454
  * found the user's file already saying something else about the same thing, and
@@ -279,6 +477,12 @@ interface InitStep {
279
477
  interface InitDecisions {
280
478
  /** The whitelist. Empty unless a human named them — penv never infers one. */
281
479
  readonly environments: readonly string[];
480
+ /**
481
+ * The environment every command falls back to when `--env` is absent (seal 3).
482
+ * Written only when the cutover adopted one — a declared decision, so the
483
+ * whitelist rule is untouched, and CI keeps naming `--env` anyway.
484
+ */
485
+ readonly defaultEnvironment?: string;
282
486
  /** The schema module, relative to the project root, POSIX. */
283
487
  readonly schemaFile: string;
284
488
  /** The prefixes the framework inlines into its client bundle. */
@@ -313,6 +517,15 @@ interface InitOptions {
313
517
  /** The detected framework name, passed by the command so the seam step need not re-detect it. */
314
518
  readonly framework?: string;
315
519
  }
520
+ interface InitPlan {
521
+ readonly detected: Detected | undefined;
522
+ /** What init writes unless a human edits it. */
523
+ readonly decisions: InitDecisions;
524
+ /** Environments the `.env*` files on disk are evidence for. Offered, never taken. */
525
+ readonly suggestedEnvironments: readonly string[];
526
+ /** Why each decision is what it is. Printed — a fallback penv takes silently is a guess. */
527
+ readonly notes: readonly string[];
528
+ }
316
529
  interface AliasEdit {
317
530
  readonly source: string;
318
531
  readonly changed: boolean;
@@ -338,6 +551,85 @@ interface AliasEdit {
338
551
  * `@env`, and this line is the only thing that has to know where that is.
339
552
  */
340
553
  declare function insertEnvAlias(source: string, target?: string, name?: string): AliasEdit;
554
+ /** One adopted file: what it holds, and the scope its values are written at. */
555
+ interface Adopted {
556
+ readonly file: DotenvFile;
557
+ readonly entries: readonly DotenvEntry[];
558
+ readonly refs: readonly ParameterRef[];
559
+ readonly scope: Scope;
560
+ }
561
+ interface AdoptionPlan {
562
+ readonly root: string;
563
+ /** Every dotenv file penv found, in the order the list shows them. */
564
+ readonly found: readonly DotenvFile[];
565
+ /** Checked when the list is first shown: the development cascade, where it exists. */
566
+ readonly preselected: readonly string[];
567
+ }
568
+ /** What there is to adopt, and what penv proposes taking. */
569
+ declare function planAdoption(root: string): AdoptionPlan;
570
+ interface CutoverPlan {
571
+ readonly root: string;
572
+ readonly selected: readonly DotenvFile[];
573
+ readonly adopted: readonly Adopted[];
574
+ /** The whitelist after this cutover — what the config declares, or is about to. */
575
+ readonly environments: readonly string[];
576
+ /**
577
+ * The environments this cutover is *about*: the ones its files name. Narrower
578
+ * than the whitelist on a project that already declared more, and the ones the
579
+ * draft is judged against and the import is validated for — an environment
580
+ * this cutover did not touch must not fail it for a state it was already in.
581
+ */
582
+ readonly adopting: readonly string[];
583
+ readonly decisions: InitDecisions;
584
+ readonly fields: readonly DraftField[];
585
+ readonly variables: number;
586
+ /** Values the parser read but that look like a mistake. Shown, never fixed. */
587
+ readonly diagnostics: readonly DotenvDiagnostic[];
588
+ readonly install: InstallPlan;
589
+ readonly framework: string | undefined;
590
+ /** True when `penv.config.ts` already existed, so init keeps every decision it records. */
591
+ readonly configured: boolean;
592
+ }
593
+ interface CutoverInput {
594
+ readonly root: string;
595
+ /** What init would scaffold anyway: detection, the schema's home, the alias. */
596
+ readonly base: InitPlan;
597
+ readonly selected: readonly DotenvFile[];
598
+ /** The environment named when the selection declares none — `.env` alone declares nothing. */
599
+ readonly environment?: string;
600
+ /** The `@penvhq/penv` version to pin. Defaults to this engine's own. */
601
+ readonly version?: string;
602
+ readonly inject?: boolean;
603
+ }
604
+ /**
605
+ * Everything a cutover needs, checked before anything is written.
606
+ *
607
+ * The order is the order the failures matter in: an unresolved bundle first
608
+ * (there is nothing to plan on top of it), then what the selection declares,
609
+ * then whether the selection is complete, then every variable name, and only
610
+ * then the schema and the install. Each one throws, so the caller has a plan or
611
+ * a refusal and never a half-answer.
612
+ */
613
+ declare function planCutover(input: CutoverInput): CutoverPlan;
614
+ interface CutoverResult {
615
+ readonly plan: CutoverPlan;
616
+ readonly steps: readonly InitStep[];
617
+ /** The dotenv files moved into the bundle, by name. */
618
+ readonly moved: readonly string[];
619
+ /** The environments whose imported values were validated before the move. */
620
+ readonly validated: readonly string[];
621
+ }
622
+ interface CutoverOptions {
623
+ /** Injected in tests: how the runtime dependency is installed. Never spawns there. */
624
+ readonly install?: InstallRuntime;
625
+ }
626
+ /**
627
+ * Installs, scaffolds, imports, validates — and only then moves the dotenv
628
+ * files aside. The order is the guarantee: every step before the move leaves a
629
+ * project whose `.env` files are exactly where they were, so a refusal at any
630
+ * of them costs a re-run and nothing else.
631
+ */
632
+ declare function applyCutover(plan: CutoverPlan, options?: CutoverOptions): Promise<CutoverResult>;
341
633
  declare function runInit(options: InitOptions): InitResult;
342
634
 
343
635
  /**
@@ -433,7 +725,26 @@ interface ValidateOptions {
433
725
  /** Bare flags the command did not declare — environment shorthands, judged against the whitelist. */
434
726
  readonly envFlags?: readonly string[];
435
727
  }
728
+ /**
729
+ * One environment, checked — and everything the check produced on the way.
730
+ *
731
+ * The verdict is `result` and it is the only verdict penv has. The rest is what
732
+ * `penv run` needs to build a child environment, handed back rather than
733
+ * recomputed: a second walk of the same tree could disagree with the one the
734
+ * verdict was reached on, and then `run` would start a process `validate`
735
+ * refuses.
736
+ */
737
+ interface EnvironmentCheck {
738
+ readonly result: ValidateResult;
739
+ /** Absent when the schema module could not be evaluated — `result.issues` says why. */
740
+ readonly schema?: z.ZodType;
741
+ /** Every parameter the tree holds, resolved for this environment. */
742
+ readonly resolutions: readonly Resolution[];
743
+ /** The schema-validated object. Present only when the verdict passed. */
744
+ readonly validated?: unknown;
745
+ }
436
746
  declare function runValidate(options: ValidateOptions): Promise<ValidateResult>;
747
+ declare function checkEnvironment(project: Project, environment: string): Promise<EnvironmentCheck>;
437
748
 
438
749
  interface ImportOptions {
439
750
  readonly cwd: string;
@@ -518,7 +829,7 @@ interface ListEntry {
518
829
  readonly variable: string;
519
830
  /** `<env>.local`, `local`, an environment name, `default`, or `absent`. */
520
831
  readonly scope: string;
521
- /** The winning value file relative to `.penv/`, or `undefined` when nothing wins. */
832
+ /** The winning value file relative to the records tree, or `undefined` when nothing wins. */
522
833
  readonly location: string | undefined;
523
834
  readonly encrypted: boolean;
524
835
  readonly viaUnscopedFallback: boolean;
@@ -529,6 +840,66 @@ interface ListResult {
529
840
  }
530
841
  declare function runList(options: ListOptions): Promise<ListResult>;
531
842
 
843
+ /**
844
+ * `penv migrate` — move a project's records under `.penv/state/records/`.
845
+ *
846
+ * penv reads one layout, so this is the one command that knows two. It is a
847
+ * relocation and nothing else: records move byte for byte, keeping their names,
848
+ * so the grammar, the cascade, the meta and the AAD that binds a ciphertext to
849
+ * its address all mean afterwards exactly what they meant before.
850
+ * `penv.schema.ts`, `penv.config.ts` and `.penv/env.ts` are the project's, and
851
+ * are never touched.
852
+ *
853
+ * It previews before it moves, because the one thing a migration must not do is
854
+ * surprise the person who ran it — and it refuses a half-migrated tree rather
855
+ * than merging two, since which copy of a parameter is current is a question
856
+ * only the user can answer.
857
+ */
858
+ /** One thing that moves, project-relative and POSIX. */
859
+ interface MigrateMove {
860
+ readonly from: string;
861
+ readonly to: string;
862
+ }
863
+ interface MigratePlan {
864
+ readonly root: string;
865
+ /** What moves, in the order it is reported. */
866
+ readonly moves: readonly MigrateMove[];
867
+ /** What penv writes that is not there yet — the tree root and the safety boundary. */
868
+ readonly creates: readonly string[];
869
+ /** What penv removes: the ignore file that described the old layout. */
870
+ readonly removes: readonly string[];
871
+ }
872
+ /**
873
+ * `previewed` is a plan nobody approved, so nothing was written; `current` is a
874
+ * project that was already on the new layout, which is what a second run says.
875
+ */
876
+ type MigrateStatus = "migrated" | "current" | "previewed";
877
+ interface MigrateResult extends MigratePlan {
878
+ readonly status: MigrateStatus;
879
+ }
880
+ interface MigrateOptions {
881
+ readonly cwd: string;
882
+ /** Approve the plan. Without it `migrate` previews and writes nothing. */
883
+ readonly yes?: boolean;
884
+ }
885
+ /**
886
+ * What a migration would do, without doing any of it.
887
+ *
888
+ * The move list is `oldLayoutEntries` — the same list every command's refusal is
889
+ * keyed off — so the preview can never describe a different migration from the
890
+ * one that runs.
891
+ */
892
+ declare function planMigrate(cwd: string): MigratePlan;
893
+ /**
894
+ * Performs a plan. Separate from {@link planMigrate} so what runs is the plan the
895
+ * user approved, not a second reading of the disk between the question and the
896
+ * answer.
897
+ */
898
+ declare function applyMigrate(plan: MigratePlan): MigrateResult;
899
+ /** Plans, and applies only when the move was approved. */
900
+ declare function runMigrate(options: MigrateOptions): MigrateResult;
901
+ declare function renderMigrate(result: MigrateResult): string[];
902
+
532
903
  /**
533
904
  * `penv mv <from> <to>` — rename a parameter, every scope at once.
534
905
  *
@@ -664,7 +1035,7 @@ interface RemoveOptions extends ScopeOptions {
664
1035
  }
665
1036
  interface RemoveResult {
666
1037
  readonly parameter: string;
667
- /** The value files that existed and are now gone, relative to `.penv/`. */
1038
+ /** The value files that existed and are now gone, relative to the records tree. */
668
1039
  readonly removed: readonly string[];
669
1040
  /** Both files penv looked at, whether or not they were there. */
670
1041
  readonly considered: readonly string[];
@@ -710,44 +1081,90 @@ declare function runRotate(options: RotateOptions): Promise<RotateResult>;
710
1081
  declare function renderRotate(result: RotateResult): string[];
711
1082
 
712
1083
  /**
713
- * The committed snapshot — `penv.snapshot.ts` at the project root — that lets
714
- * `load()` resolve in a bundled or serverless runtime where no `penv.config.ts`
715
- * or `.penv/` tree is on disk. It embeds the evaluated config and every committed
716
- * sealed value; the scaffolded `env.ts` imports it and passes it to `load`.
1084
+ * Starting someone else's command, opaquely.
717
1085
  *
718
- * Sealed records only, by decision: the snapshot ships exactly what a git clone
719
- * already sees — ciphertext, safe to commit — and never plaintext, at any scope,
720
- * nor either `.local` scope. Determinism is the point of the text output: value
721
- * keys are code-unit sorted, so `doctor snapshot-stale` is a plain text compare
722
- * against a recomputed snapshot.
1086
+ * `penv run -- <command>` starts exactly what follows `--`: the argument
1087
+ * boundaries the shell already worked out are handed to the operating system
1088
+ * untouched, stdio is the parent's, and the child's exit code and terminating
1089
+ * signal come back out. penv never parses the command, never rebuilds a command
1090
+ * line from it, never wraps it in a shell — a shell would re-split what the user
1091
+ * already split, and `penv run -- node -e "console.log(1 > 2)"` would redirect to
1092
+ * a file called `2`.
723
1093
  *
724
- * It sits beside `penv.config.ts` and `penv.schema.ts`, outside `.penv/`, so the
725
- * value-file grammar walker never sees it (no `StrayCodeFileError`) and it is
726
- * committed by default — the same placement rationale as the schema shape.
1094
+ * Windows is the one place where "hand it to the operating system" needs help.
1095
+ * `pnpm`, `next` and every other node-installed tool are `.cmd` shims there, and
1096
+ * Node refuses to execute one without a shell. So a `.cmd`/`.bat` target — and
1097
+ * only that — is started through `cmd.exe /d /s /c` with
1098
+ * `windowsVerbatimArguments`, building the one command line cmd will accept and
1099
+ * escaping every argument so that cmd hands the child the same bytes penv was
1100
+ * given. Everything else spawns directly, on every platform.
727
1101
  */
728
1102
 
729
- interface SnapshotWriteResult {
730
- readonly file: string;
731
- readonly action: "created" | "updated" | "unchanged";
1103
+ /** How a child ended. Exactly one of these is meaningful, and both are forwarded. */
1104
+ interface ChildResult {
1105
+ /** The child's own exit code, or 1 when a signal ended it. */
1106
+ readonly exitCode: number;
1107
+ /** The signal that ended the child, when one did. */
1108
+ readonly signal: NodeJS.Signals | null;
732
1109
  }
733
- /** What {@link wireEnvModule} did — `manual` carries the exact lines to add by hand. */
734
- interface WireResult {
735
- readonly file: string;
736
- readonly action: "wired" | "kept" | "manual";
737
- /** The import line to add — printed on `manual`. */
738
- readonly importLine: string;
739
- /** How to add `snapshot` to the load options — printed on `manual`. */
740
- readonly loadHint: string;
1110
+ interface ChildInvocation {
1111
+ /** The command exactly as it followed `--`: the executable, then its arguments. */
1112
+ readonly command: readonly string[];
1113
+ readonly env: Record<string, string>;
1114
+ readonly cwd: string;
741
1115
  }
742
-
743
- interface SnapshotResult {
744
- readonly write: SnapshotWriteResult;
745
- readonly wire: WireResult;
1116
+ /** A started child: how it ends, and the one thing a wrapper may do to it. */
1117
+ interface ChildHandle {
1118
+ /** Resolves when the child has ended, however it ended. */
1119
+ readonly ended: Promise<ChildResult>;
1120
+ /** Asks the child to stop — what `--watch` does before it starts the next one. */
1121
+ kill(signal?: NodeJS.Signals): void;
746
1122
  }
747
- declare function runSnapshot(options: {
1123
+ /** The seam `run` starts a child through — replaced in tests that assert what it was given. */
1124
+ type StartChild = (invocation: ChildInvocation) => ChildHandle;
1125
+
1126
+ /** Where a run reads its values from. `snapshot` is the sealed artifact. */
1127
+ type RunSource = "project" | "snapshot";
1128
+ interface RunOptions {
748
1129
  readonly cwd: string;
749
- }): SnapshotResult;
750
- declare function renderSnapshot(result: SnapshotResult): string[];
1130
+ readonly environment?: string;
1131
+ /** Bare flags the command did not declare — environment shorthands, judged against the whitelist. */
1132
+ readonly envFlags?: readonly string[];
1133
+ /** Defaults to `project`. */
1134
+ readonly source?: string;
1135
+ /** The one mode allowed to synchronise. Off by default. */
1136
+ readonly watch?: boolean;
1137
+ /** The command exactly as it followed `--`. */
1138
+ readonly command: readonly string[];
1139
+ /** The environment penv itself was started with. Defaults to `process.env`. */
1140
+ readonly host?: Readonly<Record<string, string | undefined>>;
1141
+ /** Injected in tests: how a child is started. */
1142
+ readonly start?: StartChild;
1143
+ /** Injected in tests: the sync `--watch` performs. */
1144
+ readonly pull?: (options: PullOptions) => Promise<PullResult>;
1145
+ /** Injected in tests: what tells `--watch` something changed. */
1146
+ readonly changes?: (onChange: () => void) => {
1147
+ close(): void;
1148
+ };
1149
+ /** How long a replaced child has to leave before `--watch` insists. Defaults to 5s. */
1150
+ readonly stopGraceMs?: number;
1151
+ }
1152
+ interface RunResult {
1153
+ readonly environment: string;
1154
+ readonly source: RunSource;
1155
+ readonly command: readonly string[];
1156
+ /** Declared variables written into the child. */
1157
+ readonly written: number;
1158
+ /** Declared-but-valueless variables deleted from the child. */
1159
+ readonly deleted: number;
1160
+ /** penv's own variables removed before the child saw them. */
1161
+ readonly stripped: readonly string[];
1162
+ readonly exitCode: number;
1163
+ readonly signal: NodeJS.Signals | null;
1164
+ /** How many times `--watch` replaced the child. */
1165
+ readonly restarts: number;
1166
+ }
1167
+ declare function runRun(options: RunOptions): Promise<RunResult>;
751
1168
 
752
1169
  interface WatchOptions {
753
1170
  readonly cwd: string;
@@ -786,6 +1203,54 @@ declare function runWatch(options: WatchOptions): WatchHandle;
786
1203
  */
787
1204
  declare function renderWatch(result: ValidateResult): string[];
788
1205
 
1206
+ interface Cutover {
1207
+ readonly format: number;
1208
+ /** When the files were moved, ISO-8601 UTC. */
1209
+ readonly movedAt: string;
1210
+ /** The filenames as they were at the project root — what undo restores, exactly. */
1211
+ readonly files: readonly string[];
1212
+ /** The environments the cutover declared, so a report can name them without the config. */
1213
+ readonly environments: readonly string[];
1214
+ }
1215
+ interface UndoResult {
1216
+ readonly root: string;
1217
+ /** The files put back, in the order they were moved. */
1218
+ readonly restored: readonly string[];
1219
+ /** Recorded names already at the project root — what an interrupted undo had reached. */
1220
+ readonly alreadyBack: readonly string[];
1221
+ /** Recorded names in neither the bundle nor the project root. Nothing penv can restore. */
1222
+ readonly missing: readonly string[];
1223
+ }
1224
+ /**
1225
+ * Puts every bundled file back under its exact original name, then drops the
1226
+ * bundle and the state that named it.
1227
+ *
1228
+ * Undo is resumable, because the thing it recovers from is an interruption. A
1229
+ * name already at the project root and no longer in the bundle is a file an
1230
+ * earlier run put back, not a collision — only a name that is in both places at
1231
+ * once is, and that is the one case worth refusing over, since restoring would
1232
+ * write over whatever came back. A name in neither place is reported rather than
1233
+ * refused: the old refusal's remedy was `penv cleanup`, which would have deleted
1234
+ * every file that was still recoverable.
1235
+ */
1236
+ declare function runUndo(options: {
1237
+ readonly cwd: string;
1238
+ }): UndoResult;
1239
+ interface CleanupResult {
1240
+ readonly root: string;
1241
+ /** The files the bundle held. Empty when there was nothing to clean up. */
1242
+ readonly removed: readonly string[];
1243
+ readonly cleaned: boolean;
1244
+ }
1245
+ /**
1246
+ * Drops the rollback bundle and the cutover state, and nothing else. The records
1247
+ * tree, the schema, the config and the loader are the project's — cleanup is the
1248
+ * end of the migration, not the end of the adoption.
1249
+ */
1250
+ declare function runCleanup(options: {
1251
+ readonly cwd: string;
1252
+ }): CleanupResult;
1253
+
789
1254
  /**
790
1255
  * penv's command line.
791
1256
  *
@@ -797,4 +1262,4 @@ declare function renderWatch(result: ValidateResult): string[];
797
1262
  declare const main: citty.CommandDef<citty.ArgsDef>;
798
1263
  declare function runMain(): Promise<void>;
799
1264
 
800
- export { type DoctorCheck, type DoctorFinding, type DoctorReport, type DoctorSeverity, type FillOptions, type FillPrompt, type FillResult, type GenerateResult, type GetExplanation, type ImportReport, type InitResult, type InitStep, LAST_PUSHED_KEY, type ListResult, type MoveResult, type PullOptions, type PullResult, type PushOptions, type PushResult, type RemoveResult, type ResealResult, type RotateOptions, type RotatePhase, type RotateResult, type SetResult, type SnapshotResult, type ValidateIssue, type ValidateResult, type WatchHandle, type WatchOptions, generateDotenv, importDotenv, insertEnvAlias, main, renderDoctor, renderFill, renderMove, renderPull, renderPush, renderRotate, renderSnapshot, renderWatch, runDecrypt, runDoctor, runEncrypt, runExplain, runFill, runGenerate, runGet, runInit, runList, runMain, runMove, runPull, runPush, runRemove, runRotate, runSet, runSnapshot, runValidate, runWatch };
1265
+ export { type AdoptionPlan, type ArtifactBuildOptions, type ArtifactBuildResult, type CleanupResult, type Cutover, type CutoverPlan, type CutoverResult, type DoctorCheck, type DoctorFinding, type DoctorReport, type DoctorSeverity, type EnvironmentCheck, type FillOptions, type FillPrompt, type FillResult, type GenerateResult, type GetExplanation, type ImportReport, type InitResult, type InitStep, LAST_PUSHED_KEY, type ListResult, type MigrateMove, type MigratePlan, type MigrateResult, type MigrateStatus, type MoveResult, type PullOptions, type PullResult, type PushOptions, type PushResult, type RemoveResult, type ResealResult, type RotateOptions, type RotatePhase, type RotateResult, type RunOptions, type RunResult, type RunSource, type SetResult, type UndoResult, type ValidateIssue, type ValidateResult, type WatchHandle, type WatchOptions, applyCutover, applyMigrate, checkEnvironment, generateDotenv, importDotenv, insertEnvAlias, main, planAdoption, planCutover, planMigrate, renderArtifactBuild, renderDoctor, renderFill, renderMigrate, renderMove, renderPull, renderPush, renderRotate, renderWatch, runArtifactBuild, runCleanup, runDecrypt, runDoctor, runEncrypt, runExplain, runFill, runGenerate, runGet, runInit, runList, runMain, runMigrate, runMove, runPull, runPush, runRemove, runRotate, runRun, runSet, runUndo, runValidate, runWatch };