@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,295 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { NotFoundError } from "@pithy-sh/core/src/error/pithyError";
5
+ import type { WorkerDomains } from "@pithy-sh/core/src/naming/domains";
6
+ import { defineCommand } from "citty";
7
+ import { applyDomains } from "../project/applyDomains";
8
+ import { reconcileAppWorkflows } from "../project/appWorkflows";
9
+ import { askDomains, writeDomains } from "../project/askDomains";
10
+ import { loadProject, loadWorkerDomains, projectCloudflareAccount, requireProjectName } from "../project/config";
11
+ import { renderDomainsBlock } from "../project/domainPrompt";
12
+ import { optionalEnvArg, requireEnvironment } from "../project/environment";
13
+ import { unpublishedKitNotice } from "../project/scaffold";
14
+ import { addWorker, listWorkers, removeWorker, renameWorker } from "../project/workerCommand";
15
+ import { workerIdentity } from "../project/workerIdentity";
16
+ import { formatDone, formatJsonLine, formatList, withErrorReporting } from "../terminal/output";
17
+ import { dim } from "../terminal/style";
18
+ import { targetWorker } from "./add";
19
+
20
+ /** `pithy worker add <name>` — scaffold a new worker under apps/ and wire it in. */
21
+ const add = defineCommand({
22
+ meta: { name: "add", description: "Scaffold a new worker under apps/<name> and wire it into the dev set" },
23
+ args: {
24
+ name: { type: "positional", required: true, description: "Worker name, kebab-case, e.g. web or admin-api" },
25
+ "skip-install": { type: "boolean", default: false, description: "Skip the workspace install after scaffolding" },
26
+ json: { type: "boolean", default: false, description: "Machine-readable output" },
27
+ },
28
+ run: ({ args }) =>
29
+ withErrorReporting(args.json, async () => {
30
+ const projectDir = process.cwd();
31
+ const report = await addWorker({ projectDir, name: args.name, skipInstall: args["skip-install"] });
32
+
33
+ // Every Worker serves its own hostname, so a new one is asked about too — the same question
34
+ // `pithy init` asks, against the same account zones, and equally skippable.
35
+ const asked = await askDomains({
36
+ projectDir,
37
+ account: await projectCloudflareAccount(projectDir),
38
+ workerName: report.worker,
39
+ interactive: !args.json && Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY),
40
+ });
41
+ // A declaration the writer could not place must not vanish. `writeDomains` still generates the
42
+ // wrangler values either way, so the Worker routes correctly — but the `domains` block is the
43
+ // source of truth, and an adopter who answered the prompt needs to know it did not land.
44
+ const wrote = asked.domains ? await writeDomains(report.dir, asked.domains) : null;
45
+
46
+ if (args.json) {
47
+ process.stdout.write(
48
+ `${formatJsonLine({ command: "worker.add", ...report, domains: asked.domains ?? null })}\n`,
49
+ );
50
+ return;
51
+ }
52
+ process.stdout.write(`Worker ${report.worker} scaffolded at ${report.dir}.\n`);
53
+ if (wrote && !wrote.declared && asked.domains) {
54
+ process.stdout.write(
55
+ `Could not write the domains block into pithy.config.ts. Add it by hand:\n${renderDomainsBlock(asked.domains)}\n`,
56
+ );
57
+ }
58
+ // Three states, not two. A reconciled run that pinned nothing used to print the sentence below it —
59
+ // "run pithy feature sync to assign a port" — immediately after a sync had assigned one, because the
60
+ // port lookup could not find it (#229). The lookup is fixed; the branch stays three-way so the
61
+ // sentence about assigning a port is only ever printed where none has been.
62
+ if (report.port !== null) {
63
+ process.stdout.write(`Pinned to port ${report.port}.\n`);
64
+ } else if (report.reconciled) {
65
+ process.stdout.write("Reconciled the feature's ports. This worker took none.\n");
66
+ } else {
67
+ process.stdout.write("Ports are assigned when you run pithy feature create or sync.\n");
68
+ }
69
+ // The worker's `src/index.ts` imports `@pithy-sh/core` and, while the scope is unpublished, its
70
+ // `package.json` declares no range for it — so this said "Done." over a worker that cannot resolve
71
+ // its own entrypoint. `pithy init` printed a notice for the identical gap; this is that notice,
72
+ // from the one function that decides the wording for all three commands.
73
+ for (const line of unpublishedKitNotice() ?? []) process.stdout.write(`${dim(line)}\n`);
74
+ process.stdout.write(`${formatDone()}\n`);
75
+ }),
76
+ });
77
+
78
+ /** `pithy worker list` — the discovered workers with autostart state and pinned port. */
79
+ const list = defineCommand({
80
+ meta: { name: "list", description: "List the project's workers with their autostart state and pinned port" },
81
+ args: {
82
+ json: { type: "boolean", default: false, description: "Machine-readable output" },
83
+ },
84
+ run: ({ args }) =>
85
+ withErrorReporting(args.json, async () => {
86
+ const workers = await listWorkers({ projectDir: process.cwd() });
87
+ if (args.json) {
88
+ process.stdout.write(`${formatJsonLine({ command: "worker.list", workers })}\n`);
89
+ return;
90
+ }
91
+ if (workers.length === 0) {
92
+ process.stdout.write("No workers. Run pithy worker add <name>, or pithy init.\n");
93
+ return;
94
+ }
95
+ const rows = workers.map((worker) => {
96
+ const port = worker.port === null ? "—" : String(worker.port);
97
+ const auto = worker.autostart ? "autostart" : "manual";
98
+ // The deployed name leads the row, because it is the string the Cloudflare dashboard shows and
99
+ // the one `--worker`, `remove` and `rename` all also accept. The directory is what the adopter
100
+ // acts on, so it is named too rather than left to be inferred from the deployed one — the two
101
+ // are free to be unrelated, which is the whole reason they are separate fields (#144).
102
+ return { name: worker.deployedAs, description: dim(`${worker.worker} ${auto} port ${port}`) };
103
+ });
104
+ process.stdout.write(`${formatList(rows)}\n`);
105
+ }),
106
+ });
107
+
108
+ /** `pithy worker remove <name>` — delete apps/<name> and release its port. */
109
+ const remove = defineCommand({
110
+ meta: { name: "remove", description: "Delete a worker under apps/<name> and release its port" },
111
+ args: {
112
+ name: { type: "positional", required: true, description: "Worker name to remove (an apps/<name> directory)" },
113
+ json: { type: "boolean", default: false, description: "Machine-readable output" },
114
+ },
115
+ run: ({ args }) =>
116
+ withErrorReporting(args.json, async () => {
117
+ const report = await removeWorker({ projectDir: process.cwd(), name: args.name });
118
+ if (args.json) {
119
+ process.stdout.write(`${formatJsonLine({ command: "worker.remove", ...report })}\n`);
120
+ return;
121
+ }
122
+ process.stdout.write(`Removed ${report.worker}.\n`);
123
+ process.stdout.write(`${formatDone()}\n`);
124
+ }),
125
+ });
126
+
127
+ /**
128
+ * `pithy worker rename <old> <new>` — move `apps/<old>` and reconcile the names that must move with it.
129
+ *
130
+ * A worker's name is stamped in three places that have to agree — the directory, the deployed script
131
+ * name, and `vars.WORKER`. This is the command that moves all three at once; `pithy doctor` is what
132
+ * catches a rename done by hand.
133
+ */
134
+ const rename = defineCommand({
135
+ meta: { name: "rename", description: "Rename a worker: move apps/<old> and reconcile its name everywhere" },
136
+ args: {
137
+ from: { type: "positional", required: true, description: "The worker to rename (an apps/<name> directory)" },
138
+ to: { type: "positional", required: true, description: "The new name, kebab-case, e.g. web or admin-api" },
139
+ force: {
140
+ type: "boolean",
141
+ default: false,
142
+ description: "Rename even though a script is deployed under the old name (it stays live)",
143
+ },
144
+ json: { type: "boolean", default: false, description: "Machine-readable output" },
145
+ },
146
+ run: ({ args }) =>
147
+ withErrorReporting(args.json, async () => {
148
+ const report = await renameWorker({ projectDir: process.cwd(), from: args.from, to: args.to, force: args.force });
149
+ if (args.json) {
150
+ process.stdout.write(`${formatJsonLine({ command: "worker.rename", ...report })}\n`);
151
+ return;
152
+ }
153
+ process.stdout.write(`Renamed ${report.from} to ${report.to}.\n`);
154
+ if (report.script) {
155
+ process.stdout.write(`Deploys as ${report.script.to}, not ${report.script.from}.\n`);
156
+ }
157
+ if (report.orphaned.length > 0) {
158
+ // Under --force only: the old script is still live, still serving, and still billing.
159
+ process.stdout.write(`Still deployed under the old name: ${report.orphaned.join(", ")}. Delete or keep.\n`);
160
+ }
161
+ if (!report.accountChecked) {
162
+ // Never "nothing is deployed" — the account was not reached, and saying so is the whole difference.
163
+ process.stdout.write(dim("The account could not be reached, so nothing was checked for a live script.\n"));
164
+ }
165
+ process.stdout.write(dim("Check anything outside the worker that names it: tsconfig, CI, imports.\n"));
166
+ process.stdout.write(`${formatDone()}\n`);
167
+ }),
168
+ });
169
+
170
+ /**
171
+ * The declaration narrowed to what `--env` asked for.
172
+ *
173
+ * `applyDomains` writes every environment the declaration names, which is right for a bare run and wrong
174
+ * for a flag that names one: `--env staging` on a project mid-cutover must not also rewrite prod's route.
175
+ * An environment `domains` cannot carry — `dev`, a feature environment, a custom one — narrows to nothing,
176
+ * which is the honest answer rather than an error, because the Workflow half of this command still has
177
+ * work to do for it.
178
+ */
179
+ function scopeDomains(domains: WorkerDomains, env: string | undefined): WorkerDomains {
180
+ if (env === undefined) return domains;
181
+ return {
182
+ ...(env === "staging" && domains.staging ? { staging: domains.staging } : {}),
183
+ ...(env === "prod" && domains.prod ? { prod: domains.prod } : {}),
184
+ };
185
+ }
186
+
187
+ /**
188
+ * `pithy worker sync` — write what the Worker's `pithy.config.ts` declares into its `wrangler.jsonc`: the
189
+ * route and `vars.BASE_URL` its `domains` block implies, and the app capability's Workflows and cron
190
+ * schedule, for every environment it declares.
191
+ *
192
+ * **One command for one job: the declaration is the truth, this is what makes wrangler agree with it.**
193
+ * A library capability's Workflows arrive with `pithy <capability> provision`. The adopter's own had no
194
+ * command at all, so the binding table was hand-written per environment against a naming rule that only
195
+ * fails at deploy. `domains` had the same hole and a worse ending: `applyDomains` writes the route, and the
196
+ * only way to reach it was an interactive prompt during `pithy init` or `pithy worker add`, so a
197
+ * declaration added by hand routed nowhere and `doctor` and `deploy` both called it healthy (#264). This is
198
+ * the command `pithy doctor` names when it reports that fault.
199
+ *
200
+ * It writes config and nothing else — no Cloudflare call, no deploy — so it is safe to run on any branch,
201
+ * at any time, as often as you like. A second run reports "already in sync" and touches no file.
202
+ */
203
+ const sync = defineCommand({
204
+ meta: {
205
+ name: "sync",
206
+ description: "Write what pithy.config.ts declares — domains routes, Workflows, cron — into wrangler.jsonc",
207
+ },
208
+ args: {
209
+ worker: { type: "string", description: "Which worker to reconcile (apps/<name>)" },
210
+ env: optionalEnvArg("Reconcile just this environment (omit for every one the worker declares)"),
211
+ json: { type: "boolean", default: false, description: "Machine-readable output" },
212
+ },
213
+ run: ({ args }) =>
214
+ withErrorReporting(args.json, async () => {
215
+ // First, before any config is loaded: an illegal environment must cost nothing.
216
+ const env = args.env === undefined ? undefined : requireEnvironment(args.env);
217
+ const projectDir = process.cwd();
218
+ const interactive = !args.json && Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY);
219
+ const target = await targetWorker({
220
+ projectDir,
221
+ interactive,
222
+ ...(args.worker === undefined ? {} : { worker: args.worker }),
223
+ });
224
+
225
+ // The address first, and separately from the app capability: `domains` says where this Worker
226
+ // answers, and a Worker with no `app` block still answers somewhere. Validated on the way through —
227
+ // a malformed declaration must name the field here, not produce a route Cloudflare rejects at deploy.
228
+ const domains = loadWorkerDomains(target.config);
229
+ const routes = domains ? await applyDomains(target.dir, scopeDomains(domains, env)) : [];
230
+
231
+ const app = target.config.app;
232
+ if (!app && domains === undefined) {
233
+ throw new NotFoundError({
234
+ message: `${target.name} declares neither domains nor an app capability.`,
235
+ action:
236
+ "Declare `domains` or `app` in the worker's pithy.config.ts. This writes what they imply into wrangler.jsonc — the route and BASE_URL, the Workflow bindings and cron.",
237
+ });
238
+ }
239
+
240
+ const runs = app
241
+ ? await reconcileAppWorkflows({
242
+ workerDir: target.dir,
243
+ // `requireProjectName`, never `resolveProjectName`: a Workflow name is account-scoped and
244
+ // stable forever once deployed, so a guessed project would name a Workflow another owns.
245
+ project: await loadProject(projectDir).then(requireProjectName),
246
+ app,
247
+ ...(env === undefined ? {} : { env }),
248
+ })
249
+ : [];
250
+
251
+ if (args.json) {
252
+ process.stdout.write(
253
+ `${formatJsonLine({ command: "worker.sync", ...workerIdentity(target), routes, runs })}\n`,
254
+ );
255
+ return;
256
+ }
257
+ // One sentence for a run that changed nothing, whichever half had nothing to do. A reconcile
258
+ // command is run on a hunch, often, and it has to be obvious when it did nothing.
259
+ if (routes.every((route) => !route.changed) && runs.every((run) => !run.changed)) {
260
+ // "Nothing to sync" is about what was *declared*, not about how many environments were walked.
261
+ // An app capability declaring no Workflows is still reconciled — that is what takes a dropped
262
+ // job's binding out — so a run per environment saying nothing is exactly the empty case.
263
+ const nothing =
264
+ routes.length === 0 && runs.every((run) => run.workflows.length === 0 && run.crons.length === 0);
265
+ process.stdout.write(
266
+ nothing ? `${target.name} declares nothing to sync.\n` : `${target.name} is already in sync.\n`,
267
+ );
268
+ process.stdout.write(`${formatDone()}\n`);
269
+ return;
270
+ }
271
+ for (const route of routes) {
272
+ if (!route.changed) continue;
273
+ process.stdout.write(`${route.env}: routed to ${route.pattern}. BASE_URL ${route.baseUrl}.\n`);
274
+ }
275
+ for (const run of runs) {
276
+ if (!run.changed) continue;
277
+ const crons = run.crons.length === 0 ? "no cron" : run.crons.join(", ");
278
+ // `nothing` rather than an empty gap: a run that moved to an empty table took a job's binding
279
+ // out, and a line reading ": . no cron." says that as badly as it can be said.
280
+ const bound = run.workflows.length === 0 ? "nothing" : run.workflows.map((entry) => entry.binding).join(", ");
281
+ process.stdout.write(`${run.env}: ${bound} bound. ${crons}.\n`);
282
+ }
283
+ // Cloudflare resolves class_name in the script the binding names, and that script is this one.
284
+ const classes = [...new Set(runs.flatMap((run) => run.workflows.map((entry) => entry.class_name)))];
285
+ if (classes.length > 0) {
286
+ process.stdout.write(dim(`Export a WorkflowEntrypoint subclass from main for each: ${classes.join(", ")}.\n`));
287
+ }
288
+ process.stdout.write(`${formatDone()}\n`);
289
+ }),
290
+ });
291
+
292
+ export default defineCommand({
293
+ meta: { name: "worker", description: "Manage the project's Workers under apps/ (the dev/deploy registry)" },
294
+ subCommands: { add, list, remove, rename, sync },
295
+ });
@@ -0,0 +1,323 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { InternalError, NotFoundError } from "@pithy-sh/core/src/error/pithyError";
5
+ import { z } from "zod";
6
+ import {
7
+ ConnectionHealth,
8
+ ConnectToken,
9
+ type DashboardClient,
10
+ DEFAULT_DASHBOARD_ORIGIN,
11
+ DeviceAuthorization,
12
+ IssuedConnection,
13
+ RotatedKey,
14
+ } from "./contract";
15
+
16
+ /**
17
+ * The HTTP implementation of the management-client contract in `./contract`.
18
+ *
19
+ * **The contract is a separate module, and that boundary is the point.** The schemas, the
20
+ * `DashboardClient` interface and `DEFAULT_DASHBOARD_ORIGIN` have no runtime dependencies, so anything
21
+ * that could implement the contract can import them. This file has all three — `fetch`, `AbortController`,
22
+ * and a `setTimeout` handle whose type differs between node and the Workers runtime — and none of them
23
+ * belong in a program that only wants the shapes.
24
+ *
25
+ * Every response goes through the same three gates — reachable, 2xx, then Zod — and every failure leaves
26
+ * as a `PithyError` carrying an action line. A raw `TypeError: fetch failed` or a `ZodError` escaping to
27
+ * the terminal would breach docs/CLI.md §3.3, which wants a problem line and a next step.
28
+ */
29
+
30
+ /** How long one dashboard call waits before giving up, so a hung origin never wedges the CLI. */
31
+ const DEFAULT_TIMEOUT_MS = 10_000;
32
+
33
+ /** The request shape the client issues — a subset of `RequestInit` the global `fetch` satisfies. */
34
+ export interface DashboardRequestInit {
35
+ /** The HTTP method. */
36
+ method: string;
37
+ /** Request headers, lowercase-keyed so a test can assert `authorization` without guessing case. */
38
+ headers: Record<string, string>;
39
+ /** The JSON body, already serialized. Absent for a request that carries none. */
40
+ body?: string;
41
+ /** The timeout's abort signal. */
42
+ signal?: AbortSignal;
43
+ }
44
+
45
+ /** The slice of a `fetch` `Response` this client reads. `text()`, so a non-JSON body is our error. */
46
+ export interface DashboardResponse {
47
+ /** The HTTP status — `202` and `410` are protocol, not failure, on the device-token route. */
48
+ readonly status: number;
49
+ /** Whether the status is 2xx. */
50
+ readonly ok: boolean;
51
+ /** The raw body. Read as text so an HTML error page becomes a PithyError, not a `SyntaxError`. */
52
+ text(): Promise<string>;
53
+ }
54
+
55
+ /** A minimal `fetch`. The real `globalThis.fetch` satisfies it; a test injects a double. */
56
+ export type DashboardFetch = (url: string, init: DashboardRequestInit) => Promise<DashboardResponse>;
57
+
58
+ /** Options for {@link httpDashboardClient}. */
59
+ export interface HttpDashboardClientOptions {
60
+ /** The management client's origin. Defaults to {@link DEFAULT_DASHBOARD_ORIGIN}. */
61
+ origin?: string;
62
+ /** Injected `fetch`; defaults to the global. */
63
+ fetch?: DashboardFetch;
64
+ /** Per-request timeout in milliseconds. */
65
+ timeoutMs?: number;
66
+ }
67
+
68
+ /** One request's inputs, before the shared plumbing adds the token, the timeout, and the parsing. */
69
+ interface Call {
70
+ method: string;
71
+ path: string;
72
+ token?: string;
73
+ body?: unknown;
74
+ }
75
+
76
+ /**
77
+ * A dashboard call that could not produce a usable answer. Always a PithyError with an action line.
78
+ *
79
+ * **`action` is a parameter, and that is the whole of #217 here.** One sentence — *Check the dashboard
80
+ * origin with --origin* — used to answer all four call sites: a transport failure, a non-2xx, a body
81
+ * that was not JSON, and a body that failed Zod. It is right for exactly one of them. An operator whose
82
+ * network was down, whose token had expired, or whose own management client returned 500 was told to
83
+ * check an origin that was correct, and a wrong action is worse than no action because it is followed.
84
+ */
85
+ function unusable(message: string, action: string, detail: string, cause?: unknown): InternalError {
86
+ return new InternalError({ message, action, detail }, cause === undefined ? undefined : { cause });
87
+ }
88
+
89
+ /** Read a property off an unknown throwable without widening anything to `any`. */
90
+ function prop(value: unknown, key: string): unknown {
91
+ if (typeof value !== "object" || value === null) return undefined;
92
+ return (value as Record<string, unknown>)[key];
93
+ }
94
+
95
+ /** The errno an undici `TypeError: fetch failed` hides one level down, in its `cause`. */
96
+ function transportCode(error: unknown): string | undefined {
97
+ for (const candidate of [error, prop(error, "cause")]) {
98
+ const code = prop(candidate, "code");
99
+ if (typeof code === "string") return code;
100
+ }
101
+ return undefined;
102
+ }
103
+
104
+ /**
105
+ * What to tell an operator whose call did not complete, chosen from the failure (#217).
106
+ *
107
+ * `fetch` rejects for a mistyped host, a dead network, a refused port, an expired certificate and this
108
+ * client's own abort, and only the first is answered by re-checking `--origin`. Duck-typed throughout:
109
+ * the abort is identified by `name`, and undici buries the errno in `cause`, so nothing here may lean on
110
+ * `instanceof` — the CLI ships on Bun, whose transport errors are not node's classes.
111
+ *
112
+ * **The two runtimes do not classify the same failure the same way, and this was checked rather than
113
+ * assumed.** Node wraps in a `TypeError: fetch failed` and puts `ENOTFOUND` or `ECONNREFUSED` on the
114
+ * `cause`; Bun throws a plain `Error` and reports a dead host, a bogus TLD and a refused port all as one
115
+ * `code: "ConnectionRefused"`. Bun cannot tell those two apart — so on Bun this must not either, and its
116
+ * branch names both possibilities. Node keeps the sharper split it has earned. Both verified live
117
+ * against Bun 1.3.14 and Node 22; TLS is the one place the codes already agree.
118
+ */
119
+ function transportAction(error: unknown, origin: string, timeoutMs: number): string {
120
+ if (prop(error, "name") === "AbortError" || prop(error, "name") === "TimeoutError") {
121
+ return `The call timed out after ${timeoutMs}ms. Retry; the origin answered nothing, not the wrong thing.`;
122
+ }
123
+ const code = transportCode(error);
124
+ switch (code) {
125
+ case "ENOTFOUND":
126
+ case "EAI_AGAIN":
127
+ return `That host does not resolve. Check the origin with --origin (currently ${origin}).`;
128
+ case "ECONNREFUSED":
129
+ return `Nothing is listening at ${origin}. Start it, or point --origin somewhere that is.`;
130
+ // Bun's own codes. One of them covers what node splits in two, so the sentence covers both.
131
+ case "ConnectionRefused":
132
+ case "FailedToOpenSocket":
133
+ return `Nothing at ${origin} answered. Check that it is listening, and that the origin is right (--origin).`;
134
+ case "ConnectionClosed":
135
+ case "ECONNRESET":
136
+ case "EPIPE":
137
+ case "ETIMEDOUT":
138
+ return `The connection to ${origin} dropped. Check that it is reachable, then run the command again.`;
139
+ case "CERT_HAS_EXPIRED":
140
+ case "DEPTH_ZERO_SELF_SIGNED_CERT":
141
+ case "UNABLE_TO_VERIFY_LEAF_SIGNATURE":
142
+ case "SELF_SIGNED_CERT_IN_CHAIN":
143
+ return `TLS to ${origin} could not be verified. Fix that certificate — the origin itself answered.`;
144
+ default:
145
+ return `The call to ${origin} did not complete${code ? ` (${code})` : ""}. Check network access to it, then run the command again.`;
146
+ }
147
+ }
148
+
149
+ /** What to tell an operator about a status the client itself returned — reachable, and refusing. */
150
+ function statusAction(status: number): string {
151
+ if (status === 401 || status === 403) {
152
+ return "Sign in again with pithy login — that credential was refused, not the request.";
153
+ }
154
+ if (status === 404) return "That route is not on this management client. Check the origin with --origin.";
155
+ if (status === 429) return "Rate limited. Wait, then run the command again.";
156
+ if (status >= 500) return `The management client answered ${status}. Retry; if it persists, that is its fault.`;
157
+ return `The management client answered ${status}. The detail line says which call.`;
158
+ }
159
+
160
+ /** The one answer `--origin` genuinely earns: the thing at that origin is not a management client. */
161
+ function notAManagementClient(origin: string): string {
162
+ return `Whatever is at ${origin} is not a Pithy management client. Check the origin with --origin.`;
163
+ }
164
+
165
+ /**
166
+ * Build the HTTP client — one implementation of `DashboardClient`, never the definition of it.
167
+ *
168
+ * `origin` re-points every call at a self-hosted management client. `fetch` and `timeoutMs` are the
169
+ * seams a test replaces, so no suite reaches the network or waits out a timeout.
170
+ */
171
+ export function httpDashboardClient(options: HttpDashboardClientOptions = {}): DashboardClient {
172
+ const origin = (options.origin ?? DEFAULT_DASHBOARD_ORIGIN).replace(/\/+$/, "");
173
+ const doFetch = options.fetch ?? (globalThis.fetch as unknown as DashboardFetch);
174
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
175
+
176
+ /** Issue one call and hand back its status and raw body. Never throws for a non-2xx — the caller decides. */
177
+ async function send(call: Call): Promise<{ status: number; raw: string }> {
178
+ const controller = new AbortController();
179
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
180
+ // The timeout must never hold the event loop open past the CLI's own work.
181
+ timer.unref?.();
182
+ try {
183
+ const response = await doFetch(`${origin}${call.path}`, {
184
+ method: call.method,
185
+ headers: {
186
+ accept: "application/json",
187
+ ...(call.body === undefined ? {} : { "content-type": "application/json" }),
188
+ ...(call.token === undefined ? {} : { authorization: `Bearer ${call.token}` }),
189
+ },
190
+ ...(call.body === undefined ? {} : { body: JSON.stringify(call.body) }),
191
+ signal: controller.signal,
192
+ });
193
+ return { status: response.status, raw: await response.text() };
194
+ } catch (error) {
195
+ throw unusable(
196
+ "Couldn't reach the management client.",
197
+ transportAction(error, origin, timeoutMs),
198
+ `${call.method} ${call.path} did not complete`,
199
+ error,
200
+ );
201
+ } finally {
202
+ clearTimeout(timer);
203
+ }
204
+ }
205
+
206
+ /** Send, require 2xx, and parse the body against `schema`. The one path every typed call takes. */
207
+ async function request<T>(call: Call, schema: z.ZodType<T>): Promise<T> {
208
+ const { status, raw } = await send(call);
209
+ if (status < 200 || status >= 300) {
210
+ throw unusable(
211
+ "The management client refused that request.",
212
+ statusAction(status),
213
+ `${call.method} ${call.path} → ${status}`,
214
+ );
215
+ }
216
+ return decode(schema, raw, call);
217
+ }
218
+
219
+ /** Parse a raw body as JSON, then against `schema`. Both failures read the same way to an operator. */
220
+ function decode<T>(schema: z.ZodType<T>, raw: string, call: Call): T {
221
+ let body: unknown;
222
+ try {
223
+ body = JSON.parse(raw);
224
+ } catch (error) {
225
+ throw unusable(
226
+ "The management client returned something Pithy couldn't read.",
227
+ notAManagementClient(origin),
228
+ `${call.method} ${call.path} body was not JSON`,
229
+ error,
230
+ );
231
+ }
232
+ const parsed = schema.safeParse(body);
233
+ if (!parsed.success) {
234
+ // The issue list goes in `detail`, which the HTTP codec strips and the terminal keeps — an
235
+ // operator debugging their own management client needs the field path, and nobody else does.
236
+ throw unusable(
237
+ "The management client returned something Pithy couldn't read.",
238
+ notAManagementClient(origin),
239
+ `${call.method} ${call.path} response failed validation: ${z.prettifyError(parsed.error)}`,
240
+ parsed.error,
241
+ );
242
+ }
243
+ return parsed.data;
244
+ }
245
+
246
+ return {
247
+ startDeviceAuthorization: () => request({ method: "POST", path: "/api/cli/device/start" }, DeviceAuthorization),
248
+
249
+ async pollForConnectToken(deviceCode) {
250
+ const call: Call = { method: "POST", path: "/api/cli/device/token", body: { deviceCode } };
251
+ const { status, raw } = await send(call);
252
+ // 202 is the flow working: the human has not clicked yet. 410 is the flow over.
253
+ if (status === 202) return "pending";
254
+ if (status === 410) {
255
+ throw new NotFoundError({
256
+ message: "That sign-in request expired.",
257
+ action: "Run the command again to start a new one.",
258
+ detail: "POST /api/cli/device/token → 410",
259
+ });
260
+ }
261
+ if (status < 200 || status >= 300) {
262
+ throw unusable(
263
+ "The management client refused that request.",
264
+ statusAction(status),
265
+ `${call.method} ${call.path} → ${status}`,
266
+ );
267
+ }
268
+ return decode(ConnectToken, raw, call);
269
+ },
270
+
271
+ createConnection: (token, body) =>
272
+ request({ method: "POST", path: "/api/cli/connections", token, body }, IssuedConnection),
273
+
274
+ // The address goes up with the request. The client is being asked to sign a call to the adopter's
275
+ // Worker, and the adopter's own row is the authority on where that Worker is — not the client's
276
+ // memory of where it was at connect.
277
+ rotateKey: (token, connectionId, address) =>
278
+ request(
279
+ {
280
+ method: "POST",
281
+ path: `/api/cli/connections/${encodeURIComponent(connectionId)}/rotate`,
282
+ token,
283
+ body: address,
284
+ },
285
+ RotatedKey,
286
+ ),
287
+
288
+ async updateConnection(token, connectionId, body) {
289
+ await request(
290
+ { method: "PATCH", path: `/api/cli/connections/${encodeURIComponent(connectionId)}`, token, body },
291
+ z.unknown(),
292
+ );
293
+ },
294
+
295
+ verifyConnection: (token, connectionId, workerUrl) =>
296
+ request(
297
+ {
298
+ method: "POST",
299
+ path: `/api/cli/connections/${encodeURIComponent(connectionId)}/verify`,
300
+ token,
301
+ body: { workerUrl },
302
+ },
303
+ ConnectionHealth,
304
+ ),
305
+
306
+ async deleteConnection(token, connectionId) {
307
+ const call: Call = {
308
+ method: "DELETE",
309
+ path: `/api/cli/connections/${encodeURIComponent(connectionId)}`,
310
+ token,
311
+ };
312
+ const { status } = await send(call);
313
+ // 204 carries no body, so there is nothing to parse and nothing to validate.
314
+ if (status < 200 || status >= 300) {
315
+ throw unusable(
316
+ "The management client refused that request.",
317
+ statusAction(status),
318
+ `${call.method} ${call.path} → ${status}`,
319
+ );
320
+ }
321
+ },
322
+ };
323
+ }