@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,564 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { execFile } from "node:child_process";
5
+ import { readFile, rename, writeFile } from "node:fs/promises";
6
+ import { basename, dirname, join } from "node:path";
7
+ import { promisify } from "node:util";
8
+ import { CloudflareClients } from "@pithy-sh/cloudflare/src/client/clients";
9
+ import { ConflictError, InternalError, NotFoundError, ValidationError } from "@pithy-sh/core/src/error/pithyError";
10
+ import { type CloudflareAccountSelection, cloudflareEnv } from "../cloudflare/config";
11
+ import { generateDevVars } from "../devSecrets/generate";
12
+ import { devConfigPath, readDevConfig } from "../feature/devConfig";
13
+ import { syncFeatureDevConfig } from "../feature/sync";
14
+ import { defaultGit, type GitRunner, mainRepoRoot } from "../feature/worktree";
15
+ import { loadProject, loadProjectEnvironments, projectCloudflareAccount, requireProjectName } from "./config";
16
+ import { detectPackageManager } from "./packageManager";
17
+ import { ensureScaffoldPath, pathExists, removeScaffoldPath, WORKER_NAME } from "./scaffold";
18
+ import { type WorkerIdentity, workerIdentity } from "./workerIdentity";
19
+ import { scaffoldWorker } from "./workerScaffold";
20
+ import { discoverWorkers as discoverWorkersDefault, type WorkerTarget } from "./workers";
21
+ import { readWranglerConfig, writeWranglerConfig } from "./wrangler";
22
+
23
+ const run = promisify(execFile);
24
+
25
+ /** Discover-workers seam, defaulted to the real discovery so tests can fix the worker set. */
26
+ type DiscoverWorkers = (projectDir: string) => Promise<WorkerTarget[]>;
27
+ /** Workspace-install seam, defaulted to the detected package manager's bare `install`. */
28
+ export type WorkspaceInstall = (projectDir: string) => Promise<void>;
29
+
30
+ /** A bare `<pm> install` at the project root — relinks the workspace so a new `apps/<name>` is picked up. */
31
+ const defaultInstall: WorkspaceInstall = async (projectDir) => {
32
+ const pm = await detectPackageManager(projectDir);
33
+ try {
34
+ await run(pm, ["install"], { cwd: projectDir });
35
+ } catch (cause) {
36
+ throw new InternalError({
37
+ // The worker is rolled back, so "run the install by hand" would point at a workspace that no
38
+ // longer has the package in it. The retry is the command itself.
39
+ message: `${pm} install failed, so the worker was not added.`,
40
+ action: `Fix the install, then run pithy worker add again — or pass --skip-install and run ${pm} install later.`,
41
+ detail: cause instanceof Error ? cause.message : String(cause),
42
+ });
43
+ }
44
+ };
45
+
46
+ /** The current branch name, e.g. `feature/73-cli-commands` — the port registry's key for a feature worktree. */
47
+ async function currentBranch(git: GitRunner, cwd: string): Promise<string> {
48
+ return git(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
49
+ }
50
+
51
+ /** Shared seams every worker command accepts, so the flows are testable without git, install, or discovery. */
52
+ interface WorkerContext {
53
+ /** The project root (a main checkout or a feature worktree). Defaults to `process.cwd()` at the call site. */
54
+ projectDir: string;
55
+ /** The main checkout root — where the port registry lives. Defaults to `mainRepoRoot()`. */
56
+ mainRoot?: string;
57
+ /** The feature branch. Defaults to the current branch. */
58
+ branch?: string;
59
+ git?: GitRunner;
60
+ discoverWorkers?: DiscoverWorkers;
61
+ }
62
+
63
+ /**
64
+ * Whether this project dir is a feature worktree (distinct from the main checkout) — where ports reconcile.
65
+ * A project that is not a git repo yet (a fresh `pithy init` before `git init`) is not a worktree: resolving
66
+ * the main root would fail, so we treat the project dir as its own root and skip the port reconcile. Worker
67
+ * scaffolding must never depend on git being set up.
68
+ */
69
+ async function resolveRoots(ctx: WorkerContext): Promise<{ mainRoot: string; inWorktree: boolean }> {
70
+ if (ctx.mainRoot !== undefined) return { mainRoot: ctx.mainRoot, inWorktree: ctx.projectDir !== ctx.mainRoot };
71
+ const git = ctx.git ?? defaultGit;
72
+ try {
73
+ const mainRoot = await mainRepoRoot(git);
74
+ return { mainRoot, inWorktree: ctx.projectDir !== mainRoot };
75
+ } catch {
76
+ return { mainRoot: ctx.projectDir, inWorktree: false };
77
+ }
78
+ }
79
+
80
+ /** The outcome of {@link addWorker}: where it landed, its pinned port (when reconciled), and whether it was. */
81
+ export interface AddWorkerReport extends WorkerIdentity {
82
+ dir: string;
83
+ /** The port pinned for this worker, when run inside a feature worktree; null in a plain checkout. */
84
+ port: number | null;
85
+ /** Whether the feature's `.dev.config.json` was reconciled (only inside a worktree). */
86
+ reconciled: boolean;
87
+ /**
88
+ * One sentence per Worker whose `.dev.vars` this run did not generate — a file pithy did not write, or
89
+ * a directory it may not write into. This command regenerates *every* Worker it discovers, not just the
90
+ * new one, so a sibling that will not get its bindings is a fact about the project the adopter has to
91
+ * be told. `pithy dev` names it on every run; the command that adds the Worker used to say nothing.
92
+ */
93
+ devVarsRefused: string[];
94
+ }
95
+
96
+ /** Options for {@link addWorker}. */
97
+ export interface AddWorkerOptions extends WorkerContext {
98
+ name: string;
99
+ skipInstall?: boolean;
100
+ install?: WorkspaceInstall;
101
+ }
102
+
103
+ /**
104
+ * Everything `pithy worker add` does once `apps/<name>/` exists: generate the Worker's `.dev.vars`, take a
105
+ * port when there is a block to take one from, relink the workspace, and report what it could not write.
106
+ *
107
+ * **The install goes last.** It used to run first, and it is the one step here that reaches the network —
108
+ * so when it threw, the `.dev.vars` step below it never ran and every worker after the first came out
109
+ * without the file `pithy init` promises every worker has. Ordering fixes that outright: by the time an
110
+ * install can fail, there is nothing left for it to skip.
111
+ */
112
+ async function wireAddedWorker(options: AddWorkerOptions, dir: string): Promise<AddWorkerReport> {
113
+ const discoverWorkers = options.discoverWorkers ?? discoverWorkersDefault;
114
+ const { mainRoot, inWorktree } = await resolveRoots(options);
115
+ // One discovery, over the tree the scaffold has already landed in, and the source of both names below.
116
+ const workers = await discoverWorkers(options.projectDir);
117
+ const identity = await addedIdentity(workers, dir);
118
+
119
+ let port: number | null = null;
120
+ let reconciled = false;
121
+ if (inWorktree) {
122
+ const branch = options.branch ?? (await currentBranch(options.git ?? defaultGit, options.projectDir));
123
+ const report = await syncFeatureDevConfig({ mainRoot, worktreePath: options.projectDir, branch, discoverWorkers });
124
+ // **Keyed on `deployedAs`, because that is what the registry is keyed on.** `dev.workers` is built
125
+ // from `WorkerTarget.name` — `<project>-<worker>` for anything pithy scaffolded — and this looked the
126
+ // new Worker up by the `apps/<dir>` basename the adopter typed. Those cannot match, so inside a
127
+ // feature worktree the port came back `null` beside `reconciled: true`, and `worker add` then told
128
+ // the adopter to run `pithy feature sync` to assign the port it had just assigned. #229.
129
+ port = report.dev.workers[identity.deployedAs]?.port ?? null;
130
+ reconciled = true;
131
+ }
132
+
133
+ // Generated, not linked (#154). The new Worker needs a `.dev.vars` beside it because that is where
134
+ // wrangler reads one; every sibling is regenerated in the same pass because generation is idempotent by
135
+ // content and a run that changes nothing writes no bytes. The refusals come back because `SyncReport`
136
+ // does not carry them, and dropping that list was how a Worker with no bindings went unmentioned by the
137
+ // command that made it.
138
+ const devVars = await generateDevVars({
139
+ projectDir: options.projectDir,
140
+ workerDirs: workers.map((worker) => worker.dir),
141
+ });
142
+
143
+ if (!options.skipInstall) await (options.install ?? defaultInstall)(options.projectDir);
144
+ return { ...identity, dir, port, reconciled, devVarsRefused: devVars.refused };
145
+ }
146
+
147
+ /**
148
+ * The new Worker's two names: the `apps/` directory, and the script name it will deploy under.
149
+ *
150
+ * Read off the discovered set, so the deployed name is the one every other command will see rather than
151
+ * one recomposed here from a naming rule — `scaffoldWorker` writes `<project>-<worker>`, but a Worker is
152
+ * free to be renamed afterwards and this must not be a second place that decides. Falling back to the
153
+ * Worker's own `wrangler.jsonc` covers the one case discovery can miss it (a caller that fixes the worker
154
+ * set), and to the directory basename when that file names nothing — which is discovery's own fallback.
155
+ */
156
+ async function addedIdentity(workers: readonly WorkerTarget[], dir: string): Promise<WorkerIdentity> {
157
+ const target = workers.find((worker) => worker.dir === dir);
158
+ if (target) return workerIdentity(target);
159
+ const declared = ((await readWranglerConfig(dir).catch(() => ({}))) as { name?: string }).name;
160
+ return workerIdentity({ name: declared ?? basename(dir), dir });
161
+ }
162
+
163
+ /**
164
+ * Scaffold `apps/<name>/` and wire it into the project — the logic behind `pithy worker add`. Additive: the
165
+ * root worker and every sibling are untouched. Inside a feature worktree it reconciles the feature's
166
+ * `.dev.config.json` (the new worker takes the lowest free port in the reserved block, every existing worker
167
+ * keeps its port) and re-links `.dev.vars`. In a plain main checkout there is no port block yet — it only
168
+ * links `.dev.vars`; ports are assigned when `pithy feature create`/`sync` runs.
169
+ *
170
+ * **All-or-nothing.** Anything that fails after the directory is made rolls it back, so the same command
171
+ * works on the retry. It used to leave the half-made `apps/<name>` behind, and `scaffoldWorker` refuses a
172
+ * directory holding anything at all — so the failure blocked its own retry, and `rm -rf` by hand was the
173
+ * only way forward. Rolled back rather than resumed, deliberately: `apps/<name>` is a directory pithy owns
174
+ * outright and fills in one pass, so nothing in it is ever the adopter's and removing it destroys nothing
175
+ * they wrote. Resuming would mean deciding whether a non-empty `apps/<name>` is our half-made worker or
176
+ * their directory — and after they have opened an editor in it, those look identical. Guessing wrong there
177
+ * overwrites their file, which is a worse failure than the one being fixed.
178
+ *
179
+ * The feature's `.dev.config.json` may name the rolled-back worker until the next reconcile. It is derived
180
+ * state, rebuilt from the workers actually present on every `feature sync`, `worker add` and `worker
181
+ * remove`, and `pithy dev` starts the discovered set rather than the pinned one — so a stale entry starts
182
+ * nothing and costs the feature one port until it is next reconciled away.
183
+ */
184
+ export async function addWorker(options: AddWorkerOptions): Promise<AddWorkerReport> {
185
+ // The project comes from the root config, through `requireProjectName` — never the directory basename.
186
+ // It is stamped into the new Worker's `PROJECT` var, and that stamp has to be the same string every
187
+ // command composes resource names from, or the Worker's Images/Stream assets land under an owner
188
+ // nothing sweeps. A project with no `name` is refused here rather than scaffolding a Worker that
189
+ // cannot mint an attributable upload.
190
+ const config = await loadProject(options.projectDir);
191
+ const project = requireProjectName(config);
192
+ // The environments the project declares, so the second Worker's stanzas are the first Worker's set —
193
+ // two Workers in one project disagreeing about which environments exist is the fault #241 named.
194
+ const environments = loadProjectEnvironments(config);
195
+ const { dir } = await scaffoldWorker({ projectDir: options.projectDir, name: options.name, project, environments });
196
+
197
+ try {
198
+ return await wireAddedWorker(options, dir);
199
+ } catch (cause) {
200
+ // Only `dir`, and only the one this call just created — never a sibling, and never a path that was
201
+ // there before `scaffoldWorker` ran, which it would have refused.
202
+ //
203
+ // Gated even here (#158): a rollback is still a recursive delete of a path built out of a name, and
204
+ // "we made it a moment ago" is an assumption about a tree that another process shares. A refusal is
205
+ // swallowed rather than thrown, because it would replace the failure being reported with a second
206
+ // one — and `scaffoldWorker` already refused every layout this gate catches, so a refusal at this
207
+ // point means the tree changed mid-run and the original cause is still the more useful answer.
208
+ await removeScaffoldPath(options.projectDir, dir).catch(() => {});
209
+ throw cause;
210
+ }
211
+ }
212
+
213
+ /** One row of {@link listWorkers}: a worker's two names, dir, whether it autostarts, and its pinned dev port. */
214
+ export interface WorkerListing extends WorkerIdentity {
215
+ dir: string;
216
+ autostart: boolean;
217
+ hasWrangler: boolean;
218
+ /** The port pinned in `.dev.config.json`, or null when none is assigned (a plain checkout, or unassigned). */
219
+ port: number | null;
220
+ }
221
+
222
+ /** List the discovered workers with their autostart state and pinned dev port — the logic behind `pithy worker list`. */
223
+ export async function listWorkers(options: WorkerContext): Promise<WorkerListing[]> {
224
+ const discoverWorkers = options.discoverWorkers ?? discoverWorkersDefault;
225
+ const workers = await discoverWorkers(options.projectDir);
226
+ const config = await readDevConfig(devConfigPath(options.projectDir));
227
+ return workers.map((worker) => ({
228
+ ...workerIdentity(worker),
229
+ dir: worker.dir,
230
+ autostart: worker.dev?.autostart ?? true,
231
+ hasWrangler: worker.hasWrangler !== false,
232
+ // The registry's key is the deployed name, which is what `workerIdentity` reports as `deployedAs`.
233
+ port: config?.workers[worker.name]?.port ?? null,
234
+ }));
235
+ }
236
+
237
+ /** The outcome of {@link removeWorker}: what was deleted and whether the feature's ports were reconciled. */
238
+ export interface RemoveWorkerReport extends WorkerIdentity {
239
+ dir: string;
240
+ reconciled: boolean;
241
+ }
242
+
243
+ /** Options for {@link removeWorker}. */
244
+ export interface RemoveWorkerOptions extends WorkerContext {
245
+ name: string;
246
+ }
247
+
248
+ /**
249
+ * Delete `apps/<name>/` and release its port — the logic behind `pithy worker remove`. `apps/<name>` is a
250
+ * plain directory (not a git worktree), so a recursive delete is safe here; the `rm -rf`/inotify caveat in
251
+ * the docs is about worktrees, which this is not. Inside a feature worktree the reconcile returns the freed
252
+ * port to the block via the sticky assignment every existing worker keeps.
253
+ */
254
+ export async function removeWorker(options: RemoveWorkerOptions): Promise<RemoveWorkerReport> {
255
+ const discoverWorkers = options.discoverWorkers ?? discoverWorkersDefault;
256
+ const appsDir = join(options.projectDir, "apps");
257
+ // Resolve the target from the discovered set — matched by the name `worker list` shows OR its apps/<dir>
258
+ // basename — so a worker whose wrangler `name` differs from its directory is still removable. Restricted to
259
+ // `apps/*`, which also means the root worker (its dir is the project root) can never be removed here.
260
+ const workers = await discoverWorkers(options.projectDir);
261
+ const target = workers.find(
262
+ (worker) =>
263
+ dirname(worker.dir) === appsDir && (worker.name === options.name || basename(worker.dir) === options.name),
264
+ );
265
+ if (!target) {
266
+ throw new NotFoundError({
267
+ message: `No worker named "${options.name}" under apps/.`,
268
+ action: "Run pithy worker list to see the workers this project has.",
269
+ });
270
+ }
271
+
272
+ // Gated, and the gate is stricter than the one a write gets (#158). `target.dir` is `apps/<name>`,
273
+ // composed from a name and handed to a recursive delete: a symlink at `apps` carried that delete onto a
274
+ // canary tree outside the project, reproduced with the real CLI, and the command said "Done."
275
+ await removeScaffoldPath(options.projectDir, target.dir);
276
+
277
+ const { mainRoot, inWorktree } = await resolveRoots(options);
278
+ const identity = workerIdentity(target);
279
+ if (!inWorktree) return { ...identity, dir: target.dir, reconciled: false };
280
+
281
+ const branch = options.branch ?? (await currentBranch(options.git ?? defaultGit, options.projectDir));
282
+ await syncFeatureDevConfig({ mainRoot, worktreePath: options.projectDir, branch, discoverWorkers });
283
+ return { ...identity, dir: target.dir, reconciled: true };
284
+ }
285
+
286
+ /**
287
+ * The `wrangler.jsonc` keys a rename rewrites: the deployed script name, and the `WORKER` var in every
288
+ * environment stanza. Everything else in the file belongs to the adopter and is left exactly as it is.
289
+ */
290
+ interface RenamableWrangler {
291
+ name?: string;
292
+ vars?: Record<string, string | undefined>;
293
+ env?: Record<string, RenamableWrangler | undefined>;
294
+ }
295
+
296
+ /**
297
+ * The script name a worker called `to` would deploy under, given the one it deploys under as `from` —
298
+ * `null` when the current name does not carry the worker segment at all.
299
+ *
300
+ * `scaffoldWorker` writes `<project>-<worker>`, so the worker is the trailing segment and only that
301
+ * segment moves. A name that does not end in it (`my-service`, brought in from a Worker that predates the
302
+ * project) was never composed from the worker's name, so there is nothing here to recompute: renaming it
303
+ * would rename a Worker the adopter named themselves, and — deployed — strand it under a name pithy
304
+ * invented.
305
+ */
306
+ function renamedScript(declared: string, from: string, to: string): string | null {
307
+ if (declared === from) return to;
308
+ return declared.endsWith(`-${from}`) ? `${declared.slice(0, -from.length)}${to}` : null;
309
+ }
310
+
311
+ /**
312
+ * Every script name the worker currently deploys under, in environment order.
313
+ *
314
+ * The top-level `name` is the dev script. A named environment takes `env.<name>.name` when it declares
315
+ * one and wrangler's own default — `<name>-<env>` — when it does not, which is the case for every Worker
316
+ * `pithy` scaffolds. These are the names the account is asked about: they are what a rename leaves behind.
317
+ */
318
+ function deployedScriptNames(config: RenamableWrangler): string[] {
319
+ const top = config.name;
320
+ if (top === undefined) return [];
321
+ const names = [top];
322
+ for (const [env, stanza] of Object.entries(config.env ?? {})) {
323
+ names.push(stanza?.name ?? `${top}-${env}`);
324
+ }
325
+ return names;
326
+ }
327
+
328
+ /** What the account said about the old worker's script names. */
329
+ export interface DeployedScripts {
330
+ /** The script names that exist on the account. Empty when nothing does — or when nothing was asked. */
331
+ live: string[];
332
+ /**
333
+ * Whether the account actually answered. `false` means unchecked, not clear: no credentials, an
334
+ * offline laptop, or a token that would not list. A rename then proceeds saying so, rather than
335
+ * claiming a check it never made.
336
+ */
337
+ checked: boolean;
338
+ }
339
+
340
+ /** The account seam — asked which of `scripts` are live, so a unit test never reaches Cloudflare. */
341
+ export type DeployedScriptProbe = (
342
+ scripts: string[],
343
+ account: CloudflareAccountSelection | null,
344
+ ) => Promise<DeployedScripts>;
345
+
346
+ /**
347
+ * Ask the account which of these script names are deployed. Never throws: every failure is `checked:
348
+ * false`, because "I could not find out" and "nothing is there" must not become the same answer when the
349
+ * difference decides whether a rename orphans a live Worker.
350
+ */
351
+ export const probeDeployedScripts: DeployedScriptProbe = async (scripts, account) => {
352
+ if (scripts.length === 0) return { live: [], checked: true };
353
+ const vars = cloudflareEnv({ account });
354
+ const accountId = vars.CLOUDFLARE_ACCOUNT_ID ?? "";
355
+ const apiToken = vars.CLOUDFLARE_API_TOKEN ?? "";
356
+ if (!accountId || !apiToken) return { live: [], checked: false };
357
+ try {
358
+ // One listing, not one call per name: the account is asked once and the answer filtered locally.
359
+ const live = await new CloudflareClients({ accountId, apiToken }).workers().listWorkers();
360
+ const deployed = new Set(live.map((script) => script.id));
361
+ return { live: scripts.filter((script) => deployed.has(script)), checked: true };
362
+ } catch {
363
+ return { live: [], checked: false };
364
+ }
365
+ };
366
+
367
+ /**
368
+ * The outcome of {@link renameWorker}.
369
+ *
370
+ * `worker` and `deployedAs` are the Worker **after** the move, so a caller reading the identity keys gets
371
+ * the same two facts here it gets from every other subcommand. `from`/`to`/`script` are what a rename adds
372
+ * on top of them: the transition. `deployedAs` therefore always names the script the Worker deploys under
373
+ * now, including when `script` is `null` because the adopter's own name was left alone.
374
+ */
375
+ export interface RenameWorkerReport extends WorkerIdentity {
376
+ from: string;
377
+ to: string;
378
+ /** Where the worker now lives — `apps/<to>`. */
379
+ dir: string;
380
+ /** The deployed script name before and after, or `null` when it carried no worker segment to move. */
381
+ script: { from: string; to: string } | null;
382
+ /**
383
+ * Script names left live on the account under the old name — non-empty only under `force`. Empty when
384
+ * the account said nothing is there **and** when it said nothing at all; read it with `accountChecked`.
385
+ */
386
+ orphaned: string[];
387
+ /** Whether the account answered. `false` → `orphaned` establishes nothing. */
388
+ accountChecked: boolean;
389
+ /** Whether the feature's `.dev.config.json` was reconciled (only inside a worktree). */
390
+ reconciled: boolean;
391
+ }
392
+
393
+ /** Options for {@link renameWorker}. */
394
+ export interface RenameWorkerOptions extends WorkerContext {
395
+ /** The worker to rename, by the name `worker list` shows or its `apps/<dir>` basename. */
396
+ from: string;
397
+ /** The new name — the directory, the script's worker segment, and the `WORKER` var. */
398
+ to: string;
399
+ /** Rename even though a script is live under the old name. It stays deployed, and the report says so. */
400
+ force?: boolean;
401
+ probeDeployed?: DeployedScriptProbe;
402
+ }
403
+
404
+ /**
405
+ * Rename a worker — the logic behind `pithy worker rename`.
406
+ *
407
+ * **A worker's name is three strings, and they have to agree.** The directory `apps/<name>/`, which
408
+ * tsconfig references and CI working-directories point at; the deployed script name in `wrangler.jsonc`
409
+ * and `package.json`; and `vars.WORKER`, which is what separates two Workers' audit events when they
410
+ * share a database. Renamed by hand, whichever one is missed fails quietly and in the worst possible
411
+ * shape: the Worker deploys under one name and stamps its events with another.
412
+ *
413
+ * **A rename after a deploy is not a rename.** Resource names are computed rather than stored, so
414
+ * `<project>-<env>-<binding>` survives untouched. The Worker script is not: it is named for the worker,
415
+ * so a renamed worker deploys as a *new* script and leaves the old one live, serving, and billing. That
416
+ * is refused rather than reported, and `force` is the way to say it is understood — the report then names
417
+ * exactly what was left behind. When the account cannot be reached the rename proceeds with
418
+ * `accountChecked: false`; declaring an unchecked account clear would be the one lie this guard cannot
419
+ * afford.
420
+ *
421
+ * Not touched, deliberately: the app capability's `name` in `pithy.config.ts`. That is a **migration
422
+ * namespace**, and it is stamped into every applied migration's row — moving it orphans the ledger and
423
+ * re-runs every migration under a name the database has never seen.
424
+ */
425
+ export async function renameWorker(options: RenameWorkerOptions): Promise<RenameWorkerReport> {
426
+ // The same rule `scaffoldWorker` holds a new worker to, imported rather than restated: a name this
427
+ // refuses is a directory `pithy worker add` could not have created in the first place.
428
+ if (!WORKER_NAME.test(options.to)) {
429
+ throw new ValidationError({
430
+ message: `Worker name must be kebab-case (got "${options.to}").`,
431
+ action: "Use lowercase words joined by hyphens, e.g. web or admin-api.",
432
+ });
433
+ }
434
+
435
+ const discoverWorkers = options.discoverWorkers ?? discoverWorkersDefault;
436
+ const appsDir = join(options.projectDir, "apps");
437
+ // Resolved from the discovered set exactly as `removeWorker` resolves one — by the name `worker list`
438
+ // shows OR the `apps/<dir>` basename — so a worker already half-renamed by hand is still addressable.
439
+ const workers = await discoverWorkers(options.projectDir);
440
+ const target = workers.find(
441
+ (worker) =>
442
+ dirname(worker.dir) === appsDir && (worker.name === options.from || basename(worker.dir) === options.from),
443
+ );
444
+ if (!target) {
445
+ throw new NotFoundError({
446
+ message: `No worker named "${options.from}" under apps/.`,
447
+ action: "Run pithy worker list to see the workers this project has.",
448
+ });
449
+ }
450
+
451
+ const to = join(appsDir, options.to);
452
+ // **Both ends of the move, and the source is the one that was missing.** A rename reads its source path
453
+ // as well as writing its destination, and `rename` on a symlink moves the *link* — so a link at
454
+ // `apps/<from>` arrived at `apps/<to>` still pointing outside the project, and the `wrangler.jsonc` and
455
+ // `package.json` rewrites below then went through it. Reproduced against a canary: `worker rename api
456
+ // board` left `apps/board` a link and rewrote the canary's two files, reporting success. #164.
457
+ //
458
+ // The source is gated as a `move` rather than a `write`: nothing is scaffolded here, and telling an
459
+ // adopter "the files would land outside the project" about a directory being moved is the same false
460
+ // sentence a delete borrowing the write wording used to print.
461
+ await ensureScaffoldPath(options.projectDir, target.dir, "move");
462
+ // Safe first, then free. `ensureScaffoldPath` refuses a link at `apps` or at `apps/<to>` — `rename` onto
463
+ // one is ENOTDIR, and this function had its own `exists()` over `access`, which follows the link and so
464
+ // read a dangling one as "nothing there". The gate cleared, `rename` threw, and a raw node:fs stack trace
465
+ // came out of a command whose `--json` callers parse `{"error":{…}}`. Reproduced against the real CLI.
466
+ await ensureScaffoldPath(options.projectDir, to);
467
+ // Then existence, checked against the filesystem rather than the discovered set: a directory holding
468
+ // neither manifest nor `wrangler.jsonc` is not a worker, and moving a worker on top of it would still
469
+ // destroy it. `pathExists` is `lstat`, shared with every other gate for the reason above.
470
+ if (await pathExists(to)) {
471
+ throw new ConflictError({
472
+ message: `apps/${options.to} already exists.`,
473
+ action: "Pick another name, or remove that directory first.",
474
+ });
475
+ }
476
+
477
+ let config: RenamableWrangler;
478
+ try {
479
+ config = (await readWranglerConfig(target.dir)) as RenamableWrangler;
480
+ } catch (cause) {
481
+ throw new InternalError({
482
+ message: `Could not read ${basename(target.dir)}'s wrangler.jsonc.`,
483
+ action: "Fix the file, then run the rename again — this command edits it.",
484
+ detail: cause instanceof Error ? cause.message : String(cause),
485
+ });
486
+ }
487
+
488
+ // The account is asked before anything moves, and asked even under `force`: the point of the flag is to
489
+ // proceed knowing what is being orphaned, which means the report still has to name it.
490
+ const deployed = await (options.probeDeployed ?? probeDeployedScripts)(
491
+ deployedScriptNames(config),
492
+ await projectCloudflareAccount(options.projectDir),
493
+ );
494
+ if (deployed.live.length > 0 && !options.force) {
495
+ throw new ConflictError({
496
+ message: `${options.from} is deployed as ${deployed.live.join(", ")}.`,
497
+ action:
498
+ "A renamed worker deploys as a NEW script and leaves that one live and serving. Delete it first, or pass --force to rename anyway and orphan it.",
499
+ detail: `Live scripts under the old name: ${deployed.live.join(", ")}.`,
500
+ });
501
+ }
502
+
503
+ // A plain filesystem move, not `git mv`: a project need not be a git repo at all (`pithy worker add`
504
+ // scaffolds into one that is not), and git reads a rename off the content anyway on the next `git add`.
505
+ await rename(target.dir, to);
506
+
507
+ const from = basename(target.dir);
508
+ const declared = config.name;
509
+ const renamed = declared === undefined ? null : renamedScript(declared, from, options.to);
510
+ const script = declared !== undefined && renamed !== null ? { from: declared, to: renamed } : null;
511
+ if (script) config.name = script.to;
512
+ for (const stanza of [config, ...Object.values(config.env ?? {})]) {
513
+ if (!stanza) continue;
514
+ if (stanza !== config && stanza.name !== undefined) {
515
+ // A stanza that names its own script gets the same treatment; one that does not inherits
516
+ // wrangler's `<name>-<env>` default and so moved with the top-level name already.
517
+ stanza.name = renamedScript(stanza.name, from, options.to) ?? stanza.name;
518
+ }
519
+ // Set, never added: a stanza that declares no `WORKER` declares nothing this rename can invalidate,
520
+ // and inventing the var would put a binding-shaped opinion into a config that never asked for one.
521
+ if (stanza.vars?.WORKER !== undefined) stanza.vars.WORKER = options.to;
522
+ }
523
+ await writeWranglerConfig(to, config);
524
+ await renamePackage(to, from, options.to);
525
+
526
+ const { mainRoot, inWorktree } = await resolveRoots(options);
527
+ const report: RenameWorkerReport = {
528
+ // Read off the config this run just wrote — the same fallback discovery uses when a `wrangler.jsonc`
529
+ // names no script — so the identity reported is the one `worker list` will report a moment later.
530
+ ...workerIdentity({ name: config.name ?? options.to, dir: to }),
531
+ from,
532
+ to: options.to,
533
+ dir: to,
534
+ script,
535
+ orphaned: deployed.live,
536
+ accountChecked: deployed.checked,
537
+ reconciled: false,
538
+ };
539
+ if (!inWorktree) return report;
540
+
541
+ const branch = options.branch ?? (await currentBranch(options.git ?? defaultGit, options.projectDir));
542
+ await syncFeatureDevConfig({ mainRoot, worktreePath: options.projectDir, branch, discoverWorkers });
543
+ return { ...report, reconciled: true };
544
+ }
545
+
546
+ /**
547
+ * Move the worker's package name with it, by the same rule the script name moves. Written as plain JSON
548
+ * (CLAUDE.md: `package.json` stays strict JSON), and a worker without one is simply left alone — a
549
+ * non-Worker process in the dev set may have no package at all.
550
+ */
551
+ async function renamePackage(dir: string, from: string, to: string): Promise<void> {
552
+ const path = join(dir, "package.json");
553
+ let pkg: Record<string, unknown>;
554
+ try {
555
+ pkg = JSON.parse(await readFile(path, "utf8")) as Record<string, unknown>;
556
+ } catch {
557
+ return;
558
+ }
559
+ const name = pkg.name;
560
+ if (typeof name !== "string") return;
561
+ const renamed = renamedScript(name, from, to);
562
+ if (renamed === null) return;
563
+ await writeFile(path, `${JSON.stringify({ ...pkg, name: renamed }, null, 2)}\n`);
564
+ }
@@ -0,0 +1,50 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { basename } from "node:path";
5
+ import type { WorkerTarget } from "./workers";
6
+
7
+ /**
8
+ * The two names a Worker answers to, as every `--json` payload reports them.
9
+ *
10
+ * ## Why this is one function and not six spellings
11
+ *
12
+ * A Worker has two identities and they are not interchangeable: the directory under `apps/`, and the
13
+ * script name it deploys as. `pithy init --worker board` creates `apps/board/`, and #100 stamps
14
+ * `wrangler.jsonc` with `<project>-<worker>` — so the same Worker is `board` and `dash-board`, and
15
+ * environments suffix the second again (`dash-board-staging`).
16
+ *
17
+ * The CLI used to pick between them per command. `init`, `ui add` and `ui sync` reported the directory;
18
+ * `add`, `remove`, `upgrade` and `worker sync` reported the deployed name. One field, two meanings, and
19
+ * nothing in the payload saying which — so anyone scripting the CLI had to know which half of it they
20
+ * were in. That is pithy-sh/pithy#144.
21
+ *
22
+ * ## Why `worker` is the directory
23
+ *
24
+ * It is the name the adopter typed, and the one they can act on. It is what `--worker` accepts, what
25
+ * they `cd` into, and what every path in the same payload is relative to — a payload reporting
26
+ * `dash-board` beside `created: ["src/client.tsx"]` describes a directory that does not exist.
27
+ *
28
+ * The deployed name is derived from it and suffixed per environment, which makes it the less stable of
29
+ * the two and the wrong thing for a script to key on. It is still worth reporting — it is what appears
30
+ * in the Cloudflare dashboard and in `wrangler deploy` — so it gets its own field instead of overloading
31
+ * this one.
32
+ *
33
+ * ## The failure this is shaped to prevent
34
+ *
35
+ * Read the directory. Never derive it from the deployed name by stripping a project prefix: a
36
+ * `wrangler.jsonc` is free to name the Worker anything, `pithy worker rename` can leave the two
37
+ * unrelated, and a prefix strip turns `board`/`board-worker` into nonsense. The directory is a fact on
38
+ * disk; the deployed name is a string in a config file.
39
+ */
40
+ export interface WorkerIdentity {
41
+ /** The `apps/` directory. What `--worker` names, and what sibling paths are relative to. */
42
+ worker: string;
43
+ /** The deployed script name, from `wrangler.jsonc`. What Cloudflare shows. */
44
+ deployedAs: string;
45
+ }
46
+
47
+ /** Both names for `target`, for a `--json` payload. */
48
+ export function workerIdentity(target: Pick<WorkerTarget, "name" | "dir">): WorkerIdentity {
49
+ return { worker: basename(target.dir), deployedAs: target.name };
50
+ }