@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,517 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { Capability } from "@pithy-sh/core/src/capability/capability";
5
+ import type { ErrorPayload } from "@pithy-sh/core/src/error/payload";
6
+ import { operatorError } from "@pithy-sh/core/src/error/terminal";
7
+ import { defineCommand } from "citty";
8
+ import { availableManifests, type ManifestFault } from "../capabilities/manifests";
9
+ import {
10
+ applyReconcilePlan,
11
+ buildReconcilePlan,
12
+ declineRefusal,
13
+ type ReadLedger,
14
+ type ReconcileApplied,
15
+ type ReconcilePlan,
16
+ type RunMigrate,
17
+ } from "../capabilities/reconcile";
18
+ import type { CloudflareAccountSelection } from "../cloudflare/config";
19
+ import { loadProject, projectCloudflareAccount, requireProjectName, type WorkerConfig } from "../project/config";
20
+ import { envArg, requireEnvironment } from "../project/environment";
21
+ import { resolveWorkers } from "../project/workerScope";
22
+ import { formatDone, formatJsonLine, withErrorReporting } from "../terminal/output";
23
+
24
+ /**
25
+ * `pithy upgrade [--env] [--worker <name>] [--dry-run] [--migrate]` — reconcile every Worker with the
26
+ * capability manifests installed in the project.
27
+ *
28
+ * Capabilities are per Worker, so the reconcile engine is too: `upgrade` fans out over `apps/*`, building and
29
+ * applying one plan per Worker against that Worker's own `pithy.config.ts` and `wrangler.jsonc`. Output is
30
+ * grouped by Worker — a project with two Workers gets two blocks, and `--json` carries one entry each.
31
+ * `--worker <name>` narrows the run to one.
32
+ */
33
+
34
+ /** The minimum `upgrade` needs to know about a Worker. `ResolvedWorker` satisfies it structurally. */
35
+ export interface UpgradeWorker {
36
+ /** The Worker's name, as `pithy worker list` shows it. */
37
+ name: string;
38
+ /** The Worker's directory (`apps/<name>/`) — the wiring a plan reads and an apply writes. */
39
+ dir: string;
40
+ /** The capabilities that Worker's own `pithy.config.ts` composes. */
41
+ capabilities: Capability[];
42
+ /**
43
+ * That Worker's own `pithy.config.ts`, as `resolveWorkers` already returns it.
44
+ *
45
+ * Only `declinedBindings` is read from it here, and it is taken from the loaded config rather than
46
+ * re-read, so the write this run makes and the report an adopter compares it against are built from
47
+ * one read of one file. Optional because the seam predates it and a caller with no config to give is
48
+ * a caller that declines nothing.
49
+ */
50
+ config?: WorkerConfig;
51
+ }
52
+
53
+ /** Options for {@link runUpgrade} — every filesystem and migration dependency is injectable for tests. */
54
+ export interface UpgradeRunOptions {
55
+ /** The project root — the parent of `apps/`, and where the capability manifests resolve from. */
56
+ projectDir: string;
57
+ /** The environment the pending-migration count (and any `--migrate` run) targets. */
58
+ env: string;
59
+ /**
60
+ * The Cloudflare account this project belongs to, from `projectCloudflareAccount(projectDir)`, or
61
+ * `null` when it names none. Required (#234): `--migrate --env staging` applies migrations to a live
62
+ * schema, which is the write this must never make under credentials the project did not claim.
63
+ */
64
+ account: CloudflareAccountSelection | null;
65
+ /** Narrow the fan-out to one Worker, by name or `apps/<dir>` basename. */
66
+ worker?: string;
67
+ /** Build the plans without applying them. */
68
+ dryRun: boolean;
69
+ /** Run each Worker's pending migrations after reconciling it. */
70
+ migrate: boolean;
71
+ /** Worker-set resolver seam; defaults to {@link resolveWorkers}. */
72
+ resolveWorkers?: (options: { projectDir: string; worker?: string }) => Promise<UpgradeWorker[]>;
73
+ /** Test seam: read the migration ledger without a real Miniflare/D1 run. */
74
+ readLedger?: ReadLedger;
75
+ /** Test seam: run migrations without a real Miniflare/D1 run. */
76
+ runMigrate?: RunMigrate;
77
+ /** Test seam: substitute the manifest scan. Defaults to the real `node_modules/@pithy-sh` read. */
78
+ readManifests?: (projectDir: string) => Promise<{ faults: ManifestFault[] }>;
79
+ }
80
+
81
+ /**
82
+ * One Worker's outcome — **three states, and the plan lives behind the one that has it** (#380).
83
+ *
84
+ * A plan reads that Worker's own `pithy.config.ts` and `wrangler.jsonc` and, through the ledger, its
85
+ * databases; an apply *writes* those files and, with `--migrate`, runs that Worker's migrations. All of
86
+ * it can fail for reasons belonging to one Worker, and the throw used to propagate out of the fan-out —
87
+ * so a five-Worker project lost four Workers' reports to the fifth's broken config, and, worse than its
88
+ * twin in `buildProjectHealth`, lost them *after* some of those Workers' files had already been
89
+ * rewritten.
90
+ *
91
+ * `unplanned` and `unapplied` are kept apart because the difference is whether anything was written.
92
+ * Nothing was read about an `unplanned` Worker and nothing was changed. An `unapplied` Worker had a plan
93
+ * and the apply died inside it: its `wrangler.jsonc` may hold some of the bindings, its `pithy.config.ts`
94
+ * some of the keys, and under `--migrate` its schema may have moved. Collapsing the two would tell an
95
+ * operator to re-run a command against a Worker in an unknown state as though it were untouched.
96
+ *
97
+ * The state rides on the value, so `result.plan` does not compile without narrowing and an unreconciled
98
+ * Worker cannot be rendered as a reconciled one.
99
+ */
100
+ export type UpgradeWorkerResult =
101
+ | {
102
+ /** The plan was built, and applied unless this was a dry run. */
103
+ state: "reconciled";
104
+ /** The Worker's name, as `pithy worker list` shows it. */
105
+ worker: string;
106
+ /** What this run found to do. */
107
+ plan: ReconcilePlan;
108
+ /** What applying it changed. `null` on a dry run, which wrote nothing by design. */
109
+ applied: ReconcileApplied | null;
110
+ }
111
+ | {
112
+ /** The plan could not be built. Nothing was read about this Worker, and nothing was written. */
113
+ state: "unplanned";
114
+ /** The Worker's name — the one actionable fact, and the only one this carries. */
115
+ worker: string;
116
+ }
117
+ | {
118
+ /** The plan was built and applying it failed partway. This Worker's files may already have changed. */
119
+ state: "unapplied";
120
+ /** The Worker's name. */
121
+ worker: string;
122
+ /** What the run set out to do. What of it landed is not established — that is the whole of this state. */
123
+ plan: ReconcilePlan;
124
+ }
125
+ | {
126
+ /**
127
+ * The plan was built and refused before the first write. **Distinct from `unapplied`, and the
128
+ * distinction is the fact an adopter needs**: nothing was written, so there is no half-reconciled
129
+ * wiring to inspect — only a declaration to fix.
130
+ */
131
+ state: "refused";
132
+ /** The Worker's name. */
133
+ worker: string;
134
+ /** What the run would have done, reported so the adopter sees the cost of the refusal. */
135
+ plan: ReconcilePlan;
136
+ /** Why, as the operator's own problem and action lines rather than a swallowed throw. */
137
+ refusal: ErrorPayload;
138
+ };
139
+
140
+ /**
141
+ * What one `pithy upgrade` run produced: a result per Worker, and the manifests it could not read.
142
+ *
143
+ * The faults are project-wide, not per Worker — manifests install once under the root's
144
+ * `node_modules/@pithy-sh` and every Worker shares them — so they are reported once, above the Workers.
145
+ * They used to be reported nowhere at all: a manifest the schema refused made its capability vanish from
146
+ * every plan, and the run reconciled happily around the hole (#184).
147
+ */
148
+ export interface UpgradeRun {
149
+ /** One entry per Worker in scope, in discovery order. */
150
+ workers: UpgradeWorkerResult[];
151
+ /** Installed packages whose `pithy.manifest.json` is present and unusable. Empty on a healthy install. */
152
+ manifestFaults: ManifestFault[];
153
+ }
154
+
155
+ /**
156
+ * The project name the proposed resource names lead with — resolved here, at the command edge, and handed
157
+ * to the apply step as a plain string. The same helper `pithy add` uses, for the same reason: a capability
158
+ * wired by `upgrade` must get the same `<project>-<env>-<binding>` database name it would have got from
159
+ * `add`, or one route into the project leaves nameless resources behind.
160
+ *
161
+ * `requireProjectName`, never `resolveProjectName`: a proposal has to be the name every later command
162
+ * recomputes, and the lenient resolver's fallbacks differ between checkouts. A project with no `name` gets
163
+ * no proposal rather than a guess — the entries carry only their binding, and `pithy doctor` says why.
164
+ */
165
+ async function proposalProject(projectDir: string): Promise<string | undefined> {
166
+ try {
167
+ return requireProjectName(await loadProject(projectDir));
168
+ } catch {
169
+ return undefined;
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Reconcile every Worker in scope, in discovery order. Each Worker gets its own plan, built from and (unless
175
+ * `dryRun`) applied to its own `apps/<name>/` wiring — no Worker's drift can reach another's files.
176
+ */
177
+ export async function runUpgrade(options: UpgradeRunOptions): Promise<UpgradeRun> {
178
+ const resolve = options.resolveWorkers ?? resolveWorkers;
179
+ const scan = options.readManifests ?? availableManifests;
180
+ const { faults } = await scan(options.projectDir);
181
+ const workers = await resolve({
182
+ projectDir: options.projectDir,
183
+ ...(options.worker !== undefined ? { worker: options.worker } : {}),
184
+ });
185
+ const project = options.dryRun ? undefined : await proposalProject(options.projectDir);
186
+
187
+ const results: UpgradeWorkerResult[] = [];
188
+ for (const worker of workers) {
189
+ // Guarded per Worker, and `try`/`catch` rather than `.catch()`: a plan that throws before it returns
190
+ // a promise — a `pithy.config.ts` that will not import, a `wrangler.jsonc` the parser refuses — is
191
+ // not a rejected promise, and a `.catch()` would not see it (#371).
192
+ //
193
+ // The guards take no binding. The Worker's name is what an operator acts on, and what a config load
194
+ // or a D1 read throws names a path, an id, or a query.
195
+ let plan: ReconcilePlan;
196
+ try {
197
+ plan = await buildReconcilePlan({
198
+ projectDir: options.projectDir,
199
+ workerDir: worker.dir,
200
+ worker: worker.name,
201
+ env: options.env,
202
+ account: options.account,
203
+ capabilities: worker.capabilities,
204
+ // The whole config, not just `declinedBindings` — the reader also refuses a key that is nearly
205
+ // that one, and it can only see such a key if it is handed what the adopter actually wrote.
206
+ ...(worker.config ? { workerConfig: worker.config } : {}),
207
+ ...(options.readLedger ? { readLedger: options.readLedger } : {}),
208
+ });
209
+ } catch {
210
+ results.push({ state: "unplanned", worker: worker.name });
211
+ continue;
212
+ }
213
+ // **Before the dry-run branch, not after it.** A dry run's whole job is to predict the write, and
214
+ // the refusal is the thing that stops it — so a `--dry-run` that printed "Nothing to upgrade." and
215
+ // exited 0 where the real run exits 1 is the one report that must never disagree with the run it
216
+ // describes. Nothing is written on either path, so refusing costs the dry run nothing.
217
+ const refusal = declineRefusal(plan);
218
+ if (refusal) {
219
+ results.push({ state: "refused", worker: worker.name, plan, refusal: refusal.payload });
220
+ continue;
221
+ }
222
+ if (options.dryRun) {
223
+ results.push({ state: "reconciled", worker: worker.name, plan, applied: null });
224
+ continue;
225
+ }
226
+ let applied: ReconcileApplied;
227
+ try {
228
+ applied = await applyReconcilePlan({
229
+ projectDir: options.projectDir,
230
+ workerDir: worker.dir,
231
+ plan,
232
+ migrate: options.migrate,
233
+ env: options.env,
234
+ account: options.account,
235
+ ...(project === undefined ? {} : { project }),
236
+ capabilities: worker.capabilities,
237
+ ...(options.runMigrate ? { runMigrate: options.runMigrate } : {}),
238
+ });
239
+ } catch {
240
+ // Its own entry, and a different one from `unplanned`: this Worker's files have been opened for
241
+ // writing. What landed of the plan is exactly what this run cannot say.
242
+ results.push({ state: "unapplied", worker: worker.name, plan });
243
+ continue;
244
+ }
245
+ results.push({ state: "reconciled", worker: worker.name, plan, applied });
246
+ }
247
+ return { workers: results, manifestFaults: faults };
248
+ }
249
+
250
+ /**
251
+ * Whether any Worker in the run went unreconciled — the run's exit gate (#380).
252
+ *
253
+ * A guard that let `pithy upgrade` exit 0 around a Worker it could not read would be a weaker gate than
254
+ * the throw it replaced, and this command runs headlessly in CI. So the failure still ends the run
255
+ * non-zero; it just stops taking every other Worker's report with it.
256
+ *
257
+ * **A degraded contributor counts, and that is what merging `#371` into `#380` turned up.** `#380` wrote
258
+ * this as `state !== "reconciled"`, which was complete on its own branch: a ledger that would not read
259
+ * threw, the per-Worker guard caught it, and the Worker came back `unplanned`. `#371` then made every
260
+ * contributor degrade instead of throw — the better design, and it left this reading `reconciled` for a
261
+ * Worker whose ledger nobody could read, so the run exited 0 on a check that never happened.
262
+ *
263
+ * Neither branch was wrong; the composition was. So the gate asks the question it always meant to ask —
264
+ * **was every Worker fully checked** — rather than the proxy for it that happened to be true before.
265
+ * `partial` counts alongside `unavailable`: a short sum is not a whole one.
266
+ */
267
+ export function upgradeIncomplete(run: UpgradeRun): boolean {
268
+ return run.workers.some((result) => {
269
+ if (result.state !== "reconciled") return true;
270
+ return result.plan.ledger.state !== "read" || result.plan.entitlements.state !== "read";
271
+ });
272
+ }
273
+
274
+ /** `"2 bindings"` / `"1 binding"` — count with a singular/plural noun, omitted when zero. */
275
+ function count(n: number, noun: string): string | null {
276
+ if (n === 0) return null;
277
+ return `${n} ${noun}${n === 1 ? "" : "s"}`;
278
+ }
279
+
280
+ /** Join the non-empty parts of a per-capability summary into one sentence, or null when nothing changed. */
281
+ function parts(bindings: number, keys: number, verb: string): string | null {
282
+ const pieces = [count(bindings, "binding"), count(keys, "config key")].filter(
283
+ (piece): piece is string => piece !== null,
284
+ );
285
+ if (pieces.length === 0) return null;
286
+ return `${verb} ${pieces.join(", ")}.`;
287
+ }
288
+
289
+ /**
290
+ * The warning lines for a manifest that is installed and unusable.
291
+ *
292
+ * A capability with a fault here is in no other line of the report: its manifest could not be read, so it
293
+ * contributes no drift, no bindings, and no config keys, and the run reconciles happily around the hole.
294
+ * That silence is what #184 was reported about — `upgrade` is one of the three commands an adopter runs
295
+ * when a capability has gone missing, and it was one of the three that said nothing.
296
+ */
297
+ function faultLines(faults: readonly ManifestFault[]): string[] {
298
+ return faults.flatMap((fault) => [
299
+ `${fault.package}: malformed pithy.manifest.json. Not reconciled.`,
300
+ ...fault.reason.split("\n").map((line) => ` ${line}`),
301
+ ]);
302
+ }
303
+
304
+ /**
305
+ * What the plan's ledger says, in every state it can be in (#371).
306
+ *
307
+ * A database that could not be read gets its own line rather than being absorbed into a pending count of
308
+ * zero. `upgrade --migrate` would run against exactly that database, so "nothing pending" about one
309
+ * nobody could read is the sentence that sends an adopter to deploy.
310
+ */
311
+ function ledgerLines(plan: ReconcilePlan): string[] {
312
+ const ledger = plan.ledger;
313
+ if (ledger.state === "unavailable") return [`Migrations: not checked for ${plan.env}. No database answered.`];
314
+ const counted = ledger.state === "read" ? ledger : ledger.counted;
315
+ const lines: string[] = [];
316
+ if (counted.pending > 0) {
317
+ const pending = count(counted.pending, "migration");
318
+ lines.push(`${pending} pending. Run pithy upgrade --migrate, or pithy migrate --env ${plan.env}.`);
319
+ }
320
+ if (ledger.state === "partial") {
321
+ const named = ledger.unreadable.map((entry) => `${entry.binding} (${entry.database})`).join(", ");
322
+ lines.push(`Migrations: couldn't read ${named}. Any count above excludes them.`);
323
+ }
324
+ return lines;
325
+ }
326
+
327
+ /**
328
+ * The lines a declined binding earns in either tense.
329
+ *
330
+ * Only the two states an upgrade acts on quietly: an honored decline, which is a binding this run
331
+ * deliberately did not write, and a stale one, which names nothing and is ignored. The two refusals
332
+ * never reach a renderer — `applyReconcilePlan` throws on them before it writes, so they arrive as an
333
+ * error with an action line rather than as a report of work done.
334
+ *
335
+ * Appended after `Nothing to upgrade.` rather than before it, and deliberately: a run that wrote
336
+ * nothing *did* write nothing, and a decline is why part of that is true rather than a contradiction
337
+ * of it.
338
+ */
339
+ function declineLines(plan: ReconcilePlan): string[] {
340
+ if (plan.declinedBindings.state !== "read") return [];
341
+ return plan.declinedBindings.declines.flatMap((decline) => {
342
+ if (decline.state === "honored") {
343
+ return [`${decline.capability}: ${decline.name} (${decline.type}) declined in pithy.config.ts. Not written.`];
344
+ }
345
+ if (decline.state === "unrecognized") {
346
+ return [`declinedBindings: ${decline.name} is declined, and nothing here declares it. Ignored.`];
347
+ }
348
+ return [];
349
+ });
350
+ }
351
+
352
+ /** What a Worker with no drift says, rather than saying nothing and reading as skipped. */
353
+ const NOTHING_TO_UPGRADE = "Nothing to upgrade.";
354
+
355
+ /** The human-readable lines for one Worker's dry-run plan. */
356
+ function planLines(plan: ReconcilePlan): string[] {
357
+ const lines: string[] = [];
358
+ for (const cap of plan.perCapability) {
359
+ const summary = parts(cap.missingBindings.length, cap.missingConfigKeys.length, "add");
360
+ if (summary) lines.push(`${cap.name}: ${summary}`);
361
+ }
362
+ for (const name of plan.ejectedSkipped) lines.push(`${name}: ejected. Skipped.`);
363
+ // The Worker's entry, in the same words the applied lines use one tense over. A Durable Object is one
364
+ // binding written in two files, and the plan reported only the file that is config — so a project with
365
+ // the binding and no export read as "Nothing to upgrade." while the deploy was refused (#428).
366
+ const exports = [...new Set(plan.perCapability.flatMap((cap) => cap.missingEntryExports))];
367
+ if (exports.length > 0) lines.push(`Worker entry: export ${exports.join(", ")}.`);
368
+ if (plan.missingVersionMetadata) lines.push("version_metadata: add CF_VERSION_METADATA.");
369
+ lines.push(...ledgerLines(plan));
370
+ if (lines.length === 0) lines.push(NOTHING_TO_UPGRADE);
371
+ lines.push(...declineLines(plan));
372
+ return lines;
373
+ }
374
+
375
+ /**
376
+ * The human-readable lines for one Worker's applied upgrade.
377
+ *
378
+ * A skipped binding gets a line of its own, named, under the capability that needed it. **Counted, it
379
+ * would be invisible** — and being invisible is exactly what #318 was: the count came from the plan, so
380
+ * five bindings the writer declined were reported as added and the adopter's next command, `pithy
381
+ * doctor`, contradicted this one about a file it had just written.
382
+ */
383
+ function appliedLines(applied: ReconcileApplied, plan: ReconcilePlan): string[] {
384
+ const lines: string[] = [];
385
+ for (const cap of applied.perCapability) {
386
+ const summary = parts(cap.addedBindings.length, cap.addedConfigKeys.length, "added");
387
+ if (summary) lines.push(`${cap.name}: ${summary}`);
388
+ for (const skipped of cap.skippedBindings) {
389
+ lines.push(`${cap.name}: ${skipped.name} (${skipped.type}) not written for ${skipped.env} — ${skipped.reason}.`);
390
+ }
391
+ }
392
+ for (const name of applied.ejectedSkipped) lines.push(`${name}: ejected. Skipped.`);
393
+ // The Worker's entry, which is source in the adopter's repo rather than config. Named, because a file
394
+ // this command edits without saying so is a `git diff` they have to reverse-engineer.
395
+ if (applied.addedEntryExports.length > 0) {
396
+ lines.push(`Worker entry: exported ${applied.addedEntryExports.join(", ")}.`);
397
+ }
398
+ if (applied.addedVersionMetadata) lines.push("version_metadata: added CF_VERSION_METADATA.");
399
+ else if (plan.missingVersionMetadata) lines.push("version_metadata: names another binding. Left alone.");
400
+ if (applied.migrated) {
401
+ const total = applied.migrations.reduce((sum, run) => sum + run.results.length, 0);
402
+ lines.push(total === 0 ? "Migrations up to date." : `Migrated ${count(total, "migration")}.`);
403
+ } else {
404
+ lines.push(...ledgerLines(plan));
405
+ }
406
+ if (lines.length === 0) lines.push(NOTHING_TO_UPGRADE);
407
+ lines.push(...declineLines(plan));
408
+ return lines;
409
+ }
410
+
411
+ /**
412
+ * Group each Worker's lines under its name: the name on its own line, its lines indented beneath. Every
413
+ * Worker in scope appears, including one with nothing to do — the run covered it, and silence would read
414
+ * as "skipped".
415
+ */
416
+ function renderUpgrade(run: UpgradeRun): string[] {
417
+ // Above the Workers, because it is not any Worker's fault and it explains a gap in all of them.
418
+ const lines: string[] = faultLines(run.manifestFaults);
419
+ for (const result of run.workers) {
420
+ lines.push(`${result.worker}:`);
421
+ for (const line of workerLines(result)) lines.push(` ${line}`);
422
+ }
423
+ return lines;
424
+ }
425
+
426
+ /**
427
+ * One Worker's lines, in each of the three states it can be in (#380).
428
+ *
429
+ * Neither failure state prints "Nothing to upgrade." That sentence is a finding — the run looked and
430
+ * found nothing — and it is the one thing an unread Worker must never say.
431
+ */
432
+ function workerLines(result: UpgradeWorkerResult): string[] {
433
+ if (result.state === "unplanned") {
434
+ return [
435
+ "Couldn't be planned. Its pithy.config.ts or wrangler.jsonc would not read.",
436
+ "Nothing was written for it.",
437
+ ];
438
+ }
439
+ if (result.state === "refused") {
440
+ // The plan is printed under it deliberately: the refusal says what to fix, and the plan says what
441
+ // the Worker gives up until it is fixed. Neither alone is the whole answer.
442
+ //
443
+ // Except when the plan's answer is nothing. `planLines` fills an empty plan with `Nothing to
444
+ // upgrade.` so a Worker with no drift is never silent — but under a refusal that sentence reads as
445
+ // a contradiction of the two lines above it, and the honest thing to print is neither.
446
+ const pending = planLines(result.plan);
447
+ const gives = pending.length === 1 && pending[0] === NOTHING_TO_UPGRADE ? [] : pending;
448
+ return [result.refusal.message, ...(result.refusal.action ? [result.refusal.action] : []), ...gives];
449
+ }
450
+ if (result.state === "unapplied") {
451
+ return [
452
+ "Upgrade failed partway. Its wiring may hold part of the plan below.",
453
+ `Check it, then re-run: pithy upgrade --worker ${result.worker} --env ${result.plan.env}.`,
454
+ ...planLines(result.plan),
455
+ ];
456
+ }
457
+ return result.applied ? appliedLines(result.applied, result.plan) : planLines(result.plan);
458
+ }
459
+
460
+ export default defineCommand({
461
+ meta: {
462
+ name: "upgrade",
463
+ description: "Reconcile each worker's installed capabilities with its pithy.config.ts and wrangler.jsonc",
464
+ },
465
+ args: {
466
+ env: envArg("Target environment (drives the pending-migration count)"),
467
+ worker: { type: "string", description: "Upgrade only this worker (default: every worker under apps/)" },
468
+ "dry-run": { type: "boolean", default: false, description: "Show the plan without writing anything" },
469
+ migrate: { type: "boolean", default: false, description: "Run pending migrations after reconciling" },
470
+ json: { type: "boolean", default: false, description: "Machine-readable output" },
471
+ },
472
+ run: ({ args }) =>
473
+ withErrorReporting(args.json, async () => {
474
+ const env = requireEnvironment(args.env);
475
+ const dryRun = args["dry-run"];
476
+ const projectDir = process.cwd();
477
+ const run = await runUpgrade({
478
+ projectDir,
479
+ env,
480
+ account: await projectCloudflareAccount(projectDir),
481
+ ...(args.worker ? { worker: args.worker } : {}),
482
+ dryRun,
483
+ migrate: args.migrate,
484
+ });
485
+
486
+ // A Worker that could not be read establishes nothing, so the run does not exit 0 around it. Set
487
+ // before either renderer, so the two paths cannot disagree about whether the run succeeded.
488
+ if (upgradeIncomplete(run)) process.exitCode = 1;
489
+
490
+ if (args.json) {
491
+ // The state rides on every entry, so a consumer reads `state` before reaching for a plan — there
492
+ // is no entry here whose absent fields could be read as empty ones.
493
+ const workers = run.workers.map((result) =>
494
+ result.state === "reconciled"
495
+ ? { state: result.state, ...(result.applied ?? result.plan) }
496
+ : result.state === "unapplied"
497
+ ? { state: result.state, worker: result.worker, plan: result.plan }
498
+ : result.state === "refused"
499
+ ? // `operatorError` rather than the raw payload: this line is the operator's, so it
500
+ // carries `action` — the field `clientError` strips and the one naming the entry to
501
+ // remove. A consumer reading this is driving the CLI, not receiving a response.
502
+ { state: result.state, worker: result.worker, plan: result.plan, ...operatorError(result.refusal) }
503
+ : { state: result.state, worker: result.worker },
504
+ );
505
+ process.stdout.write(
506
+ `${formatJsonLine({ command: "upgrade", env, dryRun, workers, manifestFaults: run.manifestFaults })}\n`,
507
+ );
508
+ return;
509
+ }
510
+
511
+ for (const line of renderUpgrade(run)) process.stdout.write(`${line}\n`);
512
+ process.stdout.write(dryRun ? "Dry run. Nothing written.\n" : `${formatDone()}\n`);
513
+ }),
514
+ });
515
+
516
+ // Exposed for the command test to exercise the render helpers directly (run() stays thin and untested).
517
+ export const __test = { planLines, appliedLines, renderUpgrade, workerLines, parts, count };