@pithy-sh/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/package.json +72 -0
- package/scripts/templateManifest.ts +49 -0
- package/scripts/tsconfig.json +26 -0
- package/scripts/vendorTemplate.ts +84 -0
- package/scripts/verifyPack.ts +88 -0
- package/src/audit/cliAudit.ts +406 -0
- package/src/bin.ts +111 -0
- package/src/capabilities/add.ts +288 -0
- package/src/capabilities/addBootstrap.ts +275 -0
- package/src/capabilities/catalog.ts +175 -0
- package/src/capabilities/compose.ts +39 -0
- package/src/capabilities/configConstants.ts +74 -0
- package/src/capabilities/configImports.ts +397 -0
- package/src/capabilities/eject.ts +331 -0
- package/src/capabilities/emailProvisioner.ts +346 -0
- package/src/capabilities/entitlementGap.ts +70 -0
- package/src/capabilities/entryExports.ts +162 -0
- package/src/capabilities/flow.ts +550 -0
- package/src/capabilities/hostRegistry.ts +368 -0
- package/src/capabilities/loadFailure.ts +208 -0
- package/src/capabilities/manifests.ts +238 -0
- package/src/capabilities/mediaProvisioner.ts +471 -0
- package/src/capabilities/mintSecrets.ts +306 -0
- package/src/capabilities/paymentsProvisioner.ts +207 -0
- package/src/capabilities/prerequisites.ts +168 -0
- package/src/capabilities/r2Bucket.ts +113 -0
- package/src/capabilities/reconcile.ts +1483 -0
- package/src/capabilities/remove.ts +597 -0
- package/src/capabilities/requiredOptions.ts +92 -0
- package/src/capabilities/rotateSecrets.ts +305 -0
- package/src/capabilities/secrets.ts +178 -0
- package/src/capabilities/secretsDispatcher.ts +29 -0
- package/src/capabilities/secretsProvisioner.ts +389 -0
- package/src/capabilities/storageProvisioner.ts +414 -0
- package/src/capabilities/supportProvisioner.ts +515 -0
- package/src/capabilities/testersLoader.ts +52 -0
- package/src/capabilities/testersProvisioner.ts +236 -0
- package/src/capabilities/turnstileProvisioner.ts +347 -0
- package/src/capabilities/vectorProvisioner.ts +260 -0
- package/src/ci/fileModes.ts +223 -0
- package/src/ci/sourceFiles.ts +200 -0
- package/src/ci/workflowDrivers.ts +524 -0
- package/src/cloudflare/accountAnswer.ts +110 -0
- package/src/cloudflare/config.ts +685 -0
- package/src/cloudflare/storeId.ts +129 -0
- package/src/commands/add.ts +372 -0
- package/src/commands/alias.ts +205 -0
- package/src/commands/dashboard.ts +651 -0
- package/src/commands/deploy.ts +150 -0
- package/src/commands/dev.ts +37 -0
- package/src/commands/doctor.ts +2059 -0
- package/src/commands/email.ts +425 -0
- package/src/commands/env.ts +155 -0
- package/src/commands/feature.ts +359 -0
- package/src/commands/init.ts +538 -0
- package/src/commands/media.ts +303 -0
- package/src/commands/migrate.ts +129 -0
- package/src/commands/payments.ts +336 -0
- package/src/commands/provision.ts +368 -0
- package/src/commands/remove.ts +151 -0
- package/src/commands/secrets.ts +652 -0
- package/src/commands/seed.ts +229 -0
- package/src/commands/storage.ts +309 -0
- package/src/commands/support.ts +331 -0
- package/src/commands/testers.ts +1020 -0
- package/src/commands/token.ts +364 -0
- package/src/commands/turnstile.ts +271 -0
- package/src/commands/ui.ts +222 -0
- package/src/commands/upgrade.ts +517 -0
- package/src/commands/vector.ts +390 -0
- package/src/commands/worker.ts +295 -0
- package/src/dashboard/api.ts +323 -0
- package/src/dashboard/connect.ts +758 -0
- package/src/dashboard/contract.ts +289 -0
- package/src/dashboard/grant.ts +124 -0
- package/src/dashboard/registry.ts +519 -0
- package/src/dashboard/resolveTarget.ts +119 -0
- package/src/dev/delivery.ts +174 -0
- package/src/dev/devLogin.ts +155 -0
- package/src/dev/devLoginTargets.ts +91 -0
- package/src/dev/env.ts +206 -0
- package/src/dev/hostWorkers.ts +290 -0
- package/src/dev/keys.ts +111 -0
- package/src/dev/logging.ts +87 -0
- package/src/dev/openUrl.ts +75 -0
- package/src/dev/orchestrator.ts +1014 -0
- package/src/dev/ports.ts +220 -0
- package/src/dev/readyWatch.ts +142 -0
- package/src/dev/state.ts +90 -0
- package/src/devSecrets/bootstrapVars.ts +265 -0
- package/src/devSecrets/devVars.ts +240 -0
- package/src/devSecrets/edit.ts +256 -0
- package/src/devSecrets/file.ts +277 -0
- package/src/devSecrets/generate.ts +428 -0
- package/src/devSecrets/location.ts +80 -0
- package/src/devSecrets/mode.ts +71 -0
- package/src/devSecrets/records.ts +30 -0
- package/src/devSecrets/report.ts +99 -0
- package/src/devSecrets/seed.ts +344 -0
- package/src/devSecrets/store.ts +262 -0
- package/src/devSecrets/targets.ts +204 -0
- package/src/dispatch.ts +147 -0
- package/src/docs/catalog.ts +246 -0
- package/src/docs/writeCatalog.ts +45 -0
- package/src/doctor/cloudflare.ts +287 -0
- package/src/doctor/devPreferences.ts +155 -0
- package/src/doctor/devSecrets.ts +464 -0
- package/src/doctor/devVars.ts +414 -0
- package/src/doctor/devVarsLocal.ts +138 -0
- package/src/doctor/environments.ts +155 -0
- package/src/doctor/health.ts +354 -0
- package/src/doctor/localDelivery.ts +91 -0
- package/src/doctor/portsRegistry.ts +252 -0
- package/src/doctor/projectName.ts +584 -0
- package/src/doctor/secretBindings.ts +166 -0
- package/src/doctor/settings.ts +274 -0
- package/src/doctor/settingsSources.ts +202 -0
- package/src/doctor/workerName.ts +174 -0
- package/src/doctor/wranglerVars.ts +33 -0
- package/src/feature/bindings.ts +93 -0
- package/src/feature/create.ts +179 -0
- package/src/feature/destroy.ts +160 -0
- package/src/feature/devConfig.ts +201 -0
- package/src/feature/identity.ts +100 -0
- package/src/feature/manifest.ts +132 -0
- package/src/feature/ports.ts +615 -0
- package/src/feature/provision.ts +362 -0
- package/src/feature/sync.ts +148 -0
- package/src/feature/worktree.ts +282 -0
- package/src/help/groups.ts +47 -0
- package/src/help/rootUsage.ts +135 -0
- package/src/main.ts +73 -0
- package/src/migrations/ledger.ts +129 -0
- package/src/migrations/registry.ts +47 -0
- package/src/migrations/run.ts +1066 -0
- package/src/notifier/check.ts +129 -0
- package/src/notifier/installer.ts +48 -0
- package/src/notifier/notify.ts +152 -0
- package/src/notifier/state.ts +248 -0
- package/src/notifier/version.ts +59 -0
- package/src/platform/editor.ts +333 -0
- package/src/platform/rc.ts +118 -0
- package/src/platform/shell.ts +83 -0
- package/src/project/appBindings.ts +184 -0
- package/src/project/appWorkflows.ts +266 -0
- package/src/project/applyDomains.ts +166 -0
- package/src/project/askDomains.ts +220 -0
- package/src/project/atomic.ts +466 -0
- package/src/project/bindingEntries.ts +425 -0
- package/src/project/config.ts +701 -0
- package/src/project/dashboard.ts +118 -0
- package/src/project/deploy.ts +364 -0
- package/src/project/devVars.ts +113 -0
- package/src/project/domainPrompt.ts +191 -0
- package/src/project/domains.ts +386 -0
- package/src/project/envInventory.ts +356 -0
- package/src/project/environment.ts +125 -0
- package/src/project/extensions.ts +69 -0
- package/src/project/jsonc.ts +289 -0
- package/src/project/packageManager.ts +238 -0
- package/src/project/readOptionalFile.ts +342 -0
- package/src/project/rollback.ts +145 -0
- package/src/project/scaffold.ts +1088 -0
- package/src/project/templateFiles.ts +53 -0
- package/src/project/verifyDeploy.ts +230 -0
- package/src/project/versionMetadata.ts +77 -0
- package/src/project/workerAddress.ts +176 -0
- package/src/project/workerCommand.ts +564 -0
- package/src/project/workerIdentity.ts +50 -0
- package/src/project/workerManifest.ts +135 -0
- package/src/project/workerScaffold.ts +289 -0
- package/src/project/workerScope.ts +394 -0
- package/src/project/workers.ts +86 -0
- package/src/project/workflows.ts +281 -0
- package/src/project/wrangler.ts +168 -0
- package/src/provision/confirm.ts +86 -0
- package/src/provision/environment.ts +407 -0
- package/src/provision/featureConfig.ts +98 -0
- package/src/provision/mode.ts +62 -0
- package/src/provision/pendingSecrets.ts +96 -0
- package/src/provision/resources.ts +126 -0
- package/src/provision/secretBindings.ts +149 -0
- package/src/provision/store.ts +33 -0
- package/src/provision/unprovisioned.ts +114 -0
- package/src/provision/wranglerEnv.ts +220 -0
- package/src/rootFlags.ts +48 -0
- package/src/seed/drivers.ts +423 -0
- package/src/seed/media.ts +187 -0
- package/src/seed/plan.ts +137 -0
- package/src/seed/prepare.ts +224 -0
- package/src/seed/registry.ts +25 -0
- package/src/seed/run.ts +793 -0
- package/src/seed/safety.ts +206 -0
- package/src/terminal/logger.ts +42 -0
- package/src/terminal/output.ts +64 -0
- package/src/terminal/style.ts +132 -0
- package/src/test-utils/doctorHarness.ts +190 -0
- package/src/test-utils/migrateHarness.ts +126 -0
- package/src/test-utils/seedHarness.ts +173 -0
- package/src/test-utils/tempRepo.ts +45 -0
- package/src/tokens/config.ts +16 -0
- package/src/tokens/engine.ts +345 -0
- package/src/tokens/mintedTokens.ts +233 -0
- package/src/tokens/sinks.ts +84 -0
- package/src/ui/flow.ts +451 -0
- package/src/ui/react.ts +112 -0
- package/src/ui/routeAllowlist.ts +208 -0
- package/src/ui/scaffold.ts +113 -0
- package/src/ui/screenStyles.ts +127 -0
- package/src/ui/stubs.ts +135 -0
- package/src/ui/templates.ts +52 -0
- package/src/ui/wire.ts +311 -0
- package/src/ui/workerUi.ts +172 -0
- package/templates/starter/.dev.secrets.example.jsonc +43 -0
- package/templates/starter/.dev.vars.example +30 -0
- package/templates/starter/apps/api/package.json +22 -0
- package/templates/starter/apps/api/pithy.config.ts +65 -0
- package/templates/starter/apps/api/pithy.worker.jsonc +11 -0
- package/templates/starter/apps/api/src/bindings.workers.test.ts +18 -0
- package/templates/starter/apps/api/src/cloudflare-test.d.ts +11 -0
- package/templates/starter/apps/api/src/index.ts +8 -0
- package/templates/starter/apps/api/tsconfig.json +26 -0
- package/templates/starter/apps/api/wrangler.jsonc +68 -0
- package/templates/starter/biome.template.jsonc +75 -0
- package/templates/starter/gitignore +37 -0
- package/templates/starter/package.json +28 -0
- package/templates/starter/pithy.config.ts +67 -0
- package/templates/starter/plugins/no-console.grit +25 -0
- package/templates/starter/plugins/no-process-io.grit +25 -0
- package/templates/starter/tsconfig.json +14 -0
- package/templates/starter/tsconfig.tools.json +30 -0
- package/templates/starter/vitest.config.ts +124 -0
- package/templates/starter/vitest.workers.config.ts +26 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
5
|
+
import { ConflictError, InternalError, type PithyError } from "@pithy-sh/core/src/error/pithyError";
|
|
6
|
+
import type { z } from "zod";
|
|
7
|
+
import { errnoOf } from "./atomic";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The one place a failed file read is turned into "there is no file".
|
|
11
|
+
*
|
|
12
|
+
* **Only `ENOENT` means the file is not there.** Every other errno is a file that exists and did not
|
|
13
|
+
* open: `EACCES` after someone tightened a mode, `EISDIR`, `ELOOP`, `EIO` on a failing disk. That
|
|
14
|
+
* sentence is one line long and this codebase has now paid for it three times, in three files, written
|
|
15
|
+
* by three separate changes:
|
|
16
|
+
*
|
|
17
|
+
* - `.dev.vars` (`../devSecrets/devVars.ts`). A read that answered "empty" for `EACCES` meant the next
|
|
18
|
+
* content was built from an empty base and renamed over a file full of values the process never saw —
|
|
19
|
+
* an adopter's `CLOUDFLARE_API_TOKEN` and every other line, gone, with the run reporting a clean write.
|
|
20
|
+
* - `dev.json` (`../devSecrets/bootstrapVars.ts`). Read-modify-write over a file with other tenants: a
|
|
21
|
+
* present-but-unreadable one would have been replaced, silently deleting a developer's dev-login
|
|
22
|
+
* preferences.
|
|
23
|
+
* - `pithy.manifest.json` (`../capabilities/manifests.ts`, #184). A present-but-invalid manifest read as
|
|
24
|
+
* absent made the capability vanish from `pithy add --list`, `upgrade` and `doctor` with no message at
|
|
25
|
+
* all — the three commands most likely to be run when something is missing were the three that stayed
|
|
26
|
+
* silent.
|
|
27
|
+
*
|
|
28
|
+
* Three producers is this repository's usual count for a rule that lives at call sites instead of at the
|
|
29
|
+
* thing being called. The mechanism is not carelessness: `.catch(() => null)` is *shorter* than the
|
|
30
|
+
* correct version, so the wrong thing is the thing that gets typed. A primitive inverts that, and gives
|
|
31
|
+
* the invariant one place to be tested rather than a fresh test per reader — which is what a fourth
|
|
32
|
+
* reader silently goes without.
|
|
33
|
+
*
|
|
34
|
+
* **What stays at the call site is the sentence the adopter reads.** A `.dev.vars` that will not open and
|
|
35
|
+
* a `dev.json` that will not open want different words and different error classes, and neither belongs
|
|
36
|
+
* here; `options.unreadable` is where a caller keeps its own. What may not stay at the call site is the
|
|
37
|
+
* decision about which errno means absence.
|
|
38
|
+
*
|
|
39
|
+
* The gate for this rule is in `./readOptionalFile.test.ts` — stated about any module that writes, not
|
|
40
|
+
* about the three readers above, because enumerating the known ones is exactly what produced the second
|
|
41
|
+
* and the third.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* What one read produced. Three answers, because collapsing any two of them is the defect above — and a
|
|
46
|
+
* caller that must not throw needs all three as values rather than as an exception it has to catch.
|
|
47
|
+
*/
|
|
48
|
+
export type FileRead =
|
|
49
|
+
/** The file is there and these are its bytes. */
|
|
50
|
+
| { readonly state: "read"; readonly text: string }
|
|
51
|
+
/** `ENOENT`, and nothing else: nothing has been written at that path. */
|
|
52
|
+
| { readonly state: "absent" }
|
|
53
|
+
/** The file is there and did not open. The errno says which failure it was; the cause is node's own. */
|
|
54
|
+
| { readonly state: "unreadable"; readonly code: string | undefined; readonly cause: unknown };
|
|
55
|
+
|
|
56
|
+
/** What a call site is told about a read that failed for anything but `ENOENT`. */
|
|
57
|
+
export interface UnreadableFile {
|
|
58
|
+
/** The path that would not open, exactly as it was asked for. */
|
|
59
|
+
readonly path: string;
|
|
60
|
+
/** The errno string, or `undefined` when the failure carried none. Never the file's contents. */
|
|
61
|
+
readonly code: string | undefined;
|
|
62
|
+
/** Node's own error, to be carried as `cause` so nothing about the failure is lost. */
|
|
63
|
+
readonly cause: unknown;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** How {@link readOptionalFile} refuses. */
|
|
67
|
+
export interface ReadOptionalFileOptions {
|
|
68
|
+
/**
|
|
69
|
+
* The refusal this file deserves, in this command's words — `.dev.vars` and `dev.json` each have their
|
|
70
|
+
* own, and both predate this module. Returning anything but a `PithyError` is not possible, so a
|
|
71
|
+
* caller cannot quietly turn the refusal back into an absence here.
|
|
72
|
+
*/
|
|
73
|
+
readonly unreadable?: (failure: UnreadableFile) => PithyError;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The file's bytes, or `null` when there is no file — **and only when there is no file.**
|
|
78
|
+
*
|
|
79
|
+
* Anything else throws, naming the path and the errno and carrying node's error as `cause`. The message
|
|
80
|
+
* holds the path because the operator has to look at it; it never holds a byte of the file, which for
|
|
81
|
+
* every file this was written for would be a credential.
|
|
82
|
+
*/
|
|
83
|
+
export async function readOptionalFile(path: string, options: ReadOptionalFileOptions = {}): Promise<string | null> {
|
|
84
|
+
const read = await readFileOutcome(path);
|
|
85
|
+
if (read.state === "read") return read.text;
|
|
86
|
+
if (read.state === "absent") return null;
|
|
87
|
+
const failure = { path, code: read.code, cause: read.cause };
|
|
88
|
+
throw options.unreadable?.(failure) ?? defaultRefusal(failure);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The same read, as a value rather than an exception: bytes, absence, or the failure that is neither.
|
|
93
|
+
*
|
|
94
|
+
* For the caller that was asked about *many* files and must still answer for the rest. `availableManifests`
|
|
95
|
+
* scans every installed package: one that ships no manifest is ordinary and silent, one whose manifest will
|
|
96
|
+
* not open is a fault to report by name, and a throw would take the listing of the other fifteen with it.
|
|
97
|
+
*/
|
|
98
|
+
export async function readFileOutcome(path: string): Promise<FileRead> {
|
|
99
|
+
try {
|
|
100
|
+
return { state: "read", text: await readFile(path, "utf8") };
|
|
101
|
+
} catch (cause) {
|
|
102
|
+
const code = errnoOf(cause);
|
|
103
|
+
if (code === "ENOENT") return { state: "absent" };
|
|
104
|
+
return { state: "unreadable", code, cause };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The refusal a caller that named no words of its own gets: the path, the errno, and node's error. */
|
|
109
|
+
function defaultRefusal({ path, code, cause }: UnreadableFile): PithyError {
|
|
110
|
+
return new InternalError(
|
|
111
|
+
{
|
|
112
|
+
message: `${path} is there and could not be read.`,
|
|
113
|
+
action: "Check that path and its permissions, then run the command again.",
|
|
114
|
+
detail: `${code ?? "unknown error"} while reading ${path}`,
|
|
115
|
+
},
|
|
116
|
+
{ cause },
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The second half of the same sentence, for the reader that parses what it read.
|
|
122
|
+
*
|
|
123
|
+
* {@link readOptionalFile} closes *"the file would not open"*. It cannot close *"the file opened and is
|
|
124
|
+
* not a document"*, because that read genuinely succeeded — the loss happens one step later, when a
|
|
125
|
+
* writer merges into a value it decided was empty and renames the result over everything the process
|
|
126
|
+
* never saw. Three readers reached that decision independently: `pithy.worker.jsonc` (#204),
|
|
127
|
+
* `tokens.json` and `dev.json` (#209, both credential files holding other tenants' keys). Three is this
|
|
128
|
+
* repository's count for a rule living at call sites instead of at the thing being called, so it lives
|
|
129
|
+
* here, beside the other decision no reader may make for itself.
|
|
130
|
+
*
|
|
131
|
+
* **Absent is `{}` and is the caller's answer to make. Present-but-not-a-record is this one's refusal.**
|
|
132
|
+
* A reader that has nothing to destroy may still choose `{}` for a file that will not *parse*; what no
|
|
133
|
+
* reader may choose is that a value which parsed to a string is a document to write from.
|
|
134
|
+
*
|
|
135
|
+
* All three producers ask this one, `ui/workerUi.ts` included — the tag check was written there for
|
|
136
|
+
* #204, and moving it here rather than copying it is the difference between a rule and a convention.
|
|
137
|
+
* Each keeps its own sentence through `options.notARecord`, exactly as each keeps its own refusal for a
|
|
138
|
+
* file that would not open.
|
|
139
|
+
*/
|
|
140
|
+
export function requireRecord(
|
|
141
|
+
path: string,
|
|
142
|
+
value: unknown,
|
|
143
|
+
options: RequireRecordOptions = {},
|
|
144
|
+
): Record<string, unknown> {
|
|
145
|
+
const shape = shapeOf(value);
|
|
146
|
+
if (shape === "object") return value as Record<string, unknown>;
|
|
147
|
+
const failure = { path, found: asWords(shape) };
|
|
148
|
+
throw options.notARecord?.(failure) ?? defaultShapeRefusal(failure);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** What a call site is told about a value that parsed and is not a record. Never the value itself. */
|
|
152
|
+
export interface NotARecord {
|
|
153
|
+
/** The file the value came out of, exactly as it was asked for. */
|
|
154
|
+
readonly path: string;
|
|
155
|
+
/** The shape as a sentence names it — `null`, `an array`, `a string`. Never a byte of the file. */
|
|
156
|
+
readonly found: string;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** How {@link requireRecord} refuses. */
|
|
160
|
+
export interface RequireRecordOptions {
|
|
161
|
+
/**
|
|
162
|
+
* The refusal this file deserves, in this command's words — a `tokens.json` and a `pithy.worker.jsonc`
|
|
163
|
+
* name different things and want different error classes. Returning anything but a `PithyError` is not
|
|
164
|
+
* possible, so a caller cannot quietly turn the refusal back into an empty document here.
|
|
165
|
+
*/
|
|
166
|
+
readonly notARecord?: (failure: NotARecord) => PithyError;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The value's shape, by its own tag rather than by `typeof`, which answers this wrong twice.
|
|
171
|
+
*
|
|
172
|
+
* `null` is a `"object"` — so a `null` check reads as the whole rule and is half of it. And
|
|
173
|
+
* **comment-json boxes a top-level primitive** so it has somewhere to hang the file's comments:
|
|
174
|
+
* `parse('"react"')` is a `String` object, not `null`, and `typeof` calls it an `"object"` too. Only a
|
|
175
|
+
* `String` *object* would survive both checks, which is why this asks the tag.
|
|
176
|
+
*/
|
|
177
|
+
function shapeOf(value: unknown): string {
|
|
178
|
+
return Object.prototype.toString.call(value).slice(8, -1).toLowerCase();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The shape as the sentence names it: `null`, `an array`, `a string`. */
|
|
182
|
+
function asWords(shape: string): string {
|
|
183
|
+
if (shape === "null" || shape === "undefined") return shape;
|
|
184
|
+
return /^[aeiou]/.test(shape) ? `an ${shape}` : `a ${shape}`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The refusal a caller that named no words of its own gets: the path and the shape, never the value. */
|
|
188
|
+
function defaultShapeRefusal({ path, found }: NotARecord): PithyError {
|
|
189
|
+
return new ConflictError({
|
|
190
|
+
message: `Cannot update ${path}: it holds ${found}, not a document.`,
|
|
191
|
+
action: "Restore it to a JSON object, or move it aside, and run the command again.",
|
|
192
|
+
detail: `${path} parsed to ${found}`,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The whole sentence, for the read whose answer is about to be written back over the file it came from.
|
|
198
|
+
*
|
|
199
|
+
* **Absent is the only state that licenses starting from empty.** Unopenable, unparseable,
|
|
200
|
+
* parsed-but-not-a-record and parsed-but-not-this-document are each a refusal here, whatever any of them
|
|
201
|
+
* may be for a reader that only reports. This family has five known instances — `readManifestDocument`
|
|
202
|
+
* (#204), `readMintedTokens` and `readDevJson` (#209), and the two #209 could not close without this
|
|
203
|
+
* split (#219) — and every one of them was a *writer* merging into a `{}` that a read had invented from
|
|
204
|
+
* something it could not make sense of. `tokens.json` is keyed by environment and `dev.json` has other
|
|
205
|
+
* tenants, so what the merge replaced was other people's live credentials and preferences, with the run
|
|
206
|
+
* reporting a clean write.
|
|
207
|
+
*
|
|
208
|
+
* **The reason the split is a type and not a rule.** `readMintedTokens` was both the reporting read and
|
|
209
|
+
* the writer's read, and the argument for its leniency — a reader has nothing to destroy, a half-typed
|
|
210
|
+
* preferences file must not stop `pithy dev` — is *sound for the reader*. Tightening it would have cost
|
|
211
|
+
* that, and leaving a lenient sibling beside a strict one is how all five happened: the call site picked
|
|
212
|
+
* the wrong one and nothing said so. So the strict read hands back a {@link MergeBase} rather than a
|
|
213
|
+
* document, every writer takes one, and the lenient `{}` is a compile error at the merge instead of a
|
|
214
|
+
* lost file at the rename. A reader may still answer `{}`; it simply cannot answer a merge base.
|
|
215
|
+
*
|
|
216
|
+
* It lives here rather than at the sixth call site for the same reason {@link requireRecord} does: no
|
|
217
|
+
* reader decides for itself what its own failure means. Each keeps its own sentence through the options.
|
|
218
|
+
*/
|
|
219
|
+
export interface MergeBase<Document> {
|
|
220
|
+
/**
|
|
221
|
+
* The file the document came from, and the file a writer may write back to.
|
|
222
|
+
*
|
|
223
|
+
* Carried rather than re-derived so the base and its destination cannot come apart — a document read
|
|
224
|
+
* from one path and renamed over another is the same loss by a different route.
|
|
225
|
+
*/
|
|
226
|
+
readonly path: string;
|
|
227
|
+
/** What is in it: the empty document when there was no file, and otherwise exactly what passed. */
|
|
228
|
+
readonly document: Document;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** What a call site is told about a file that opened and is not JSON. Never a byte of it — see below. */
|
|
232
|
+
export interface UnparseableFile {
|
|
233
|
+
/** The file that would not parse, exactly as it was asked for. */
|
|
234
|
+
readonly path: string;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** What a call site is told about a document that parsed and is not the one this file holds. */
|
|
238
|
+
export interface InvalidDocument {
|
|
239
|
+
/** The file. */
|
|
240
|
+
readonly path: string;
|
|
241
|
+
/** Where it broke, as dotted key paths — `dev.CF_TOKEN`. The keys, never the values behind them. */
|
|
242
|
+
readonly at: string;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** How {@link readMergeBase} refuses, in four places rather than one — and how it reads. */
|
|
246
|
+
export interface MergeBaseOptions extends ReadOptionalFileOptions, RequireRecordOptions {
|
|
247
|
+
/** The refusal for a file that opened and is not JSON. */
|
|
248
|
+
readonly unparseable?: (failure: UnparseableFile) => PithyError;
|
|
249
|
+
/** The refusal for a document that parsed, is a record, and is not what this file holds. */
|
|
250
|
+
readonly invalid?: (failure: InvalidDocument) => PithyError;
|
|
251
|
+
/**
|
|
252
|
+
* How the file's bytes become a value. **`JSON.parse` unless a caller says otherwise.**
|
|
253
|
+
*
|
|
254
|
+
* The one thing here that is a property of the file rather than a decision about what a failure means.
|
|
255
|
+
* `pithy.worker.jsonc` is JSONC and is read comment-preserving, because the document goes straight back
|
|
256
|
+
* out through `stringify` and an adopter's notes live on it as symbol-keyed properties — a parser that
|
|
257
|
+
* dropped them would take every comment in the file with it at the next write (`../ui/workerUi.ts`).
|
|
258
|
+
*
|
|
259
|
+
* It is the parser and nothing else. Whichever one is supplied, **its own error is dropped** for the
|
|
260
|
+
* reason {@link parseJson} gives: every parser in this family quotes the line it choked on, and the
|
|
261
|
+
* files this module was written for are credentials.
|
|
262
|
+
*/
|
|
263
|
+
readonly parse?: (source: string) => unknown;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* The document at `path` and the file it came from, known-good — or a refusal, in every state but one.
|
|
268
|
+
*
|
|
269
|
+
* `{}` comes back for an absent file and for nothing else. The schema is what "known-good" means, so a
|
|
270
|
+
* `tokens.json` whose value is a number and a `dev.json` whose binding is a number are refusals rather
|
|
271
|
+
* than empty bases, and the caller that merges into the result never has to ask what it was handed.
|
|
272
|
+
*/
|
|
273
|
+
export async function readMergeBase<Schema extends z.ZodType<Record<string, unknown>>>(
|
|
274
|
+
path: string,
|
|
275
|
+
schema: Schema,
|
|
276
|
+
options: MergeBaseOptions = {},
|
|
277
|
+
): Promise<MergeBase<z.output<Schema>>> {
|
|
278
|
+
const source = await readOptionalFile(path, options);
|
|
279
|
+
if (source === null) return { path, document: emptyDocument(path, schema) };
|
|
280
|
+
const document = requireRecord(path, parseJson(path, source, options), options);
|
|
281
|
+
const parsed = schema.safeParse(document);
|
|
282
|
+
if (parsed.success) return { path, document: parsed.data };
|
|
283
|
+
const failure = { path, at: whereItBroke(parsed.error) };
|
|
284
|
+
throw options.invalid?.(failure) ?? defaultInvalidRefusal(failure);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* The caller's parser or `JSON.parse`, refusing rather than answering `undefined` — and **dropping the
|
|
289
|
+
* parser's own error**.
|
|
290
|
+
*
|
|
291
|
+
* The one place in this module a cause is deliberately not carried. `JSON.parse` puts the offending text
|
|
292
|
+
* in its message — `Unexpected token 'n', "{ not json" is not valid JSON` — comment-json's does the same,
|
|
293
|
+
* and every file this was written for is credentials. The path and "it is not JSON" is the whole of what
|
|
294
|
+
* an operator needs; the quoted line is the leak the refusal existed to avoid. That holds for whichever
|
|
295
|
+
* parser {@link MergeBaseOptions.parse} supplies, which is why the drop lives here and not at a call site.
|
|
296
|
+
*/
|
|
297
|
+
function parseJson(path: string, source: string, options: MergeBaseOptions): unknown {
|
|
298
|
+
try {
|
|
299
|
+
return options.parse === undefined ? JSON.parse(source) : options.parse(source);
|
|
300
|
+
} catch {
|
|
301
|
+
const failure = { path };
|
|
302
|
+
throw options.unparseable?.(failure) ?? defaultParseRefusal(failure);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** What an absent file starts from: the schema's own reading of `{}`, so the empty state is its too. */
|
|
307
|
+
function emptyDocument<Schema extends z.ZodType<Record<string, unknown>>>(
|
|
308
|
+
path: string,
|
|
309
|
+
schema: Schema,
|
|
310
|
+
): z.output<Schema> {
|
|
311
|
+
const empty = schema.safeParse({});
|
|
312
|
+
if (empty.success) return empty.data;
|
|
313
|
+
throw new InternalError({
|
|
314
|
+
message: `Cannot start ${path} from an empty document.`,
|
|
315
|
+
action: "This is a defect in Pithy. Report it with the command you ran.",
|
|
316
|
+
detail: `the schema for ${path} refuses {}, so an absent file has no base to merge into`,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Where a document broke, by key path and never by value: `dev.CF_TOKEN, staging`. */
|
|
321
|
+
function whereItBroke(error: z.ZodError): string {
|
|
322
|
+
const paths = new Set(error.issues.map((issue) => issue.path.join(".") || "the top level"));
|
|
323
|
+
return [...paths].slice(0, 5).join(", ");
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** The refusal a caller that named no words of its own gets for a file that is not JSON. */
|
|
327
|
+
function defaultParseRefusal({ path }: UnparseableFile): PithyError {
|
|
328
|
+
return new ConflictError({
|
|
329
|
+
message: `Cannot update ${path}: it is there and is not JSON.`,
|
|
330
|
+
action: "Fix the file, or move it aside, and run the command again.",
|
|
331
|
+
detail: `${path} did not parse, and Pithy will not rewrite a file it could not read back`,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** The refusal a caller that named no words of its own gets for a document that failed its schema. */
|
|
336
|
+
function defaultInvalidRefusal({ path, at }: InvalidDocument): PithyError {
|
|
337
|
+
return new ConflictError({
|
|
338
|
+
message: `Cannot update ${path}: it is not the document Pithy keeps there.`,
|
|
339
|
+
action: "Fix it, or move it aside, and run the command again.",
|
|
340
|
+
detail: `${path} failed its schema at ${at}`,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { mkdir, rm, rmdir, writeFile } from "node:fs/promises";
|
|
5
|
+
import { dirname, relative, resolve, sep } from "node:path";
|
|
6
|
+
import { InternalError } from "@pithy-sh/core/src/error/pithyError";
|
|
7
|
+
import { readFileOutcome } from "./readOptionalFile";
|
|
8
|
+
import { pathExists } from "./scaffold";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Run a set of writes so a failure leaves the tree exactly as it found it.
|
|
12
|
+
*
|
|
13
|
+
* **The invariant is over the outcome, not the order.** A scaffolding command that writes and then does
|
|
14
|
+
* something that can fail leaves the adopter holding half a project — and worse, holding one its own
|
|
15
|
+
* refusal mistakes for a finished one. `pithy ui add` wrote its whole template and then composed the
|
|
16
|
+
* app; a composition that threw left the files written, the wiring absent, and the retry refused by the
|
|
17
|
+
* "this Worker already has a front end" guard, which is correct in itself and cannot tell a finished
|
|
18
|
+
* front end from one abandoned a minute ago (#259). The way out was deleting files by hand, which is
|
|
19
|
+
* precisely what a scaffolder exists to prevent.
|
|
20
|
+
*
|
|
21
|
+
* Ordering alone does not close it. Putting the step that fails first removes the *known* failure, and
|
|
22
|
+
* the next one added goes back on the end of the list. This states the property instead, so a command
|
|
23
|
+
* built out of it cannot regress into the old shape: whatever throws, and wherever, the tree is
|
|
24
|
+
* restored.
|
|
25
|
+
*
|
|
26
|
+
* `pithy worker add` has done this by hand since #158 — it deletes the Worker directory it just created
|
|
27
|
+
* when the wiring throws. This is that, generalized to a set of files rather than one new directory, so
|
|
28
|
+
* a command that edits files an adopter already owns can have the same guarantee.
|
|
29
|
+
*
|
|
30
|
+
* **Text only.** A snapshot is the file's characters, which is what every path this covers holds: a
|
|
31
|
+
* scaffolder's templates are `Record<string, string>` by construction, and the four documents it edits
|
|
32
|
+
* are JSON and JSONC. A binary asset would need bytes, and adding one is a reason to revisit this rather
|
|
33
|
+
* than a case it silently mishandles.
|
|
34
|
+
*
|
|
35
|
+
* **Best effort on the way back, and deliberately silent about it.** A restore that itself fails is
|
|
36
|
+
* swallowed and the original error is rethrown: the failure the caller was told about is the more
|
|
37
|
+
* useful answer, and replacing it with a second one buries the cause. Same trade `addWorker` documents.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/** One path's state before the run: its contents, or absence. */
|
|
41
|
+
interface Snapshot {
|
|
42
|
+
path: string;
|
|
43
|
+
content: string | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Read one path's contents, or record that nothing is there.
|
|
48
|
+
*
|
|
49
|
+
* **Only `ENOENT` is absence**, and here that rule has teeth beyond the usual: a file recorded as absent
|
|
50
|
+
* is one the rollback *deletes*. Reading `EACCES` as "not there" would make this function destroy the
|
|
51
|
+
* very file it exists to protect. So an unreadable path refuses the whole run, before a byte is written
|
|
52
|
+
* — the one moment when refusing costs nothing.
|
|
53
|
+
*/
|
|
54
|
+
async function snapshot(path: string): Promise<Snapshot> {
|
|
55
|
+
const read = await readFileOutcome(path);
|
|
56
|
+
if (read.state === "absent") return { path, content: null };
|
|
57
|
+
if (read.state === "unreadable") {
|
|
58
|
+
throw new InternalError({
|
|
59
|
+
message: `${path} could not be read, so this command cannot undo itself if it fails.`,
|
|
60
|
+
action: "Fix that file's permissions, then run the command again.",
|
|
61
|
+
detail: `${read.code ?? "unknown errno"} while snapshotting for rollback.`,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return { path, content: read.text };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Every directory between `root` (exclusive) and `path` (exclusive) that does not exist yet, outermost
|
|
69
|
+
* first.
|
|
70
|
+
*
|
|
71
|
+
* Recorded because a restore that only removes files leaves the directories they were created in, and an
|
|
72
|
+
* empty `src/routes/pithy/` is a tree that reads clean to a person and fails a comparison. `root` itself
|
|
73
|
+
* is never a candidate: it is the project, and it was there before the run.
|
|
74
|
+
*/
|
|
75
|
+
async function missingAncestors(root: string, path: string): Promise<string[]> {
|
|
76
|
+
const missing: string[] = [];
|
|
77
|
+
const base = resolve(root);
|
|
78
|
+
let dir = dirname(resolve(path));
|
|
79
|
+
// `pathExists` is `lstat`: a symlink standing where a directory should be is *something*, and treating
|
|
80
|
+
// it as absence would have this function remove an adopter's link on the way out.
|
|
81
|
+
while (dir !== base && !relative(base, dir).startsWith("..") && dir !== dirname(dir)) {
|
|
82
|
+
if (await pathExists(dir)) break; // this one is there, and so is every ancestor above it
|
|
83
|
+
missing.push(dir);
|
|
84
|
+
dir = dirname(dir);
|
|
85
|
+
}
|
|
86
|
+
return missing.reverse();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** What a rollback scope covers: the boundary it will not delete past, and the files it may restore. */
|
|
90
|
+
export interface RollbackScope {
|
|
91
|
+
/** The directory every path lives under, and the one this never removes — the project root. */
|
|
92
|
+
root: string;
|
|
93
|
+
/**
|
|
94
|
+
* Every file the run may create or modify, absolute. A path the run touches that is not listed here is
|
|
95
|
+
* not restored, so a command's list is part of its contract: `ui/flow.ts` builds it from the plan it is
|
|
96
|
+
* about to write plus the four documents it edits.
|
|
97
|
+
*/
|
|
98
|
+
paths: readonly string[];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Put one path back the way it was: its contents, or gone. */
|
|
102
|
+
async function restore(entry: Snapshot): Promise<void> {
|
|
103
|
+
try {
|
|
104
|
+
if (entry.content === null) {
|
|
105
|
+
await rm(entry.path, { force: true });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
// `writeFile` truncates in place, so a file that was there keeps the mode it had. Nothing here
|
|
109
|
+
// changes a permission, which is what makes restoring a `0600` file safe.
|
|
110
|
+
await mkdir(dirname(entry.path), { recursive: true });
|
|
111
|
+
await writeFile(entry.path, entry.content);
|
|
112
|
+
} catch {
|
|
113
|
+
// See the module note: the original failure is the one worth reporting.
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Snapshot `scope.paths`, run `write`, and restore every one of them if it throws — including removing
|
|
119
|
+
* the directories the run had to create. The original error is rethrown untouched.
|
|
120
|
+
*
|
|
121
|
+
* On success nothing is restored and nothing is copied anywhere: the snapshot is held in memory for the
|
|
122
|
+
* duration of the run and dropped. These are config files and template sources, so the cost is bytes.
|
|
123
|
+
*/
|
|
124
|
+
export async function withRollback<T>(scope: RollbackScope, write: () => Promise<T>): Promise<T> {
|
|
125
|
+
const before = await Promise.all(scope.paths.map(snapshot));
|
|
126
|
+
const created = new Set<string>();
|
|
127
|
+
for (const entry of before) {
|
|
128
|
+
if (entry.content !== null) continue;
|
|
129
|
+
for (const dir of await missingAncestors(scope.root, entry.path)) created.add(dir);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
return await write();
|
|
134
|
+
} catch (cause) {
|
|
135
|
+
for (const entry of before) await restore(entry);
|
|
136
|
+
// Deepest first, and `rmdir` rather than a recursive remove: a directory that is not empty is one the
|
|
137
|
+
// adopter has something in, and removing it would be this function causing the loss it exists to
|
|
138
|
+
// prevent. `rmdir` refuses a non-empty directory, so the refusal is the filesystem's, not a check
|
|
139
|
+
// that can be wrong.
|
|
140
|
+
for (const dir of [...created].sort((a, b) => b.split(sep).length - a.split(sep).length)) {
|
|
141
|
+
await rmdir(dir).catch(() => {});
|
|
142
|
+
}
|
|
143
|
+
throw cause;
|
|
144
|
+
}
|
|
145
|
+
}
|