@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,394 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { access } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+ import type { Capability } from "@pithy-sh/core/src/capability/capability";
7
+ import { messageOf, NotFoundError, PithyError, ValidationError } from "@pithy-sh/core/src/error/pithyError";
8
+ import { composeCapabilities } from "../capabilities/compose";
9
+ import { allCapabilities, loadWorkerConfig, type WorkerConfig } from "./config";
10
+ import { discoverWorkers as discoverWorkersDefault, type WorkerTarget } from "./workers";
11
+
12
+ /**
13
+ * Resolving *which Worker* a command acts on — the one seam every per-Worker command shares.
14
+ *
15
+ * Since every Worker lives in `apps/<name>/` with its own `pithy.config.ts` and `wrangler.jsonc`, a command
16
+ * either targets **one** Worker (`add`, `remove` — they write that Worker's wiring) or **fans out** over the
17
+ * whole set (`migrate`, `seed`, `upgrade`, `doctor`, `env`). Both paths resolve through here so the ambiguity
18
+ * rules and the error copy stay identical across the CLI.
19
+ */
20
+
21
+ /** A Worker plus its loaded config — what a per-Worker command actually needs to do its work. */
22
+ export interface ResolvedWorker {
23
+ /** The Worker's name (its `wrangler.jsonc` name, else its `apps/<dir>` basename). */
24
+ name: string;
25
+ /** The Worker's directory — where its `wrangler.jsonc` and `pithy.config.ts` live. */
26
+ dir: string;
27
+ /** The Worker's own `pithy.config.ts`. */
28
+ config: WorkerConfig;
29
+ /** That config's capabilities in composition order — libraries first, its app last. */
30
+ capabilities: Capability[];
31
+ /** The discovered target, carrying its dev-manifest block. */
32
+ target: WorkerTarget;
33
+ }
34
+
35
+ /** Shared options for both resolvers: the project root, plus seams so tests need no real filesystem. */
36
+ export interface ResolveOptions {
37
+ /** The project root — the parent of `apps/`. */
38
+ projectDir: string;
39
+ /** Discovery seam (default: `discoverWorkers`). */
40
+ discoverWorkers?: (projectDir: string) => Promise<WorkerTarget[]>;
41
+ /** Worker-config loader seam (default: `loadWorkerConfig`). */
42
+ loadConfig?: (workerDir: string) => Promise<WorkerConfig>;
43
+ }
44
+
45
+ /**
46
+ * The actionable error when nothing resolved, distinguishing the two very different causes: standing outside
47
+ * a Pithy project entirely (no root `pithy.config.ts` — you want `pithy init`) versus standing in one that has
48
+ * no Workers yet (you want `pithy worker add`). Telling someone to add a worker when they are simply in the
49
+ * wrong directory sends them the wrong way.
50
+ */
51
+ async function noWorkers(projectDir: string): Promise<never> {
52
+ try {
53
+ await access(join(projectDir, "pithy.config.ts"));
54
+ } catch {
55
+ throw new NotFoundError({
56
+ message: "No pithy.config.ts here.",
57
+ action: "Run from a Pithy project. pithy init creates one.",
58
+ });
59
+ }
60
+ throw new NotFoundError({
61
+ message: "No workers here.",
62
+ action: "Every worker lives in apps/<name>. Run pithy worker add <name> to create one.",
63
+ });
64
+ }
65
+
66
+ /** Load a discovered target's config, attaching its capabilities. */
67
+ async function resolve(target: WorkerTarget, load: (dir: string) => Promise<WorkerConfig>): Promise<ResolvedWorker> {
68
+ const config = await load(target.dir);
69
+ return { name: target.name, dir: target.dir, config, capabilities: allCapabilities(config), target };
70
+ }
71
+
72
+ /** A discovered Worker left out of a resolution, and why. */
73
+ export interface SkippedWorker {
74
+ /** The Worker's name, as `pithy worker list` shows it. */
75
+ name: string;
76
+ /** The Worker's directory. */
77
+ dir: string;
78
+ /** Why its config could not be read. Reaches a terminal, so never `detail`. */
79
+ reason: string;
80
+ }
81
+
82
+ /** Both halves of one resolution: the Workers that resolved, and the ones that could not be asked. */
83
+ export interface WorkerResolution {
84
+ /** Every Worker whose `pithy.config.ts` loaded, in discovery order. */
85
+ workers: ResolvedWorker[];
86
+ /**
87
+ * Every Worker the set is *missing* — a directory with a `wrangler.jsonc` whose `pithy.config.ts` is
88
+ * absent. Empty on an ordinary run, and empty for a dev-only process, which never had one.
89
+ */
90
+ skipped: SkippedWorker[];
91
+ }
92
+
93
+ /**
94
+ * Every Worker in the project **and every one that had to be left out** — the two answers that must not
95
+ * arrive as one (#455). {@link resolveWorkers} is this with the second half dropped, which is the right
96
+ * call for a fan-out that acts per Worker and the wrong one for anything reasoning about the project as a
97
+ * whole.
98
+ *
99
+ * A `pithy.config.ts` that **throws** still throws from here, unchanged: swallowing it reports
100
+ * "No workers here" for a project that plainly has workers, hiding the real cause (usually uninstalled
101
+ * dependencies or a syntax error). What changes is the *absent* file. A dev-only process — a Vite frontend
102
+ * joining the dev set through `pithy.worker.jsonc` alone — has no config and never had one, so skipping it
103
+ * is the ordinary state. A directory with a `wrangler.jsonc` is a Worker, so a missing config there means
104
+ * the resolved set is **incomplete**, not merely smaller: `#454`'s guard closes "a config throws" and this
105
+ * is the case it does not cover. `feature destroy`'s reconcile backstop scans the union's bindings, so a
106
+ * Worker silently dropped leaks every resource it declared while the run exits 0.
107
+ *
108
+ * `hasWrangler` is what tells the two apart, and it is read strictly (`=== true`): real discovery always
109
+ * sets it, and the many test doubles that carry only `name`/`dir` must not start reporting themselves as
110
+ * gaps. See {@link WorkerTarget}.
111
+ */
112
+ export async function resolveWorkersReporting(
113
+ options: ResolveOptions & { worker?: string },
114
+ ): Promise<WorkerResolution> {
115
+ const discover = options.discoverWorkers ?? discoverWorkersDefault;
116
+ const load = options.loadConfig ?? loadWorkerConfig;
117
+ const targets = await discover(options.projectDir);
118
+ if (targets.length === 0) await noWorkers(options.projectDir);
119
+
120
+ if (options.worker !== undefined) {
121
+ // A named Worker is the one the caller asked for: its config is loaded, and its failure is theirs to
122
+ // see. Narrowing happens *before* the load, so a sibling's broken config costs nothing.
123
+ return { workers: [await resolve(pick(targets, options.worker), load)], skipped: [] };
124
+ }
125
+
126
+ const workers: ResolvedWorker[] = [];
127
+ const skipped: SkippedWorker[] = [];
128
+ for (const target of targets) {
129
+ let config: WorkerConfig;
130
+ try {
131
+ config = await load(target.dir);
132
+ } catch (error) {
133
+ if (!(error instanceof PithyError && error.payload.code === "core/not_found")) throw error;
134
+ if (target.hasWrangler === true) skipped.push({ name: target.name, dir: target.dir, reason: messageOf(error) });
135
+ continue;
136
+ }
137
+ workers.push({ name: target.name, dir: target.dir, config, capabilities: allCapabilities(config), target });
138
+ }
139
+ // Nothing resolved and nothing skipped is genuinely nothing, and {@link noWorkers} says which kind. An
140
+ // empty set *with* something skipped is a different fact, and reporting it is this function's whole job
141
+ // — the refusal belongs to {@link resolveWorkers}, whose callers have only an array to read it from.
142
+ if (workers.length === 0 && skipped.length === 0) await noWorkers(options.projectDir);
143
+ return { workers, skipped };
144
+ }
145
+
146
+ /**
147
+ * The refusal when every discovered Worker is missing its `pithy.config.ts`. "No workers here" is false —
148
+ * the project plainly has them — and it sent the reader to `pithy worker add` for Workers that exist.
149
+ */
150
+ function everyWorkerUnreadable(skipped: readonly SkippedWorker[]): never {
151
+ throw new NotFoundError({
152
+ message: "Every worker here is missing its pithy.config.ts.",
153
+ action: `Restore it, or run pithy worker add to rewrite one. Missing: ${skipped.map((s) => s.name).join(", ")}.`,
154
+ });
155
+ }
156
+
157
+ /**
158
+ * Every Worker in the project, each with its config loaded — the fan-out set for `migrate`, `seed`,
159
+ * `upgrade`, `doctor`, and `env`. Pass `worker` to narrow to one. Workers are returned in discovery order
160
+ * (alphabetical), so output ordering is stable run to run.
161
+ *
162
+ * Only Workers that carry a `pithy.config.ts` are returned: a non-Worker dev process (a Vite frontend joining
163
+ * the dev set via `pithy.worker.jsonc` alone) has no capabilities to migrate, seed, or reconcile. A command
164
+ * that acts on the project as a whole rather than per Worker wants {@link resolveWorkersReporting}, which
165
+ * also names the Workers this one drops.
166
+ */
167
+ export async function resolveWorkers(options: ResolveOptions & { worker?: string }): Promise<ResolvedWorker[]> {
168
+ const { workers, skipped } = await resolveWorkersReporting(options);
169
+ if (workers.length === 0) everyWorkerUnreadable(skipped);
170
+ return workers;
171
+ }
172
+
173
+ /**
174
+ * Why a set could not be determined — the third state, **carrying its own diagnosis**.
175
+ *
176
+ * A bare `null` was the first shape of this and it repeated `#454`'s own mistake one level down: every
177
+ * refusal downstream had to invent a sentence, and each invented the same wrong one — *this project's
178
+ * Worker configuration will not load* — for a config that is **absent**, pointing the reader at a file
179
+ * that does not exist and never naming which Worker. {@link resolveWorkersReporting} knows both facts, so
180
+ * the reason travels with the refusal instead of being recomputed badly by whoever catches it.
181
+ */
182
+ export interface UnknownSet {
183
+ /** One actionable sentence naming the Workers that could not be read. Reaches a terminal, never `detail`. */
184
+ readonly unknown: string;
185
+ }
186
+
187
+ /** Every Worker in the project, or why that set cannot be known. */
188
+ export type WorkerSet = ResolvedWorker[] | UnknownSet;
189
+
190
+ /** Every capability composed anywhere in the project, or why that set cannot be known. */
191
+ export type CapabilitySet = readonly Capability[] | UnknownSet;
192
+
193
+ /**
194
+ * Narrow either set: `true` when the answer is unknowable. An array — **including an empty one** — is a
195
+ * real answer, and that is the whole distinction: a project with no Workers composes nothing, and one
196
+ * whose config will not load composes something nobody here can name.
197
+ */
198
+ export function isUnknown<T>(set: readonly T[] | UnknownSet): set is UnknownSet {
199
+ return !Array.isArray(set);
200
+ }
201
+
202
+ /** The diagnosis for a set left incomplete by Workers whose `pithy.config.ts` is absent. */
203
+ function missingConfigs(skipped: readonly SkippedWorker[]): UnknownSet {
204
+ const names = skipped.map((worker) => worker.name).join(", ");
205
+ return { unknown: `No pithy.config.ts in ${names} — every worker under apps/ needs one.` };
206
+ }
207
+
208
+ /**
209
+ * Every Worker resolved, or why the set is **unknowable from here** — the third state (#455).
210
+ *
211
+ * Unknowable is not "none". A project with no Workers at all answers `[]`, because nothing was ever named
212
+ * and there is nothing to reason about. {@link UnknownSet} means a `pithy.config.ts` threw, or a Worker was
213
+ * skipped and the set that came back is incomplete — and a caller deriving policy from it (which
214
+ * credentials CI gets, whether this project audits, which resources teardown must delete) has to refuse
215
+ * rather than infer absence. Folding the two together is what let `pithy deploy` ship unaudited and exit 0.
216
+ *
217
+ * **Deliberately no `worker`.** The distinction only means something across the project: narrowed to one
218
+ * Worker, `core/not_found` stops being "this project has no Workers" and becomes "*this* Worker has no
219
+ * config", which the catch below would answer `[]` — the exact conflation this function exists to undo.
220
+ * A caller wanting one Worker wants {@link resolveWorkers}, whose failure is theirs to see.
221
+ */
222
+ export async function resolveWorkerSet(options: ResolveOptions): Promise<WorkerSet> {
223
+ try {
224
+ const { workers, skipped } = await resolveWorkersReporting(options);
225
+ // Incomplete is unknowable: a Worker nobody could read is a Worker whose capabilities are unaccounted
226
+ // for, and every caller of this function derives policy from the set being whole.
227
+ return skipped.length > 0 ? missingConfigs(skipped) : workers;
228
+ } catch (error) {
229
+ /*
230
+ **A project with no Workers is `[]`, not unknowable.** `resolveWorkers` throws `core/not_found` for
231
+ exactly that — an empty `apps/`, or one holding only dev-only processes with no `pithy.config.ts` —
232
+ and nothing was ever named, so a reconcile pass has nothing to recompute and a manifest pass still
233
+ runs. Swallowed into `null`, `feature destroy` refused with a diagnosis that was not true: *this
234
+ project's Worker configuration will not load*, pointing at a file that does not exist, and a CI
235
+ teardown failed on it (#454).
236
+
237
+ `core/not_found` also covers "no `pithy.config.ts` here", which is not this — but a caller wanting the
238
+ third state loads the *root* config first and throws its own error before reaching here. The
239
+ every-Worker-unreadable case never arrives as a throw at all: {@link resolveWorkersReporting} reports
240
+ it, and the branch above has already read it as `null`.
241
+ */
242
+ if (error instanceof PithyError && error.payload.code === "core/not_found") return [];
243
+ return { unknown: messageOf(error) };
244
+ }
245
+ }
246
+
247
+ /** Find a Worker by the name `pithy worker list` shows, or by its `apps/<dir>` basename. */
248
+ function pick(targets: WorkerTarget[], name: string): WorkerTarget {
249
+ const found = targets.find((target) => target.name === name || target.dir.endsWith(`/${name}`));
250
+ if (!found) {
251
+ throw new NotFoundError({
252
+ message: `No worker named "${name}".`,
253
+ action: `Run pithy worker list to see this project's workers. Known: ${targets.map((t) => t.name).join(", ")}.`,
254
+ });
255
+ }
256
+ return found;
257
+ }
258
+
259
+ /**
260
+ * Fold a later instance of an already-seen capability into the kept one: same entry, plus any binding the
261
+ * later instance declares that the kept one does not. Returns the kept instance untouched when it declares
262
+ * everything (the common case — two Workers composing the same capability the same way), so nothing is
263
+ * copied unless a binding would otherwise be dropped. Bindings are matched by **name**, because one binding
264
+ * name is exactly one provisioned resource.
265
+ */
266
+ function mergeBindings(kept: Capability, later: Capability): Capability {
267
+ const declared = new Set(kept.requiredBindings.map((binding) => binding.name));
268
+ const extra = later.requiredBindings.filter((binding) => !declared.has(binding.name));
269
+ return extra.length === 0 ? kept : { ...kept, requiredBindings: [...kept.requiredBindings, ...extra] };
270
+ }
271
+
272
+ /**
273
+ * Every capability composed anywhere in the project, one entry per name, in worker-discovery order.
274
+ *
275
+ * Most commands fan out per Worker — that is the point of the layout. A few genuinely need the project's
276
+ * whole capability surface as one list: `pithy feature` provisions **one** resource per binding name for the
277
+ * whole feature (two Workers that both declare `DB` deliberately share a database), and the local
278
+ * migrate/seed it runs must cover every table any Worker owns.
279
+ *
280
+ * One entry per capability **name**, because a capability composed by two Workers ships one migration
281
+ * namespace and one set of tables — running it twice would double-apply the same registry. But two Workers
282
+ * may compose the same capability with **different config**, and config changes what it binds
283
+ * (`media({ recordStore: "kv" })` adds a KV namespace; `audit({ database })` renames the D1 binding).
284
+ * Keeping only the first instance would drop the second's bindings from the union `pithy provision`
285
+ * creates resources from, leaving that Worker deployed with a binding nothing backs. So the first instance
286
+ * wins and every later instance's *additional* bindings are folded into it: one namespace, no lost binding.
287
+ */
288
+ export function projectCapabilities(workers: readonly ResolvedWorker[]): Capability[] {
289
+ const byName = new Map<string, Capability>();
290
+ for (const worker of workers) {
291
+ for (const capability of worker.capabilities) {
292
+ const kept = byName.get(capability.name);
293
+ byName.set(capability.name, kept ? mergeBindings(kept, capability) : capability);
294
+ }
295
+ }
296
+ return [...byName.values()];
297
+ }
298
+
299
+ /**
300
+ * The same union, or why it is **unknowable from here** — the third state (#455, #454).
301
+ *
302
+ * Unknowable is not "none", and every caller has to tell the two apart. An empty array would let `feature
303
+ * destroy` report a clean remote teardown having deleted nothing, `pithy deploy` ship unaudited from a
304
+ * project that has audit composed, and `pithy token rotate` mint a replacement carrying only the base
305
+ * permissions before deleting the fully-permissioned token it replaces. See {@link resolveWorkerSet} for
306
+ * what separates `[]` from an {@link UnknownSet}.
307
+ */
308
+ export async function projectCapabilitySet(
309
+ projectDir: string,
310
+ seams: Omit<ResolveOptions, "projectDir"> = {},
311
+ ): Promise<CapabilitySet> {
312
+ return capabilitySetOf(await resolveWorkerSet({ projectDir, ...seams }));
313
+ }
314
+
315
+ /**
316
+ * {@link projectCapabilities} over a set that may be unknowable — the pure half of
317
+ * {@link projectCapabilitySet}, for a caller that already resolved the Workers and would otherwise
318
+ * resolve them a second time.
319
+ */
320
+ export function capabilitySetOf(workers: WorkerSet): CapabilitySet {
321
+ return isUnknown(workers) ? workers : projectCapabilities(workers);
322
+ }
323
+
324
+ /**
325
+ * The same union, **assembled** — every capability's `compose` hook run over it, the way a Worker runs
326
+ * them at startup.
327
+ *
328
+ * The pairing is deliberate. {@link projectCapabilities} is a pure fold and stays one, because most of
329
+ * its callers want the binding surface and nothing else. A caller that goes on to *read* a value off a
330
+ * capability — `hostCatalogs()`, `layersFor`, `composedMessages` — wants this one, because those are
331
+ * the values a hook fills and they are placeholders until it has. See {@link composeCapabilities} for
332
+ * what an uncomposed read produces, and why it is silent.
333
+ */
334
+ export function composedProjectCapabilities(workers: readonly ResolvedWorker[]): Capability[] {
335
+ return composeCapabilities(projectCapabilities(workers));
336
+ }
337
+
338
+ /** Options for {@link resolveSingleWorker}. */
339
+ export interface ResolveSingleOptions extends ResolveOptions {
340
+ /** The `--worker` value, when the caller passed one. */
341
+ worker?: string;
342
+ /**
343
+ * Prompt for the Worker when several exist and none was named. Supplied only by an interactive command
344
+ * (a TTY, not `--json`); omitted, ambiguity is an actionable error instead.
345
+ */
346
+ prompt?: (choices: ResolvedWorker[]) => Promise<string>;
347
+ }
348
+
349
+ /**
350
+ * The single Worker a wiring command acts on — `pithy add`/`remove`, which write one Worker's
351
+ * `pithy.config.ts` and `wrangler.jsonc`.
352
+ *
353
+ * `--worker` names it outright. With none named: a project holding exactly one Worker uses it (the common
354
+ * case — no ceremony for a single-Worker project); a project holding several **never guesses**, because
355
+ * wiring a capability into the wrong Worker silently puts bindings and Durable Object class migrations on the
356
+ * wrong script. It prompts when a human is attached, and fails with an actionable error otherwise, so an
357
+ * agent driving `--json` gets told exactly what to pass rather than a surprise.
358
+ */
359
+ export async function resolveSingleWorker(options: ResolveSingleOptions): Promise<ResolvedWorker> {
360
+ const seams = {
361
+ ...(options.discoverWorkers ? { discoverWorkers: options.discoverWorkers } : {}),
362
+ ...(options.loadConfig ? { loadConfig: options.loadConfig } : {}),
363
+ };
364
+
365
+ // A named Worker narrows **before** the load (#455). Resolving the whole set first meant one unloadable
366
+ // config disabled `pithy add` and `pithy remove` for every Worker in the project — including when editing
367
+ // a healthy Worker was the way around the broken one. `resolveWorkers` already supports the narrowing,
368
+ // and its own "No worker named" error builds the `Known:` list from discovery, which needs no load.
369
+ if (options.worker !== undefined) {
370
+ const [found] = await resolveWorkers({ projectDir: options.projectDir, worker: options.worker, ...seams });
371
+ // Unreachable: the single-Worker path either resolves one or throws. Narrowed for the type, not the case.
372
+ if (!found) throw new NotFoundError({ message: `No worker named "${options.worker}".` });
373
+ return found;
374
+ }
375
+
376
+ // With none named, the *set* is the question — how many Workers there are decides between using the only
377
+ // one, prompting, and refusing — so every config is loaded, and one that will not load is still fatal.
378
+ // Guessing past it would wire bindings and Durable Object class migrations onto the wrong script.
379
+ const workers = await resolveWorkers({ projectDir: options.projectDir, ...seams });
380
+
381
+ const only = workers[0];
382
+ if (workers.length === 1 && only) return only;
383
+
384
+ if (options.prompt) {
385
+ const chosen = await options.prompt(workers);
386
+ const found = workers.find((candidate) => candidate.name === chosen);
387
+ if (found) return found;
388
+ }
389
+
390
+ throw new ValidationError({
391
+ message: "This project has several workers, so which one to wire is ambiguous.",
392
+ action: `Pass --worker <name>. Known: ${workers.map((w) => w.name).join(", ")}.`,
393
+ });
394
+ }
@@ -0,0 +1,86 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { readdir, readFile, stat } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+ import { parse } from "comment-json";
7
+ import { defaultWorkerDev, parseWorkerManifest, WORKER_MANIFEST_FILE, type WorkerDev } from "./workerManifest";
8
+
9
+ /** A discovered Worker: its deploy name, its directory, its dev-set settings, and whether it is a real Worker. */
10
+ export interface WorkerTarget {
11
+ /** The Worker's name — its `wrangler.jsonc` `name`, or the directory when that is absent. */
12
+ name: string;
13
+ /** The directory holding the Worker's `wrangler.jsonc`/`pithy.worker.jsonc` — the `cwd` for wrangler. */
14
+ dir: string;
15
+ /**
16
+ * How `pithy dev` runs this worker locally, from `pithy.worker.jsonc` (or a synthesized default). Real
17
+ * discovery always sets it; it is optional so the many test doubles that only need `name`/`dir` stay valid.
18
+ */
19
+ dev?: WorkerDev;
20
+ /** Whether the directory holds a `wrangler.jsonc`. `false` → a non-Worker process (deploy skips it). */
21
+ hasWrangler?: boolean;
22
+ }
23
+
24
+ /** Just the `name` field of a `wrangler.jsonc` — the Worker's deployed name. */
25
+ interface NamedWrangler {
26
+ name?: string;
27
+ }
28
+
29
+ /** True if `dir` holds a file named `file`. */
30
+ async function hasFile(dir: string, file: string): Promise<boolean> {
31
+ try {
32
+ return (await stat(join(dir, file))).isFile();
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ /** The Worker's name from its `wrangler.jsonc`, falling back to `fallback` if unreadable or unnamed. */
39
+ async function workerName(dir: string, fallback: string): Promise<string> {
40
+ try {
41
+ const config = parse(await readFile(join(dir, "wrangler.jsonc"), "utf8")) as unknown as NamedWrangler;
42
+ return config.name ?? fallback;
43
+ } catch {
44
+ return fallback;
45
+ }
46
+ }
47
+
48
+ /** Build a {@link WorkerTarget} for `dir`, reading its manifest (or synthesizing a default dev block). */
49
+ async function toTarget(dir: string, fallbackName: string, hasWrangler: boolean): Promise<WorkerTarget> {
50
+ const manifest = await parseWorkerManifest(dir);
51
+ return {
52
+ name: hasWrangler ? await workerName(dir, fallbackName) : fallbackName,
53
+ dir,
54
+ dev: manifest?.dev ?? defaultWorkerDev(),
55
+ hasWrangler,
56
+ };
57
+ }
58
+
59
+ /**
60
+ * The project's Workers. `apps/` **is** the registry (`docs/CLI.md` §6): every `apps/<name>/` carrying a
61
+ * `pithy.worker.jsonc` **or** a `wrangler.jsonc` is one Worker. Discovery keys on `pithy.worker.jsonc` so a
62
+ * non-Worker process (a Vite frontend with no `wrangler.jsonc`) can still join the dev set; a `wrangler.jsonc`
63
+ * with no manifest is still discovered with a synthesized autostart dev block.
64
+ *
65
+ * **There is no root Worker.** Every Worker lives in `apps/<name>/` with its own `wrangler.jsonc` and its own
66
+ * `pithy.config.ts`, so capabilities, bindings, and Durable Object class migrations attach to the Worker that
67
+ * actually owns them. `dev`, `deploy`, `migrate`, `seed`, `upgrade`, and `doctor` all discover the set this
68
+ * way — no hand-kept list, and nothing special-cases the project root.
69
+ */
70
+ export async function discoverWorkers(projectDir: string): Promise<WorkerTarget[]> {
71
+ let entries: string[] = [];
72
+ try {
73
+ entries = await readdir(join(projectDir, "apps"));
74
+ } catch {
75
+ entries = [];
76
+ }
77
+
78
+ const workers: WorkerTarget[] = [];
79
+ for (const entry of entries) {
80
+ const dir = join(projectDir, "apps", entry);
81
+ const hasManifest = await hasFile(dir, WORKER_MANIFEST_FILE);
82
+ const hasWrangler = await hasFile(dir, "wrangler.jsonc");
83
+ if (hasManifest || hasWrangler) workers.push(await toTarget(dir, entry, hasWrangler));
84
+ }
85
+ return workers.sort((a, b) => a.name.localeCompare(b.name));
86
+ }