@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.
Files changed (234) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +72 -0
  3. package/scripts/templateManifest.ts +49 -0
  4. package/scripts/tsconfig.json +26 -0
  5. package/scripts/vendorTemplate.ts +84 -0
  6. package/scripts/verifyPack.ts +88 -0
  7. package/src/audit/cliAudit.ts +406 -0
  8. package/src/bin.ts +111 -0
  9. package/src/capabilities/add.ts +288 -0
  10. package/src/capabilities/addBootstrap.ts +275 -0
  11. package/src/capabilities/catalog.ts +175 -0
  12. package/src/capabilities/compose.ts +39 -0
  13. package/src/capabilities/configConstants.ts +74 -0
  14. package/src/capabilities/configImports.ts +397 -0
  15. package/src/capabilities/eject.ts +331 -0
  16. package/src/capabilities/emailProvisioner.ts +346 -0
  17. package/src/capabilities/entitlementGap.ts +70 -0
  18. package/src/capabilities/entryExports.ts +162 -0
  19. package/src/capabilities/flow.ts +550 -0
  20. package/src/capabilities/hostRegistry.ts +368 -0
  21. package/src/capabilities/loadFailure.ts +208 -0
  22. package/src/capabilities/manifests.ts +238 -0
  23. package/src/capabilities/mediaProvisioner.ts +471 -0
  24. package/src/capabilities/mintSecrets.ts +306 -0
  25. package/src/capabilities/paymentsProvisioner.ts +207 -0
  26. package/src/capabilities/prerequisites.ts +168 -0
  27. package/src/capabilities/r2Bucket.ts +113 -0
  28. package/src/capabilities/reconcile.ts +1483 -0
  29. package/src/capabilities/remove.ts +597 -0
  30. package/src/capabilities/requiredOptions.ts +92 -0
  31. package/src/capabilities/rotateSecrets.ts +305 -0
  32. package/src/capabilities/secrets.ts +178 -0
  33. package/src/capabilities/secretsDispatcher.ts +29 -0
  34. package/src/capabilities/secretsProvisioner.ts +389 -0
  35. package/src/capabilities/storageProvisioner.ts +414 -0
  36. package/src/capabilities/supportProvisioner.ts +515 -0
  37. package/src/capabilities/testersLoader.ts +52 -0
  38. package/src/capabilities/testersProvisioner.ts +236 -0
  39. package/src/capabilities/turnstileProvisioner.ts +347 -0
  40. package/src/capabilities/vectorProvisioner.ts +260 -0
  41. package/src/ci/fileModes.ts +223 -0
  42. package/src/ci/sourceFiles.ts +200 -0
  43. package/src/ci/workflowDrivers.ts +524 -0
  44. package/src/cloudflare/accountAnswer.ts +110 -0
  45. package/src/cloudflare/config.ts +685 -0
  46. package/src/cloudflare/storeId.ts +129 -0
  47. package/src/commands/add.ts +372 -0
  48. package/src/commands/alias.ts +205 -0
  49. package/src/commands/dashboard.ts +651 -0
  50. package/src/commands/deploy.ts +150 -0
  51. package/src/commands/dev.ts +37 -0
  52. package/src/commands/doctor.ts +2059 -0
  53. package/src/commands/email.ts +425 -0
  54. package/src/commands/env.ts +155 -0
  55. package/src/commands/feature.ts +359 -0
  56. package/src/commands/init.ts +538 -0
  57. package/src/commands/media.ts +303 -0
  58. package/src/commands/migrate.ts +129 -0
  59. package/src/commands/payments.ts +336 -0
  60. package/src/commands/provision.ts +368 -0
  61. package/src/commands/remove.ts +151 -0
  62. package/src/commands/secrets.ts +652 -0
  63. package/src/commands/seed.ts +229 -0
  64. package/src/commands/storage.ts +309 -0
  65. package/src/commands/support.ts +331 -0
  66. package/src/commands/testers.ts +1020 -0
  67. package/src/commands/token.ts +364 -0
  68. package/src/commands/turnstile.ts +271 -0
  69. package/src/commands/ui.ts +222 -0
  70. package/src/commands/upgrade.ts +517 -0
  71. package/src/commands/vector.ts +390 -0
  72. package/src/commands/worker.ts +295 -0
  73. package/src/dashboard/api.ts +323 -0
  74. package/src/dashboard/connect.ts +758 -0
  75. package/src/dashboard/contract.ts +289 -0
  76. package/src/dashboard/grant.ts +124 -0
  77. package/src/dashboard/registry.ts +519 -0
  78. package/src/dashboard/resolveTarget.ts +119 -0
  79. package/src/dev/delivery.ts +174 -0
  80. package/src/dev/devLogin.ts +155 -0
  81. package/src/dev/devLoginTargets.ts +91 -0
  82. package/src/dev/env.ts +206 -0
  83. package/src/dev/hostWorkers.ts +290 -0
  84. package/src/dev/keys.ts +111 -0
  85. package/src/dev/logging.ts +87 -0
  86. package/src/dev/openUrl.ts +75 -0
  87. package/src/dev/orchestrator.ts +1014 -0
  88. package/src/dev/ports.ts +220 -0
  89. package/src/dev/readyWatch.ts +142 -0
  90. package/src/dev/state.ts +90 -0
  91. package/src/devSecrets/bootstrapVars.ts +265 -0
  92. package/src/devSecrets/devVars.ts +240 -0
  93. package/src/devSecrets/edit.ts +256 -0
  94. package/src/devSecrets/file.ts +277 -0
  95. package/src/devSecrets/generate.ts +428 -0
  96. package/src/devSecrets/location.ts +80 -0
  97. package/src/devSecrets/mode.ts +71 -0
  98. package/src/devSecrets/records.ts +30 -0
  99. package/src/devSecrets/report.ts +99 -0
  100. package/src/devSecrets/seed.ts +344 -0
  101. package/src/devSecrets/store.ts +262 -0
  102. package/src/devSecrets/targets.ts +204 -0
  103. package/src/dispatch.ts +147 -0
  104. package/src/docs/catalog.ts +246 -0
  105. package/src/docs/writeCatalog.ts +45 -0
  106. package/src/doctor/cloudflare.ts +287 -0
  107. package/src/doctor/devPreferences.ts +155 -0
  108. package/src/doctor/devSecrets.ts +464 -0
  109. package/src/doctor/devVars.ts +414 -0
  110. package/src/doctor/devVarsLocal.ts +138 -0
  111. package/src/doctor/environments.ts +155 -0
  112. package/src/doctor/health.ts +354 -0
  113. package/src/doctor/localDelivery.ts +91 -0
  114. package/src/doctor/portsRegistry.ts +252 -0
  115. package/src/doctor/projectName.ts +584 -0
  116. package/src/doctor/secretBindings.ts +166 -0
  117. package/src/doctor/settings.ts +274 -0
  118. package/src/doctor/settingsSources.ts +202 -0
  119. package/src/doctor/workerName.ts +174 -0
  120. package/src/doctor/wranglerVars.ts +33 -0
  121. package/src/feature/bindings.ts +93 -0
  122. package/src/feature/create.ts +179 -0
  123. package/src/feature/destroy.ts +160 -0
  124. package/src/feature/devConfig.ts +201 -0
  125. package/src/feature/identity.ts +100 -0
  126. package/src/feature/manifest.ts +132 -0
  127. package/src/feature/ports.ts +615 -0
  128. package/src/feature/provision.ts +362 -0
  129. package/src/feature/sync.ts +148 -0
  130. package/src/feature/worktree.ts +282 -0
  131. package/src/help/groups.ts +47 -0
  132. package/src/help/rootUsage.ts +135 -0
  133. package/src/main.ts +73 -0
  134. package/src/migrations/ledger.ts +129 -0
  135. package/src/migrations/registry.ts +47 -0
  136. package/src/migrations/run.ts +1066 -0
  137. package/src/notifier/check.ts +129 -0
  138. package/src/notifier/installer.ts +48 -0
  139. package/src/notifier/notify.ts +152 -0
  140. package/src/notifier/state.ts +248 -0
  141. package/src/notifier/version.ts +59 -0
  142. package/src/platform/editor.ts +333 -0
  143. package/src/platform/rc.ts +118 -0
  144. package/src/platform/shell.ts +83 -0
  145. package/src/project/appBindings.ts +184 -0
  146. package/src/project/appWorkflows.ts +266 -0
  147. package/src/project/applyDomains.ts +166 -0
  148. package/src/project/askDomains.ts +220 -0
  149. package/src/project/atomic.ts +466 -0
  150. package/src/project/bindingEntries.ts +425 -0
  151. package/src/project/config.ts +701 -0
  152. package/src/project/dashboard.ts +118 -0
  153. package/src/project/deploy.ts +364 -0
  154. package/src/project/devVars.ts +113 -0
  155. package/src/project/domainPrompt.ts +191 -0
  156. package/src/project/domains.ts +386 -0
  157. package/src/project/envInventory.ts +356 -0
  158. package/src/project/environment.ts +125 -0
  159. package/src/project/extensions.ts +69 -0
  160. package/src/project/jsonc.ts +289 -0
  161. package/src/project/packageManager.ts +238 -0
  162. package/src/project/readOptionalFile.ts +342 -0
  163. package/src/project/rollback.ts +145 -0
  164. package/src/project/scaffold.ts +1088 -0
  165. package/src/project/templateFiles.ts +53 -0
  166. package/src/project/verifyDeploy.ts +230 -0
  167. package/src/project/versionMetadata.ts +77 -0
  168. package/src/project/workerAddress.ts +176 -0
  169. package/src/project/workerCommand.ts +564 -0
  170. package/src/project/workerIdentity.ts +50 -0
  171. package/src/project/workerManifest.ts +135 -0
  172. package/src/project/workerScaffold.ts +289 -0
  173. package/src/project/workerScope.ts +394 -0
  174. package/src/project/workers.ts +86 -0
  175. package/src/project/workflows.ts +281 -0
  176. package/src/project/wrangler.ts +168 -0
  177. package/src/provision/confirm.ts +86 -0
  178. package/src/provision/environment.ts +407 -0
  179. package/src/provision/featureConfig.ts +98 -0
  180. package/src/provision/mode.ts +62 -0
  181. package/src/provision/pendingSecrets.ts +96 -0
  182. package/src/provision/resources.ts +126 -0
  183. package/src/provision/secretBindings.ts +149 -0
  184. package/src/provision/store.ts +33 -0
  185. package/src/provision/unprovisioned.ts +114 -0
  186. package/src/provision/wranglerEnv.ts +220 -0
  187. package/src/rootFlags.ts +48 -0
  188. package/src/seed/drivers.ts +423 -0
  189. package/src/seed/media.ts +187 -0
  190. package/src/seed/plan.ts +137 -0
  191. package/src/seed/prepare.ts +224 -0
  192. package/src/seed/registry.ts +25 -0
  193. package/src/seed/run.ts +793 -0
  194. package/src/seed/safety.ts +206 -0
  195. package/src/terminal/logger.ts +42 -0
  196. package/src/terminal/output.ts +64 -0
  197. package/src/terminal/style.ts +132 -0
  198. package/src/test-utils/doctorHarness.ts +190 -0
  199. package/src/test-utils/migrateHarness.ts +126 -0
  200. package/src/test-utils/seedHarness.ts +173 -0
  201. package/src/test-utils/tempRepo.ts +45 -0
  202. package/src/tokens/config.ts +16 -0
  203. package/src/tokens/engine.ts +345 -0
  204. package/src/tokens/mintedTokens.ts +233 -0
  205. package/src/tokens/sinks.ts +84 -0
  206. package/src/ui/flow.ts +451 -0
  207. package/src/ui/react.ts +112 -0
  208. package/src/ui/routeAllowlist.ts +208 -0
  209. package/src/ui/scaffold.ts +113 -0
  210. package/src/ui/screenStyles.ts +127 -0
  211. package/src/ui/stubs.ts +135 -0
  212. package/src/ui/templates.ts +52 -0
  213. package/src/ui/wire.ts +311 -0
  214. package/src/ui/workerUi.ts +172 -0
  215. package/templates/starter/.dev.secrets.example.jsonc +43 -0
  216. package/templates/starter/.dev.vars.example +30 -0
  217. package/templates/starter/apps/api/package.json +22 -0
  218. package/templates/starter/apps/api/pithy.config.ts +65 -0
  219. package/templates/starter/apps/api/pithy.worker.jsonc +11 -0
  220. package/templates/starter/apps/api/src/bindings.workers.test.ts +18 -0
  221. package/templates/starter/apps/api/src/cloudflare-test.d.ts +11 -0
  222. package/templates/starter/apps/api/src/index.ts +8 -0
  223. package/templates/starter/apps/api/tsconfig.json +26 -0
  224. package/templates/starter/apps/api/wrangler.jsonc +68 -0
  225. package/templates/starter/biome.template.jsonc +75 -0
  226. package/templates/starter/gitignore +37 -0
  227. package/templates/starter/package.json +28 -0
  228. package/templates/starter/pithy.config.ts +67 -0
  229. package/templates/starter/plugins/no-console.grit +25 -0
  230. package/templates/starter/plugins/no-process-io.grit +25 -0
  231. package/templates/starter/tsconfig.json +14 -0
  232. package/templates/starter/tsconfig.tools.json +30 -0
  233. package/templates/starter/vitest.config.ts +124 -0
  234. package/templates/starter/vitest.workers.config.ts +26 -0
