@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,129 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { CloudflareClients } from "@pithy-sh/cloudflare/src/client/clients";
5
+ import type { CfSecretsStore } from "@pithy-sh/cloudflare/src/secrets/secretsStores";
6
+ import {
7
+ type CloudflareConfigOptions,
8
+ cloudflareConfigPath,
9
+ describeCloudflareAccountMismatch,
10
+ resolveCloudflare,
11
+ writeCloudflareConfig,
12
+ } from "./config";
13
+
14
+ /**
15
+ * Resolve the account's Secrets Store id **once, at provisioning time**, and record it in
16
+ * `<config>/cloudflare.json` (#182).
17
+ *
18
+ * **This is the one network call in the store id's whole life.** Discovery at read time was considered
19
+ * and rejected: avoiding an API call per invocation would mean caching the resolved id in a file, which
20
+ * is the config key again with a staleness question and a network failure mode added on top. So the key
21
+ * stays, and what this buys is that nobody has to find it in the dashboard and paste it. `pithy init`
22
+ * writes the credential pair at the one moment the operator is holding both; this completes the file.
23
+ *
24
+ * **One store per account, so the answer is a lookup rather than a choice.** Cloudflare permits one, and
25
+ * that is what makes an automatic write defensible at all. Two is therefore a state this refuses rather
26
+ * than resolves — guessing which store holds an adopter's production secrets is not a choice a tool gets
27
+ * to make silently — and zero is a state it explains.
28
+ *
29
+ * **Nothing here can fail `pithy add secrets`.** Every outcome is a sentence, never a throw. The command's
30
+ * job is to wire the capability; recording the store id is a convenience on top of it, and an adopter
31
+ * whose network is down, whose token lacks `secrets:read`, or who has no store yet can still export
32
+ * `SECRETS_STORE_ID` and carry on. A convenience that can fail the command it rides on is not one.
33
+ *
34
+ * **It never overwrites a recorded id.** A file that already names a store is an answer somebody gave;
35
+ * if the account disagrees, that is worth a sentence and is not worth acting on — the recorded id may be
36
+ * the deliberate one, and replacing it would point every later `pithy secrets set` at another store.
37
+ */
38
+
39
+ /** What {@link ensureSecretsStoreId} needs. Both seams default to the real account and the real config. */
40
+ export interface EnsureSecretsStoreIdOptions {
41
+ /**
42
+ * Where the Pithy config directory is, and **which account's file inside it**.
43
+ *
44
+ * Required, because the account is: one store per account means the store id belongs to whichever
45
+ * account the project uses, and a default would be a guess about the most consequential part.
46
+ */
47
+ paths: CloudflareConfigOptions;
48
+ /**
49
+ * Seam: list the account's Secrets Stores. Defaults to the real REST call. A test passing this never
50
+ * reaches Cloudflare — and the default is what makes "no credentials" a decision this module owns
51
+ * rather than one every caller repeats.
52
+ */
53
+ listStores?: (credentials: { accountId: string; apiToken: string }) => Promise<CfSecretsStore[]>;
54
+ }
55
+
56
+ /** The lines `pithy add secrets` prints for the store id. Empty when there was nothing worth saying. */
57
+ export async function ensureSecretsStoreId(options: EnsureSecretsStoreIdOptions): Promise<string[]> {
58
+ const paths = options.paths;
59
+ // Resolved rather than read: a project that pins `cloudflare.accountId` and finds credentials for a
60
+ // different account must get a sentence here, not the throw `cloudflareEnv` raises. Recording the store
61
+ // id is a convenience riding on `pithy add secrets`, and a convenience that fails its command is not
62
+ // one — while writing another account's store id into this project's file would be worse than either.
63
+ const resolution = resolveCloudflare(paths);
64
+ if (resolution.mismatch) {
65
+ return [
66
+ `${describeCloudflareAccountMismatch(resolution.mismatch)} SECRETS_STORE_ID was not recorded — a store id belongs to the account the project claims.`,
67
+ ];
68
+ }
69
+ const vars = resolution.vars;
70
+ const accountId = vars.CLOUDFLARE_ACCOUNT_ID ?? "";
71
+ const apiToken = vars.CLOUDFLARE_API_TOKEN ?? "";
72
+ const recorded = vars.SECRETS_STORE_ID ?? "";
73
+
74
+ // No credentials is not a fault here. `pithy add secrets` on a project that has not been pointed at an
75
+ // account yet is an ordinary thing to do, and `pithy doctor` says the credentials are absent every run.
76
+ if (!accountId || !apiToken) {
77
+ if (recorded) return [];
78
+ return [
79
+ `SECRETS_STORE_ID is not recorded, and there are no Cloudflare credentials to resolve it with. Run pithy init, then pithy add secrets again.`,
80
+ ];
81
+ }
82
+
83
+ const stores = await listStoresOrNull(options, { accountId, apiToken });
84
+ if (stores === null) {
85
+ if (recorded) return [];
86
+ return [
87
+ `Could not reach Cloudflare to resolve SECRETS_STORE_ID, so it was not recorded. Run pithy add secrets again, or set SECRETS_STORE_ID in ${cloudflareConfigPath(paths)}.`,
88
+ ];
89
+ }
90
+
91
+ if (stores.length === 0) {
92
+ if (recorded) return [];
93
+ return [
94
+ "This account has no Secrets Store yet, so SECRETS_STORE_ID was not recorded. Create one in the Cloudflare dashboard (Secrets Store), then run pithy add secrets again.",
95
+ ];
96
+ }
97
+
98
+ // Named, never picked. The account is supposed to have one; two means somebody made a second, and
99
+ // which of them holds the live secrets is a question only they can answer.
100
+ if (stores.length > 1) {
101
+ const named = stores.map((store) => `${store.name} (${store.id})`).join(", ");
102
+ return [
103
+ `This account has more than one Secrets Store — ${named} — so SECRETS_STORE_ID was not recorded. Set the one you mean in ${cloudflareConfigPath(paths)}.`,
104
+ ];
105
+ }
106
+
107
+ const store = stores[0];
108
+ if (store === undefined) return [];
109
+ if (recorded) {
110
+ if (recorded === store.id) return [];
111
+ // Reported, never corrected. The recorded id may be the deliberate one, and replacing it would point
112
+ // every later `pithy secrets set` at another store without anyone asking for that.
113
+ return [
114
+ `SECRETS_STORE_ID is recorded as ${recorded}, and this account's only Secrets Store is ${store.name} (${store.id}). Nothing was changed — fix it in ${cloudflareConfigPath(paths)} if the recorded one is wrong.`,
115
+ ];
116
+ }
117
+
118
+ await writeCloudflareConfig({ SECRETS_STORE_ID: store.id }, paths);
119
+ return [`Recorded SECRETS_STORE_ID (${store.name}) in ${cloudflareConfigPath(paths)}. Nothing resolves it again.`];
120
+ }
121
+
122
+ /** The account's stores, or `null` when the account could not be asked. Never throws. */
123
+ async function listStoresOrNull(
124
+ options: EnsureSecretsStoreIdOptions,
125
+ credentials: { accountId: string; apiToken: string },
126
+ ): Promise<CfSecretsStore[] | null> {
127
+ const list = options.listStores ?? ((creds) => new CloudflareClients(creds).secretsStores().listStores());
128
+ return list(credentials).catch(() => null);
129
+ }
@@ -0,0 +1,372 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { join, relative } from "node:path";
5
+ import { CloudflareClients } from "@pithy-sh/cloudflare/src/client/clients";
6
+ import type { Capability } from "@pithy-sh/core/src/capability/capability";
7
+ import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
8
+ import { defineCommand } from "citty";
9
+ import { type CliAuditEmit, type CreateCliAuditOptions, createRemoteCliAudit } from "../audit/cliAudit";
10
+ import type { ConfigValue } from "../capabilities/add";
11
+ import { buildCatalogListing } from "../capabilities/catalog";
12
+ import {
13
+ type ConfigPrompt,
14
+ coerceConfigValue,
15
+ collectSetFlags,
16
+ isHandWritten,
17
+ type PrerequisitePrompt,
18
+ runAdd,
19
+ } from "../capabilities/flow";
20
+ import { availableManifests } from "../capabilities/manifests";
21
+ import { requiredOptionRefusal } from "../capabilities/requiredOptions";
22
+ import { type CloudflareAccountSelection, cloudflareEnv } from "../cloudflare/config";
23
+ import type { DatabaseRun } from "../migrations/run";
24
+ import { loadProject, projectCloudflareAccount, requireProjectName } from "../project/config";
25
+ import { type ResolvedWorker, type ResolveOptions, resolveSingleWorker } from "../project/workerScope";
26
+ import { formatDone, formatJsonLine, formatList, withErrorReporting } from "../terminal/output";
27
+
28
+ /** What {@link buildAudit} needs, plus the factory seam a test observes. */
29
+ export interface BuildAuditOptions {
30
+ /**
31
+ * The **project root** — the parent of `apps/`. Both halves of the lookup hang off it: the shared
32
+ * `.dev.vars` holding the Cloudflare credentials, and the `apps/*` scan that resolves the audit
33
+ * database. Passing a Worker directory here finds no `apps/`, so nothing is ever recorded.
34
+ */
35
+ projectDir: string;
36
+ /** The target Worker's name — narrows the audit-database lookup to the Worker being wired. */
37
+ worker: string;
38
+ /** The environment the record lands in. */
39
+ env: string;
40
+ /** The target Worker's capabilities — auditing is wired only when `audit` is among them. */
41
+ capabilities: Capability[];
42
+ /**
43
+ * The Cloudflare account this project belongs to, from `projectCloudflareAccount(projectDir)`, or
44
+ * `null` when it names none. Required rather than defaulted: an audit written against the wrong
45
+ * account is a record of this project's work in another company's database (#206).
46
+ */
47
+ account: CloudflareAccountSelection | null;
48
+ /** Audit factory seam (default: {@link createRemoteCliAudit}). */
49
+ create?: (options: CreateCliAuditOptions) => Promise<CliAuditEmit>;
50
+ }
51
+
52
+ /**
53
+ * The audit emitter for the wiring commands, `pithy add` and `pithy remove`. Both act on **one** Worker
54
+ * in a project, so the audit database is looked up from the project root and narrowed to that Worker.
55
+ * `add` has no environment concept — a capability is wired into config, not deployed — so it passes
56
+ * `"dev"`, which makes its emitter inert by design; `remove` passes the env its `--drop` destroys.
57
+ *
58
+ * A no-op when Cloudflare creds or the audit capability aren't there (which is always true the very
59
+ * first time `pithy add audit` itself runs — nothing can audit-log its own installation).
60
+ */
61
+ export async function buildAudit(options: BuildAuditOptions): Promise<CliAuditEmit> {
62
+ const { projectDir, worker, env, capabilities, account } = options;
63
+ const create = options.create ?? createRemoteCliAudit;
64
+ const vars = cloudflareEnv({ account });
65
+ const accountId = vars.CLOUDFLARE_ACCOUNT_ID ?? "";
66
+ const apiToken = vars.CLOUDFLARE_API_TOKEN ?? "";
67
+ if (!accountId || !apiToken) return async () => {};
68
+ return create({
69
+ projectDir,
70
+ worker,
71
+ env,
72
+ capabilities,
73
+ clients: new CloudflareClients({ accountId, apiToken }),
74
+ apiToken,
75
+ });
76
+ }
77
+
78
+ /**
79
+ * Pick the Worker interactively when a project has several and none was named. Supplied only when a
80
+ * human is attached — an agent run (`--json`, no TTY) gets the actionable error from
81
+ * {@link resolveSingleWorker} instead of a prompt it cannot answer.
82
+ */
83
+ export async function promptWorker(choices: ResolvedWorker[]): Promise<string> {
84
+ const { isCancel, select } = await import("@clack/prompts");
85
+ const answer = await select({
86
+ message: "Which worker?",
87
+ options: choices.map((choice) => ({ value: choice.name, label: choice.name })),
88
+ });
89
+ if (isCancel(answer)) {
90
+ process.stderr.write("Canceled.\n");
91
+ process.exit(1);
92
+ }
93
+ return answer as string;
94
+ }
95
+
96
+ /** Options for {@link targetWorker}: the project, the `--worker` value, and whether a human is attached. */
97
+ export interface TargetWorkerOptions extends ResolveOptions {
98
+ /** The `--worker` value, when one was passed. */
99
+ worker?: string;
100
+ /** True only at a real terminal without `--json` — the one condition a prompt is answerable. */
101
+ interactive: boolean;
102
+ }
103
+
104
+ /**
105
+ * The Worker a wiring command acts on — shared by `pithy add` and `pithy remove`, which must resolve it
106
+ * the same way. `--worker` names it; a single-Worker project needs no ceremony; several Workers prompt a
107
+ * human and raise an actionable error for anyone else. The prompt is attached **only** when a human is
108
+ * attached, so an agent driving `--json` is told what to pass instead of hanging on a question.
109
+ */
110
+ export function targetWorker(options: TargetWorkerOptions): Promise<ResolvedWorker> {
111
+ const { interactive, worker, ...resolve } = options;
112
+ return resolveSingleWorker({
113
+ ...resolve,
114
+ ...(worker === undefined ? {} : { worker }),
115
+ ...(interactive ? { prompt: promptWorker } : {}),
116
+ });
117
+ }
118
+
119
+ /**
120
+ * `pithy add --list`: the built-in catalog, with installed capabilities marked.
121
+ *
122
+ * A package whose manifest is present and unusable is named, with the reason. It used to be dropped in
123
+ * silence — the capability was simply absent from the listing, and the command an adopter runs *because*
124
+ * something is missing was the command that said nothing about it (#184). Reported rather than refused:
125
+ * one broken package must not cost the adopter the other fifteen entries.
126
+ */
127
+ async function listCapabilities(projectDir: string, json: boolean): Promise<void> {
128
+ const { manifests, faults } = await availableManifests(projectDir);
129
+ const listing = buildCatalogListing(new Set(manifests.map((manifest) => manifest.name)));
130
+ if (json) {
131
+ process.stdout.write(`${formatJsonLine({ command: "add", capabilities: listing, manifestFaults: faults })}\n`);
132
+ return;
133
+ }
134
+ const rows = listing.map((entry) => ({
135
+ name: entry.name,
136
+ description: entry.installed ? `${entry.whenToEnable} (installed)` : entry.whenToEnable,
137
+ }));
138
+ process.stdout.write(`${formatList(rows)}\n`);
139
+ for (const fault of faults) {
140
+ process.stderr.write(`${fault.package} ships a malformed pithy.manifest.json. Not listed.\n${fault.reason}\n`);
141
+ }
142
+ }
143
+
144
+ /**
145
+ * The project name `add` works under — resolved **here**, at the command edge, and handed to
146
+ * {@link runAdd} as a plain string. It does two jobs: it leads every resource name `add` proposes, and
147
+ * it claims the database `add`'s closing dev migration writes to.
148
+ *
149
+ * `requireProjectName`, never `resolveProjectName`: the lenient resolver's fallbacks (the
150
+ * alphabetically-first worker, then the directory basename) differ between machines and checkouts, so a
151
+ * later command would recompute a different name for the same resource — and stamp a different owner on
152
+ * the same database. It throws rather than guessing, and it throws **here**, before the package is
153
+ * installed and the Worker is wired, so a nameless project is told to set `name` instead of being left
154
+ * half-configured around an unowned database.
155
+ */
156
+ function proposalProject(projectDir: string): Promise<string> {
157
+ return loadProject(projectDir).then(requireProjectName);
158
+ }
159
+
160
+ /** One line per database migrated, brand-voiced (docs/CLI.md §3). */
161
+ function describeRun(run: DatabaseRun): string {
162
+ return run.results.length === 0
163
+ ? `${run.database}: nothing to apply.`
164
+ : `${run.database}: ${run.results.length} applied.`;
165
+ }
166
+
167
+ /**
168
+ * The first entry offered for a **required** closed-set option, and the one `select` opens on.
169
+ *
170
+ * `@clack/prompts`' `select` has no unselected state: it highlights its first option and enter accepts it.
171
+ * So "offer no `initialValue`" does not mean "nothing is pre-committed" — it means the first choice is, and
172
+ * for `billingSubject` that is enter silently picking a billing model, which is the exact defect #412
173
+ * exists to close. An unanswerable first entry is what actually gives the prompt no default: enter lands
174
+ * here, and here refuses.
175
+ *
176
+ * The empty string cannot collide with a real choice — `ConfigOption.choices` are non-empty printable
177
+ * strings — so it is unambiguous as a sentinel.
178
+ */
179
+ const UNANSWERED_CHOICE = "";
180
+
181
+ /**
182
+ * Fill un-set options interactively — a human-attached run prompts from the manifest.
183
+ *
184
+ * **What the option states decides what it is asked with.** A closed set is a `select` over its choices,
185
+ * because a free-text field that only accepts three answers is a quiz. And an option with **no default** is
186
+ * offered nothing to accept, in either shape: a `select` opens on {@link UNANSWERED_CHOICE}, and a `text`
187
+ * gets no `defaultValue` and no `placeholder`. Both then refuse an unanswered enter with the same error a
188
+ * `--json` run gets, naming the same flag — so the guarantee is one guarantee rather than one per transport.
189
+ *
190
+ * It used to hand `String(option.default)` to every prompt, which is how pressing enter picked a billing
191
+ * model (#412).
192
+ */
193
+ const promptConfigValues: ConfigPrompt = async (manifest, provided) => {
194
+ const { isCancel, select, text } = await import("@clack/prompts");
195
+ const values: Record<string, ConfigValue> = { ...provided };
196
+ for (const option of manifest.configOptions) {
197
+ if (option.key in values) continue;
198
+ // Left as the manifest scaffolds it. A secrets registry is not something anyone types at a prompt,
199
+ // and the fallback offered would have been the string "[object Object]".
200
+ if (isHandWritten(option)) continue;
201
+ const message = `${option.key} — ${option.describe}`;
202
+ const required = option.default === undefined;
203
+ const answer = option.choices
204
+ ? await select({
205
+ message,
206
+ options: [
207
+ // Only for a required option: an optional one has a default that enter should accept, which is
208
+ // the whole point of having one.
209
+ ...(required ? [{ value: UNANSWERED_CHOICE, label: "Choose one" }] : []),
210
+ ...option.choices.map((choice) => ({ value: choice, label: choice })),
211
+ ],
212
+ ...(typeof option.default === "string" ? { initialValue: option.default } : {}),
213
+ })
214
+ : await text({
215
+ message,
216
+ // Absent for a required option, both of them: `defaultValue` is what enter accepts and
217
+ // `placeholder` is what says so. There is nothing here to accept.
218
+ ...(option.default === undefined
219
+ ? {}
220
+ : { defaultValue: String(option.default), placeholder: String(option.default) }),
221
+ });
222
+ if (isCancel(answer)) {
223
+ process.stderr.write("Canceled.\n");
224
+ process.exit(1);
225
+ }
226
+ // An unanswered required option is refused rather than written. `text` returns "" for a bare enter and
227
+ // `select` returns the sentinel, and both mean the same thing: nobody chose. Writing either would put
228
+ // an empty key into the adopter's config for them to find later — which is worse than the question.
229
+ if (required && (answer === UNANSWERED_CHOICE || answer === "")) {
230
+ throw requiredOptionRefusal({ capability: manifest.name, missing: [option] });
231
+ }
232
+ values[option.key] = coerceConfigValue(option, answer, manifest.name);
233
+ }
234
+ return values;
235
+ };
236
+
237
+ /**
238
+ * Ask whether to compose a capability's prerequisites too — one question for the whole cascade.
239
+ *
240
+ * Attached only when a human is attached, exactly like the Worker picker above it. Declining is a
241
+ * refusal, not a silent partial add: the caller raises the error that names the commands, so a `no` here
242
+ * and a non-interactive run reach the same place by different roads.
243
+ */
244
+ const promptPrerequisites: PrerequisitePrompt = async ({ capability, missing }) => {
245
+ const { confirm, isCancel } = await import("@clack/prompts");
246
+ const one = missing.length === 1;
247
+ const answer = await confirm({
248
+ message: `${capability} requires ${missing.join(", ")}. Compose ${one ? "it" : "them"} too?`,
249
+ });
250
+ if (isCancel(answer)) {
251
+ process.stderr.write("Canceled.\n");
252
+ process.exit(1);
253
+ }
254
+ return answer === true;
255
+ };
256
+
257
+ export default defineCommand({
258
+ meta: { name: "add", description: "Add a capability" },
259
+ args: {
260
+ // Optional so `pithy add --list` runs without a capability.
261
+ capability: { type: "positional", required: false, description: "Capability name, e.g. auth" },
262
+ list: { type: "boolean", default: false, description: "List the capabilities you can add" },
263
+ worker: { type: "string", description: "Which worker to wire it into (apps/<name>)" },
264
+ set: { type: "string", description: "Override a config option: --set key=value (repeatable)" },
265
+ eject: {
266
+ type: "boolean",
267
+ default: false,
268
+ description: "Copy the capability's source into your repo and own it (no upgrades)",
269
+ },
270
+ force: {
271
+ type: "boolean",
272
+ default: false,
273
+ description: "With --eject, overwrite an existing local copy (discards edits)",
274
+ },
275
+ "with-prerequisites": {
276
+ type: "boolean",
277
+ default: false,
278
+ description: "Compose the capabilities this one requires, if they aren't composed yet",
279
+ },
280
+ json: { type: "boolean", default: false, description: "Machine-readable output" },
281
+ },
282
+ // `ctx` over `{ args }`: repeated `--set` only survives in rawArgs (citty keeps
283
+ // the last value of a repeated string flag), so collectSetFlags reads it there.
284
+ run: ({ args, rawArgs }) =>
285
+ withErrorReporting(args.json, async () => {
286
+ const projectDir = process.cwd();
287
+ if (args.list) {
288
+ await listCapabilities(projectDir, args.json);
289
+ return;
290
+ }
291
+ if (!args.capability) {
292
+ throw new ValidationError({
293
+ message: "Name a capability to add.",
294
+ action: "Run pithy add --list to see what's available.",
295
+ });
296
+ }
297
+
298
+ const interactive = !args.json && Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY);
299
+ // Which Worker gets the wiring. One worker needs no ceremony; several never guess.
300
+ const target = await targetWorker({
301
+ projectDir,
302
+ interactive,
303
+ ...(args.worker === undefined ? {} : { worker: args.worker }),
304
+ });
305
+
306
+ // Resolved once and used three times over: the store id `add secrets` records, the migrate it
307
+ // finishes with, and the audit trail. Every one of them belongs to the account this project names
308
+ // rather than to whichever `cloudflare.json` the machine happens to hold (#234).
309
+ const account = await projectCloudflareAccount(projectDir);
310
+
311
+ const result = await runAdd({
312
+ projectDir,
313
+ workerDir: target.dir,
314
+ worker: target.name,
315
+ project: await proposalProject(projectDir),
316
+ account,
317
+ capability: args.capability,
318
+ setFlags: collectSetFlags(rawArgs),
319
+ prompt: interactive ? promptConfigValues : undefined,
320
+ withPrerequisites: args["with-prerequisites"],
321
+ askPrerequisites: interactive ? promptPrerequisites : undefined,
322
+ eject: args.eject,
323
+ force: args.force,
324
+ audit: await buildAudit({
325
+ projectDir,
326
+ account,
327
+ worker: target.name,
328
+ env: "dev",
329
+ capabilities: target.capabilities,
330
+ }),
331
+ });
332
+
333
+ if (args.json) {
334
+ process.stdout.write(`${formatJsonLine({ command: "add", ...result })}\n`);
335
+ return;
336
+ }
337
+ // Said first, because it happened first, and because a capability composed on the adopter's behalf
338
+ // is the one thing in this output they did not type.
339
+ if (result.prerequisites.length > 0) {
340
+ process.stdout.write(
341
+ `Composed ${result.prerequisites.join(", ")} into ${result.worker} first — ${result.capability} requires ${result.prerequisites.length === 1 ? "it" : "them"}.\n`,
342
+ );
343
+ }
344
+ process.stdout.write(`Wired ${result.capability} into ${result.worker}.\n`);
345
+ for (const run of result.databases) {
346
+ process.stdout.write(`${describeRun(run)}\n`);
347
+ }
348
+ // KV titles are printed rather than written: a `kv_namespaces` entry has no title field, so the
349
+ // only place the name can land is the account. D1's went into wrangler.jsonc as `database_name`.
350
+ for (const kv of result.kvNamespaces) {
351
+ process.stdout.write(`Name the ${kv.binding} namespace for ${kv.env}: ${kv.name}\n`);
352
+ }
353
+ // What add finished off-config, and what only a provision command can supply. Printed here, at
354
+ // the moment the adopter is thinking about the capability — not left to the first 500.
355
+ for (const note of result.notes) {
356
+ process.stdout.write(`${note}\n`);
357
+ }
358
+ if (result.eject) {
359
+ // The fork lands beside the Worker's config, so report it project-relative: apps/<worker>/capabilities/<cap>.
360
+ const path = relative(projectDir, join(target.dir, result.eject.path));
361
+ process.stdout.write(
362
+ `Ejected ${result.capability} into ${path}/. It's yours now — ${result.package} no longer upgrades it.\n`,
363
+ );
364
+ if (result.eject.promotedDependencies.length > 0) {
365
+ process.stdout.write(
366
+ `Promoted ${result.eject.promotedDependencies.length} dependencies. ${result.package} is safe to remove.\n`,
367
+ );
368
+ }
369
+ }
370
+ process.stdout.write(`${formatDone()}\n`);
371
+ }),
372
+ });