@@ -0,0 +1,256 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { randomBytes } from "node:crypto";
5
+ import { readFile, unlink } from "node:fs/promises";
6
+ import { dirname, join, parse } from "node:path";
7
+ import type { ErrorPayload } from "@pithy-sh/core/src/error/payload";
8
+ import { ConflictError, PithyError, ValidationError } from "@pithy-sh/core/src/error/pithyError";
9
+ import type { DevSecretsFile } from "@pithy-sh/secrets/src/dev/devSecretsFile";
10
+ import { loadDevSecrets } from "@pithy-sh/secrets/src/dev/loadDevSecrets";
11
+ import type { SecretRegistry } from "@pithy-sh/secrets/src/registry";
12
+ import { requireEditor, runEditor } from "../platform/editor";
13
+ import { writeFileAtomic } from "../project/atomic";
14
+ import { formatError } from "../terminal/output";
15
+ import { initialDevSecretsContent, readDevSecretsSource } from "./file";
16
+ import { ensureDevSecretsDir } from "./location";
17
+ import { tightenDirMode, tightenMode } from "./mode";
18
+
19
+ /**
20
+ * `pithy secrets edit` — open the dev secrets file wherever it lives, and take back what comes out (#157).
21
+ *
22
+ * Since #156 the file is at `<config>/<project>/secrets.jsonc`, outside every checkout. That is the whole
23
+ * point of it: nothing to gitignore, nothing a `git add -A` reaches, nothing an `npm pack` carries. It
24
+ * also removes the obvious way to edit it, and "resolve the path yourself and open it" is not a workflow.
25
+ * A symlink back into the project is the tempting answer and is the one thing that must not happen —
26
+ * every tool that follows a link would see the file again, which is how #145 put a maintainer's
27
+ * `.dev.vars` in a published tarball.
28
+ *
29
+ * **The edit is never the thing that is lost.** That is the invariant this module exists for, and every
30
+ * decision below follows from it:
31
+ *
32
+ * - The editor opens on a **draft** beside the real file, never on the real file. So the real file is
33
+ * only ever replaced by content that has already parsed and validated — no editor's rename lands a
34
+ * half-written document on it, and no `:wq` of something malformed breaks every later command.
35
+ * - A draft that does not validate is **re-opened on the adopter's own text**, with the problem printed
36
+ * above it. Re-opening the *file* would quietly discard everything they typed, and what they were
37
+ * typing may be the only copy of a credential that exists.
38
+ * - A draft that still does not validate, that the editor abandoned, or that lost a race with another
39
+ * command **is kept**, and the refusal names its absolute path. Nothing here ever deletes text it
40
+ * could not write.
41
+ * - A draft that is byte-identical to what it started from is deleted, because there is nothing in it
42
+ * to keep and it holds every value the real file does.
43
+ *
44
+ * **The draft is a plaintext copy of every secret in the project, so it lives where the real file
45
+ * lives** — inside `<config>/<project>/`, which is held at `0700`, created `0600` through
46
+ * {@link writeFileAtomic}, and named with 64 bits of randomness so nothing can be waiting at the path.
47
+ * Not the OS temp directory: that is world-listable on a shared machine, and it is not covered by any of
48
+ * the mode discipline this directory has (#150, #151).
49
+ *
50
+ * **Nothing it prints or throws carries a value.** The file holds OAuth client secrets and Stripe keys;
51
+ * `comment-json` puts the whole source into its own `SyntaxError.message`, so every parse goes through
52
+ * `loadDevSecrets`, whose errors carry a line, a column, and a secret *name* at worst. That is asserted
53
+ * in `edit.test.ts` for both the syntax and the schema failure, because "the error carries no value" is
54
+ * the kind of property that holds until one convenient interpolation.
55
+ */
56
+
57
+ /** Opens `file` in the adopter's editor, waits, and resolves with its exit status. */
58
+ export type OpenEditor = (file: string) => Promise<number>;
59
+
60
+ /** What {@link editDevSecrets} needs. */
61
+ export interface EditDevSecretsOptions {
62
+ /** The resolved absolute path of the dev secrets file — `location.ts` owns that resolution. */
63
+ path: string;
64
+ /**
65
+ * Resolve the editor, then hand back the opener. **Called once, before any draft exists**, so a run
66
+ * with no terminal or no usable editor refuses without leaving a copy of every secret on disk — and so
67
+ * that a `$EDITOR` changed mid-session cannot swap editors between two rounds of the same edit.
68
+ *
69
+ * Defaults to the real one: `$VISUAL`, `$EDITOR`, platform default, refusing an editor that does not
70
+ * wait and a run with no TTY. See `platform/editor.ts`.
71
+ */
72
+ editor?: () => OpenEditor;
73
+ /**
74
+ * Where the "that did not parse" notice goes. Defaults to stderr, which is where it belongs: stdout
75
+ * is the `--json` contract, and this is a message for the person at the terminal.
76
+ */
77
+ report?: (text: string) => void;
78
+ /**
79
+ * The project's merged secret registry, when the command could resolve one. It is what turns a bare
80
+ * value typed where an envelope belongs into a refusal naming the secret (#323) — a registry is the
81
+ * only thing that knows which payload a name takes.
82
+ *
83
+ * **Optional, and it must stay optional.** A project whose `pithy.config.ts` will not load is exactly
84
+ * when an adopter reaches for this command, and refusing to open their editor because the registry is
85
+ * unreadable would make the tool for fixing things unavailable to the state that needs fixing.
86
+ */
87
+ registry?: SecretRegistry;
88
+ }
89
+
90
+ /** What an edit did. Counts and flags — never a secret's name, and never a value. */
91
+ export interface EditDevSecretsResult {
92
+ /** Whether the file was written. False for an edit that changed nothing, which is not a failure. */
93
+ changed: boolean;
94
+ /** How many secrets the file holds now. A count: the names are the adopter's, and `secrets ls` lists them. */
95
+ secrets: number;
96
+ /** How many times the edit had to be handed back. Zero is the ordinary case. */
97
+ reopened: number;
98
+ }
99
+
100
+ /** Open the dev secrets file in the adopter's editor, validate what comes back, and write it. */
101
+ export async function editDevSecrets(options: EditDevSecretsOptions): Promise<EditDevSecretsResult> {
102
+ const { path } = options;
103
+ const report = options.report ?? ((text: string) => void process.stderr.write(text));
104
+ // Before the draft. A refusal here — no terminal, an editor that returns immediately, none installed —
105
+ // must not have left a plaintext copy of the project's secrets sitting in the config directory.
106
+ const open = (options.editor ?? (() => defaultEditor(path)))();
107
+ const registry = options.registry;
108
+
109
+ const original = await readDevSecretsSource(path);
110
+ const base = original === null || original.trim().length === 0 ? initialDevSecretsContent() : original;
111
+ const draft = draftPath(path);
112
+ await ensureDevSecretsDir(path);
113
+ await writeFileAtomic(draft, base, { mode: 0o600 });
114
+
115
+ // `previous` is what the draft held when the editor was last opened on it. An adopter who saves
116
+ // nothing after being shown the problem has given up, and that is the one stop condition here: a
117
+ // fixed round count would either cut someone off mid-fix or spin forever on an editor that no-ops.
118
+ //
119
+ // Nothing below deletes the draft on a failure. Every `throw` here leaves it where the message says
120
+ // it is, because it is the only place the adopter's text exists.
121
+ let previous = base;
122
+ for (let reopened = 0; ; reopened += 1) {
123
+ const status = await open(draft);
124
+ const edited = await readDraft(draft);
125
+
126
+ // The editor deleted it, or never wrote it: there is nothing to keep and nothing to write.
127
+ if (edited === null || edited === base) return await abandon(draft, path, original, registry);
128
+ if (status !== 0) throw abandonedByEditor(status, path, draft);
129
+
130
+ const checked = validate(edited, draft, registry);
131
+ if (checked.file !== undefined) {
132
+ await commit(path, edited, original);
133
+ await unlink(draft).catch(() => {});
134
+ return { changed: true, secrets: Object.keys(checked.file).length, reopened };
135
+ }
136
+ if (edited === previous) throw stillBroken(checked.problem.payload, path);
137
+
138
+ report(`${formatError(checked.problem.payload)}\nNothing has been written. Re-opening your text.\n`);
139
+ previous = edited;
140
+ }
141
+ }
142
+
143
+ /** The real editor: resolved once against the real path, then run on whatever file it is given. */
144
+ function defaultEditor(path: string): OpenEditor {
145
+ const editor = requireEditor(path);
146
+ return (file) => runEditor(editor, file);
147
+ }
148
+
149
+ /**
150
+ * `<dir>/secrets.edit-<hex>.jsonc` — beside the file, in the `0700` directory, with an unguessable name.
151
+ *
152
+ * The extension is kept so an editor still highlights JSONC, and the `.edit-` infix is deliberately not
153
+ * the `.<hex>.tmp` shape `writeFileAtomic` sweeps: that sweep deletes anything of ours older than a
154
+ * minute, and an edit that takes longer than a minute is an edit, not a corpse.
155
+ */
156
+ function draftPath(path: string): string {
157
+ const { dir, name, ext } = parse(path);
158
+ return join(dir, `${name}.edit-${randomBytes(8).toString("hex")}${ext}`);
159
+ }
160
+
161
+ /** The draft's bytes, or null when the editor removed it. */
162
+ async function readDraft(draft: string): Promise<string | null> {
163
+ return readFile(draft, "utf8").catch(() => null);
164
+ }
165
+
166
+ /**
167
+ * Validate an edit the way every other reader of this file does — the same loader `pithy dev`, `pithy
168
+ * add` and `pithy seed` go through, so an edit this accepts is an edit they accept.
169
+ *
170
+ * It answers with the parsed file rather than a boolean, so the count reported afterwards comes from the
171
+ * parse that approved the text and not from a second one that could disagree with it. The fault comes
172
+ * back rather than being thrown: a failure here is a round of the loop, not the end of the command.
173
+ */
174
+ function validate(
175
+ source: string,
176
+ path: string,
177
+ registry?: SecretRegistry,
178
+ ): { file: DevSecretsFile; problem?: never } | { file?: never; problem: PithyError } {
179
+ try {
180
+ return { file: loadDevSecrets(source, { path, ...(registry !== undefined ? { registry } : {}) }) };
181
+ } catch (error) {
182
+ if (error instanceof PithyError) return { problem: error };
183
+ throw error;
184
+ }
185
+ }
186
+
187
+ /** An edit that changed nothing: delete the draft — it holds every value the real file does. */
188
+ async function abandon(
189
+ draft: string,
190
+ path: string,
191
+ original: string | null,
192
+ registry?: SecretRegistry,
193
+ ): Promise<EditDevSecretsResult> {
194
+ await unlink(draft).catch(() => {});
195
+ // The count is of what is *there*, which for an abandoned edit is whatever the file already held. A
196
+ // file that does not parse counts as none rather than failing: the adopter just declined to fix it,
197
+ // and answering with a second error about the same fault would be the command arguing with them.
198
+ const held = original === null ? {} : (validate(original, path, registry).file ?? {});
199
+ return { changed: false, secrets: Object.keys(held).length, reopened: 0 };
200
+ }
201
+
202
+ /**
203
+ * Write the validated text, refusing if the file moved underneath the edit.
204
+ *
205
+ * The race is ordinary rather than exotic: `pithy dev` and `pithy add` both mint into this file, and an
206
+ * edit is open for as long as somebody is typing. A blind write would drop a freshly minted session key
207
+ * that nothing else has a copy of. The check is a re-read rather than an mtime, because an mtime has a
208
+ * resolution and a value does not.
209
+ */
210
+ async function commit(path: string, edited: string, original: string | null): Promise<void> {
211
+ const current = await readDevSecretsSource(path);
212
+ if ((current ?? null) !== (original ?? null)) {
213
+ throw new ConflictError({
214
+ message: `${path} changed while you were editing, so nothing was written.`,
215
+ action: "Another command wrote it — pithy add or pithy dev. Merge the two by hand.",
216
+ detail: `dev secrets file '${path}' differs from the content the edit started from`,
217
+ });
218
+ }
219
+ try {
220
+ await writeFileAtomic(path, edited, { mode: 0o600 });
221
+ } finally {
222
+ // The same closing discipline every other write of this file has: narrowing only, and unconditional,
223
+ // because the case it exists for is a file somebody else created at the umask.
224
+ await tightenMode(path);
225
+ await tightenDirMode(dirname(path));
226
+ }
227
+ }
228
+
229
+ /** The editor exited non-zero on text that had changed. Kept, named, and not written. */
230
+ function abandonedByEditor(status: number, path: string, draft: string): PithyError {
231
+ return new ValidationError({
232
+ message: `Your editor exited with status ${status}, so ${path} is unchanged. Your text is in ${draft}.`,
233
+ action: "Nothing was discarded. Move that file into place yourself if you meant to keep it.",
234
+ detail: `editor exited ${status} with a changed draft at '${draft}'`,
235
+ });
236
+ }
237
+
238
+ /**
239
+ * The edit was handed back and came again unchanged and still invalid. Kept, named, and not written.
240
+ *
241
+ * The draft is not a parameter here because it is already in `problem.message`: the loader was given the
242
+ * draft's path, since the draft is the file that was actually open. Naming the real file for a fault in
243
+ * the draft would send the adopter to a file that is perfectly fine.
244
+ */
245
+ function stillBroken(problem: ErrorPayload, path: string): PithyError {
246
+ return new ValidationError({
247
+ // `problem.message` names the draft, because the draft is the file that was open. It is quoted
248
+ // whole: the fault an adopter can act on is the loader's own sentence, not a paraphrase of it.
249
+ message: `${problem.message} ${path} is unchanged, and your text is kept.`,
250
+ // Not the loader's own action. It says to fix the syntax, which is true and is what they just
251
+ // declined to do; what they need to know is that the text is not gone and that a re-run opens the
252
+ // real file rather than the draft, so the draft is theirs to move.
253
+ action: "Nothing was discarded. Fix that file and move it into place, or start again.",
254
+ detail: problem.detail,
255
+ });
256
+ }
@@ -0,0 +1,277 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { dirname } from "node:path";
5
+ import { InternalError } from "@pithy-sh/core/src/error/pithyError";
6
+ import type { DevSecretsFile } from "@pithy-sh/secrets/src/dev/devSecretsFile";
7
+ import { loadDevSecrets } from "@pithy-sh/secrets/src/dev/loadDevSecrets";
8
+ import { parse, stringify } from "comment-json";
9
+ import { writeFileAtomic } from "../project/atomic";
10
+ import { readOptionalFile } from "../project/readOptionalFile";
11
+ import { DEV_SECRETS_FILE_NAME, ensureDevSecretsDir } from "./location";
12
+ import { tightenDirMode, tightenMode } from "./mode";
13
+ import { ownProperties } from "./records";
14
+
15
+ /**
16
+ * The dev secrets file as bytes on disk — the seam `@pithy-sh/secrets` deliberately left open. That
17
+ * package is Workers-runtime code with no `node:` imports: it parses text and returns what should be
18
+ * written. Reading, writing, the mode, and "absent means no secrets" are the CLI's.
19
+ *
20
+ * **Every function here takes the resolved absolute path, never a project root** (#156). The file
21
+ * lives at `<config>/<project>/secrets.jsonc`, outside the checkout — see `location.ts`, which owns
22
+ * the one resolution — so "the project's directory" is no longer the answer to where it is, and a
23
+ * second module deriving it would be the second answer to a question that must have one.
24
+ *
25
+ * **The mode is not a detail.** The file holds OAuth client secrets, Stripe keys, and whatever else a
26
+ * capability needs that cannot be minted. It is written `0600` on creation *and on every rewrite* —
27
+ * see {@link writeFileAtomic}, where the rename is what used to widen it back to the umask default —
28
+ * and narrowed on every write path whether or not a write was needed, which is the case the `mode`
29
+ * option cannot reach: a file somebody else created at the umask that a re-run has nothing to add to.
30
+ * The directory is held to `0700` on the same terms: its listing is the inventory of every secret name
31
+ * this project has, and `mkdir`'s mode only applies to a directory it creates. See {@link tightenMode}
32
+ * and {@link tightenDirMode}.
33
+ *
34
+ * **Nothing about `.gitignore` remains, because nothing is in the repository to ignore.** This module
35
+ * used to verify an ignore rule before writing a byte — the guarantee that stood between a minted
36
+ * session key and a commit. Moving the file out of the checkout removes the hazard rather than
37
+ * guarding it: there is no path in the project, no `.tmp` sibling in the project, and nothing for a
38
+ * `git add -A` to reach. `.dev.vars` still lives in the project and its ignore lines stay in the
39
+ * template.
40
+ *
41
+ * **Writes merge, they never replace.** The file is hand-edited: comments say where a value came from,
42
+ * and a trailing comma is the residue of deleting a line. So a write re-parses the adopter's own text
43
+ * with `comment-json` and edits *that* tree, rather than stringifying what the loader returned — the
44
+ * loader strips comments, and a write built from it would quietly delete every note in the file.
45
+ *
46
+ * **And a present secret always wins.** Minting over a value that is already there is how a re-run
47
+ * invalidates every live session and breaks every magic link already in an inbox. The merge adds keys
48
+ * and changes none. The one exception is explicit: a *provisioned* value, which somebody else issued.
49
+ *
50
+ * **Nothing thrown from here carries a byte of the file.** `comment-json` puts the whole source in its
51
+ * `SyntaxError.message`, so every parse goes through {@link parseTree} — see there for what that cost.
52
+ * What it does carry is the **absolute path**, in every error: the file is outside the checkout now, so
53
+ * "your secrets file is malformed" names nothing the reader can open.
54
+ */
55
+
56
+ /**
57
+ * The header a file this command creates leads with. Four lines, because four things are not obvious
58
+ * from the contents: where the file is (nothing in the project points at it), that each value is the
59
+ * payload its destination receives, which secret that is not an envelope for, and that the keys are
60
+ * registry names rather than env bindings.
61
+ */
62
+ const HEADER = `// Local dev secret values. Machine-local, outside every checkout — nothing in the project points here.
63
+ // Keys are registry secret names — <capability>-<what>. The registry decides where each one is seeded.
64
+ // Each value is the payload its destination receives — { "currentVersion": "1", "versions": { "1": <value> } }
65
+ // for every secret but SECRETS_ENCRYPTION_KEYS, whose binding is read before any envelope decoder exists.
66
+ `;
67
+
68
+ /**
69
+ * The bytes a file that does not exist yet starts from: the header, and an empty object.
70
+ *
71
+ * Exported for `edit.ts` — `pithy secrets edit` on a project with no file has to open the editor on
72
+ * *something*, and an empty buffer is a document the adopter has to know the shape of before they can
73
+ * type into it. It is the same header a mint would have created, so the first hand-written secret and
74
+ * the first minted one land in a file that reads identically.
75
+ */
76
+ export function initialDevSecretsContent(): string {
77
+ return `${HEADER}{}\n`;
78
+ }
79
+
80
+ /**
81
+ * The project's dev secrets, validated. An absent file is `{}` — a project has none until a capability
82
+ * needs one, and that is not a fault. A file that is *there* and malformed is, and comes back as the
83
+ * loader's `ValidationError` naming this project's real absolute path.
84
+ */
85
+ export async function readDevSecrets(path: string): Promise<DevSecretsFile> {
86
+ const source = await readDevSecretsSource(path);
87
+ // Prototype-free, both branches. Every caller reads it as `file[name]` for a name the adopter typed,
88
+ // and `{}` is the one an empty project hands to every lookup. See {@link ownProperties}.
89
+ if (source === null) return ownProperties<never>({});
90
+ return ownProperties(loadDevSecrets(source, { path }));
91
+ }
92
+
93
+ /**
94
+ * The file's bytes, or `null` when there is no file — and **only** when there is no file.
95
+ *
96
+ * Exported for `edit.ts`, which needs the adopter's own text rather than the parsed value: an editor
97
+ * opens on bytes, and every comment in the file is a byte the parse throws away. It reads through here
98
+ * rather than calling `readFile` itself so that "there is no file yet" and "the file would not open"
99
+ * stay one decision — a second reader answering `{}` for an EACCES is exactly the defect below.
100
+ *
101
+ * The errno decision is {@link readOptionalFile}'s: `ENOENT` is the one that means "no secrets yet".
102
+ * Every other one is a file that is there and did not open — `EACCES` after someone tightened the mode,
103
+ * `EISDIR`, `EIO` on failing disk. Answering `{}` for those was the same as answering "empty", so a
104
+ * write merged its one new value into an empty base and the adopter's OAuth client secrets went with
105
+ * the next rename.
106
+ *
107
+ * The words below are this file's, because the sentence an adopter reads about a secrets file is not
108
+ * the sentence about a `.dev.vars`. The wrapped error carries the node error as `cause`. Its message is
109
+ * `EACCES: permission denied, open '<path>'` — a path and an errno, never a byte of the file.
110
+ */
111
+ export async function readDevSecretsSource(path: string): Promise<string | null> {
112
+ return readOptionalFile(path, {
113
+ unreadable: ({ code, cause }) =>
114
+ new InternalError(
115
+ {
116
+ message: `${path} is there and could not be read.`,
117
+ action: `Check that path and its permissions. It should be a file, mode 600.`,
118
+ detail: `dev secrets file '${path}' failed to read: ${code ?? "unknown error"}`,
119
+ },
120
+ { cause },
121
+ ),
122
+ });
123
+ }
124
+
125
+ /**
126
+ * Merge `added` into the file body, keeping every comment, and keeping every value already there.
127
+ * Pure — the caller owns the read and the write.
128
+ *
129
+ * Returns the source unchanged when there is nothing to add, so a re-run rewrites no bytes: the file's
130
+ * mtime is what an adopter's editor watches, and churning it on every `pithy dev` would make the
131
+ * idempotence the seeder works for invisible.
132
+ */
133
+ export function mergeDevSecretsContent(content: string, added: DevSecretsFile): string {
134
+ return mergeDevSecrets(content, added).content;
135
+ }
136
+
137
+ /**
138
+ * `comment-json`'s parse, with the one thing that makes it safe to run on this file.
139
+ *
140
+ * **Its `SyntaxError.message` embeds the source.** `Unexpected token 'o', "{ … the entire file … }" is
141
+ * not valid JSONC` — so a single missing brace, on the *write* path, printed every OAuth client secret
142
+ * in the file to the terminal and into whatever logged the error. `loadDevSecrets` was taught this
143
+ * already and has the sanitized error, with the line and column and nothing else; the write path
144
+ * re-parsed with a bare `parse` and no catch, and this module's own docstring promised the opposite.
145
+ *
146
+ * Re-raising through the loader rather than composing a second error keeps one sentence for one fault:
147
+ * the message an adopter sees for a malformed file is the same whichever command hit it first.
148
+ */
149
+ function parseTree(source: string, path: string): Record<string, unknown> | null {
150
+ try {
151
+ return parse(source) as Record<string, unknown> | null;
152
+ } catch {
153
+ loadDevSecrets(source, { path });
154
+ // Unreachable in practice: the same source that just failed `parse` fails the loader's parse too.
155
+ throw new InternalError({
156
+ message: `${path} could not be parsed.`,
157
+ action: `Fix the syntax in that file and run the command again.`,
158
+ detail: `dev secrets file '${path}' failed to parse on the write path`,
159
+ });
160
+ }
161
+ }
162
+
163
+ /** The merge, plus the names it actually landed — what {@link writeDevSecrets} reports to its caller. */
164
+ function mergeDevSecrets(
165
+ content: string,
166
+ added: DevSecretsFile,
167
+ replace = false,
168
+ path: string = DEV_SECRETS_FILE_NAME,
169
+ ): { content: string; added: string[] } {
170
+ const source = content.trim().length === 0 ? initialDevSecretsContent() : content;
171
+ const tree = parseTree(source, path);
172
+ // A file whose top level is not an object is the loader's error to raise, with its own actionable
173
+ // message. Anything written here would land inside something that is not a secrets file.
174
+ if (tree === null || typeof tree !== "object" || Array.isArray(tree)) return { content, added: [] };
175
+
176
+ // `Object.hasOwn`, not `in`: `in` walks the prototype chain, so a secret named for an
177
+ // `Object.prototype` key read as already present and was dropped — a mint the caller was told landed.
178
+ const names = Object.keys(added).filter((name) =>
179
+ replace ? !same(tree[name], added[name]) : !Object.hasOwn(tree, name),
180
+ );
181
+ if (names.length === 0) return { content, added: [] };
182
+ for (const name of names) tree[name] = added[name];
183
+ return { content: `${stringify(tree, null, 2)}\n`, added: names };
184
+ }
185
+
186
+ /** Whether two envelopes are the same value, so a re-provision of an unchanged secret rewrites no bytes. */
187
+ function same(current: unknown, next: unknown): boolean {
188
+ return current !== undefined && JSON.stringify(current) === JSON.stringify(next);
189
+ }
190
+
191
+ /**
192
+ * Add every secret in `added` that the file does not already carry, and return the names that landed.
193
+ *
194
+ * The return value is the whole point of the call for a caller that reports: `pithy add` says "minted"
195
+ * only for what it actually minted, and says "left as it is" for the rest. Assuming the write happened
196
+ * is how a command claims to have minted a value it did not.
197
+ *
198
+ * Nothing to add writes nothing at all, and creates no directory: an empty `secrets.jsonc` conjured by
199
+ * a no-op `pithy add` would be one more thing to explain.
200
+ *
201
+ * **The mint is unconditional now, and that is the point of the move.** This used to verify the
202
+ * project's `.gitignore` covered the file before writing a byte, and refuse the whole set when it could
203
+ * not. There is nothing left to refuse: the file is not in the checkout, so no `.gitignore` governs it
204
+ * and no commit can reach it.
205
+ */
206
+ export async function writeDevSecrets(
207
+ path: string,
208
+ added: DevSecretsFile,
209
+ options: WriteDevSecretsOptions = {},
210
+ ): Promise<string[]> {
211
+ try {
212
+ if (Object.keys(added).length === 0) return [];
213
+ // The merge base. A read that fails for anything but ENOENT throws rather than answering "empty":
214
+ // merging into an empty base is how a write replaces a file of secrets with the one it is adding.
215
+ const content = (await readDevSecretsSource(path)) ?? "";
216
+ const merged = mergeDevSecrets(content, added, options.replace === true, path);
217
+ if (merged.added.length === 0) return [];
218
+
219
+ await ensureDevSecretsDir(path);
220
+ await writeFileAtomic(path, merged.content, { mode: 0o600 });
221
+ return merged.added;
222
+ } finally {
223
+ // Unconditionally, whatever happened above, and including the no-op return. The `mode` on the write
224
+ // is right and it left the mode to a write that mostly never happens: every caller filters what is
225
+ // already there first, so a re-run of `pithy add` reaches this function with nothing to add at all —
226
+ // and a file created at the umask by an older pithy, an editor, or a `cp` kept 0644 forever while
227
+ // holding the OAuth client secrets `.dev.vars` only carries a copy of.
228
+ //
229
+ // No file is still no file — {@link tightenMode} stats and returns — so a no-op add conjures
230
+ // nothing. Narrowing only, so a deliberate 0400 survives. The directory is held the same way, for
231
+ // the same reason: its listing names every secret this project has.
232
+ await tightenMode(path);
233
+ await tightenDirMode(dirname(path));
234
+ }
235
+ }
236
+
237
+ /** How {@link writeDevSecrets} treats a name the file already carries. */
238
+ export interface WriteDevSecretsOptions {
239
+ /**
240
+ * Overwrite a value already in the file. **For a value somebody else issued, never for a mint.**
241
+ *
242
+ * The default — a present secret always wins — exists because minting over a live session key
243
+ * invalidates every session and minting over a link key breaks every magic link already in an inbox.
244
+ * A provisioner is the opposite case: `pithy turnstile provision` is handed the widget's secret by
245
+ * Cloudflare, and keeping the old one leaves the project verifying against a widget it no longer has.
246
+ *
247
+ * An identical value still writes nothing, so a re-provision does not churn the file's mtime.
248
+ */
249
+ replace?: boolean;
250
+ }
251
+
252
+ /**
253
+ * Delete each named secret from the file, and answer which ones were actually there.
254
+ *
255
+ * The counterpart to a provisioner's write. `pithy turnstile deprovision` deletes the widget; leaving
256
+ * its secret behind would have the next `pithy dev` seed and inject a key for a widget that no longer
257
+ * exists, which reads as "turnstile is configured" everywhere anyone would look.
258
+ *
259
+ * Comments and every other value survive, for the same reason a write merges rather than replaces: the
260
+ * file is hand-maintained, and the note saying where a value came from is the useful part of it. No
261
+ * file, or no name present, writes nothing at all.
262
+ */
263
+ export async function removeDevSecrets(path: string, names: readonly string[]): Promise<string[]> {
264
+ if (names.length === 0) return [];
265
+ const content = await readDevSecretsSource(path);
266
+ if (content === null || content.trim().length === 0) return [];
267
+
268
+ const tree = parseTree(content, path);
269
+ if (tree === null || typeof tree !== "object" || Array.isArray(tree)) return [];
270
+ // `Object.hasOwn`, not `in`: a secret named for an `Object.prototype` key would otherwise read as
271
+ // present in an empty file, and `delete` would report a removal that never happened.
272
+ const removed = names.filter((name) => Object.hasOwn(tree, name));
273
+ if (removed.length === 0) return [];
274
+ for (const name of removed) delete tree[name];
275
+ await writeFileAtomic(path, `${stringify(tree, null, 2)}\n`, { mode: 0o600 });
276
+ return [...removed];
277
+ }