@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,174 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { basename } from "node:path";
5
+ import { kebab } from "@pithy-sh/core/src/naming/resource";
6
+ import { loadProject } from "../project/config";
7
+ import { discoverWorkers, type WorkerTarget } from "../project/workers";
8
+ import { readWranglerConfig } from "../project/wrangler";
9
+
10
+ /**
11
+ * Whether a Worker's three names still agree.
12
+ *
13
+ * **A Worker's name is not one string.** It is stamped in three places, and every one of them is read by
14
+ * something different:
15
+ *
16
+ * - the directory, `apps/<name>/`, which tsconfig references and CI working-directories point at;
17
+ * - the deployed script name in `wrangler.jsonc`, which is what Cloudflare serves it under;
18
+ * - `vars.WORKER`, which is what separates two Workers' audit events when they share a database.
19
+ *
20
+ * `pithy worker rename` moves all three together. A rename done by hand — `git mv`, then the edits —
21
+ * moves whichever the person remembered, and nothing until now noticed the rest. The failure is quiet and
22
+ * it is the worst shape a failure can take: the Worker deploys under one name and stamps its events with
23
+ * another, so the audit trail names a Worker that is not the one that acted.
24
+ *
25
+ * This is the counterpart to {@link checkProjectName}, one level down, and it is held to the same
26
+ * evidence discipline: a name is only a fault when this repo's own files positively contradict each
27
+ * other. An adopter who migrated an existing Worker in has a script name Pithy never composed, and that
28
+ * is theirs.
29
+ */
30
+ export type WorkerNameState =
31
+ /** Every stamp this project declares agrees with the directory it sits in. */
32
+ | "ok"
33
+ /** A `wrangler.jsonc` would not parse, or the worker set would not enumerate. Never fails the exit. */
34
+ | "could-not-check"
35
+ /** A stamp contradicts the directory. Established from local files alone, and it fails the exit. */
36
+ | "drifted";
37
+
38
+ /** One stamp that disagrees with the directory holding it. */
39
+ export interface WorkerNameMismatch {
40
+ /** The `apps/<dir>` basename — the anchor, since tsconfig and CI point at the path, not at a config key. */
41
+ worker: string;
42
+ /** Which stamp disagrees: the deployed script name, or the `WORKER` var. */
43
+ stamp: "name" | "vars.WORKER";
44
+ /** The value the file declares. */
45
+ declared: string;
46
+ /** The value the directory implies. */
47
+ expected: string;
48
+ /**
49
+ * The environment stanzas declaring it — `dev` for the top-level one. Empty for the script name, which
50
+ * is one top-level key rather than a per-environment one.
51
+ */
52
+ envs: string[];
53
+ }
54
+
55
+ /** What `doctor` learned about this project's worker names. */
56
+ export interface WorkerNameCheck {
57
+ state: WorkerNameState;
58
+ mismatches: WorkerNameMismatch[];
59
+ }
60
+
61
+ /** The `wrangler.jsonc` keys this reads: the script name, and the `WORKER` var in every stanza. */
62
+ interface NamedWorkerConfig {
63
+ name?: string;
64
+ vars?: Record<string, string | undefined>;
65
+ env?: Record<string, NamedWorkerConfig | undefined>;
66
+ }
67
+
68
+ /** Every environment's stanza: the top-level one (the dev environment) plus each `env.<name>`. */
69
+ function envStanzas(config: NamedWorkerConfig): { env: string; stanza: NamedWorkerConfig }[] {
70
+ const list = [{ env: "dev", stanza: config }];
71
+ for (const [env, stanza] of Object.entries(config.env ?? {})) {
72
+ if (stanza) list.push({ env, stanza });
73
+ }
74
+ return list;
75
+ }
76
+
77
+ /**
78
+ * The script-name mismatch for one Worker, or `null` when there is nothing to say.
79
+ *
80
+ * **Shape is the filter, exactly as it is for the project name.** `<project>-<worker>` is what
81
+ * `scaffoldWorker` writes, so a declared name leading with this project's own segment was composed by
82
+ * Pithy's rule and its tail is a worker segment — a tail that is not the directory is this repo
83
+ * contradicting itself. A name that does not lead with the project (`my-service`, brought in from a
84
+ * Worker that predates the project) was never composed from anything, so nothing local establishes what
85
+ * it ought to be, and a check that renamed it would be inventing a fault on the adoption path.
86
+ */
87
+ function scriptNameMismatch(worker: string, declared: string | undefined, project: string | null): boolean {
88
+ if (declared === undefined || project === null) return false;
89
+ return declared.startsWith(`${project}-`) && declared !== `${project}-${worker}`;
90
+ }
91
+
92
+ /** Group the per-environment `WORKER` disagreements by the value they declare — one line per wrong value. */
93
+ function workerVarMismatches(worker: string, config: NamedWorkerConfig): WorkerNameMismatch[] {
94
+ const byValue = new Map<string, string[]>();
95
+ for (const { env, stanza } of envStanzas(config)) {
96
+ const declared = stanza.vars?.WORKER;
97
+ // A stanza that declares no `WORKER` declares nothing to contradict. Adding one is `pithy worker`'s
98
+ // job, not a diagnostic's, and reporting its absence would fail every Worker written before the var.
99
+ if (declared === undefined || declared === worker) continue;
100
+ byValue.set(declared, [...(byValue.get(declared) ?? []), env]);
101
+ }
102
+ return [...byValue].map(([declared, envs]) => ({
103
+ worker,
104
+ stamp: "vars.WORKER" as const,
105
+ declared,
106
+ expected: worker,
107
+ envs,
108
+ }));
109
+ }
110
+
111
+ /** The configured project name, or `null` when the root config would not read or carries none. */
112
+ async function configuredProject(projectDir: string): Promise<string | null> {
113
+ try {
114
+ const config = await loadProject(projectDir);
115
+ // Kebabed through core's own helper, because that is the form `scaffoldWorker` stamped into the
116
+ // script name. An illegal name is not refused here: `checkProjectName` is the check that owns that
117
+ // verdict, and two blocks reporting one fault is how a report starts contradicting itself.
118
+ return config.name ? kebab(config.name) : null;
119
+ } catch {
120
+ return null;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Check every Worker under `apps/` for the three-way disagreement a hand-rename leaves.
126
+ *
127
+ * Never throws. A diagnostic has to work in the broken project it exists to diagnose, so an unreadable
128
+ * `wrangler.jsonc` becomes `could-not-check` rather than an exception — and only when nothing else was
129
+ * found. A mismatch this read is a fault whether or not the next file parsed; degrading a positive
130
+ * finding into "I could not check" would hide the fault behind the noise.
131
+ */
132
+ export async function checkWorkerNames(projectDir: string): Promise<WorkerNameCheck> {
133
+ let workers: WorkerTarget[];
134
+ try {
135
+ workers = await discoverWorkers(projectDir);
136
+ } catch {
137
+ return { state: "could-not-check", mismatches: [] };
138
+ }
139
+
140
+ const project = await configuredProject(projectDir);
141
+ const mismatches: WorkerNameMismatch[] = [];
142
+ let unreadable = false;
143
+ for (const target of workers) {
144
+ if (!target.hasWrangler) continue; // a non-Worker process in the dev set has no script name at all
145
+ let config: NamedWorkerConfig;
146
+ try {
147
+ config = (await readWranglerConfig(target.dir)) as NamedWorkerConfig;
148
+ } catch {
149
+ unreadable = true;
150
+ continue;
151
+ }
152
+ const worker = basename(target.dir);
153
+ if (scriptNameMismatch(worker, config.name, project) && config.name !== undefined) {
154
+ mismatches.push({
155
+ worker,
156
+ stamp: "name",
157
+ declared: config.name,
158
+ expected: `${project}-${worker}`,
159
+ envs: [],
160
+ });
161
+ }
162
+ mismatches.push(...workerVarMismatches(worker, config));
163
+ }
164
+
165
+ if (mismatches.length > 0) return { state: "drifted", mismatches };
166
+ return { state: unreadable ? "could-not-check" : "ok", mismatches: [] };
167
+ }
168
+
169
+ /** One mismatch in a sentence — what the stamp does with the wrong name, and what the directory says. */
170
+ export function describeWorkerName(mismatch: WorkerNameMismatch): string {
171
+ return mismatch.stamp === "name"
172
+ ? `deploys as ${mismatch.declared}, not ${mismatch.expected}`
173
+ : `stamps events as ${mismatch.declared}, not ${mismatch.expected}`;
174
+ }
@@ -0,0 +1,33 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { readWranglerConfig } from "../project/wrangler";
5
+
6
+ /**
7
+ * Every variable name one Worker's `wrangler.jsonc` declares — the top-level `vars` block **and** every
8
+ * `env.<name>.vars`.
9
+ *
10
+ * **`env.<name>.vars` REPLACES the top-level block rather than merging it**, which is the gotcha the
11
+ * starter's own `wrangler.jsonc` comment warns about: every environment repeats every variable. So a
12
+ * name counts as declared if it appears at the top level **or** in any environment — reading only the
13
+ * top level names a staging-only variable as undeclared, and reading only one environment names every
14
+ * ordinary variable as undeclared.
15
+ *
16
+ * **It lives here rather than in either caller.** Two doctor checks ask this question — what a
17
+ * `.dev.vars.local` key has behind it, and what a root `.dev.vars` key has behind it — and a rule this
18
+ * easy to get half-right is a rule that must exist once. An unreadable or absent config declares
19
+ * nothing, which is the honest answer: the health block is where a `wrangler.jsonc` that will not parse
20
+ * gets said, and louder.
21
+ */
22
+ export async function declaredVars(workerDir: string): Promise<Set<string>> {
23
+ const config = (await readWranglerConfig(workerDir).catch(() => null)) as {
24
+ vars?: Record<string, unknown>;
25
+ env?: Record<string, { vars?: Record<string, unknown> } | undefined>;
26
+ } | null;
27
+ const keys = new Set<string>();
28
+ for (const key of Object.keys(config?.vars ?? {})) keys.add(key);
29
+ for (const environment of Object.values(config?.env ?? {})) {
30
+ for (const key of Object.keys(environment?.vars ?? {})) keys.add(key);
31
+ }
32
+ return keys;
33
+ }
@@ -0,0 +1,93 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { BindingType } from "@pithy-sh/core/src/capability/bindings";
5
+ import type { Capability } from "@pithy-sh/core/src/capability/capability";
6
+ import type { FeatureResourceKind } from "@pithy-sh/core/src/naming/feature";
7
+
8
+ /**
9
+ * A single Cloudflare resource `pithy provision --feature` stands up: the Worker binding it backs and the
10
+ * kind of resource that binding needs. Derived purely from the composed capabilities' `requiredBindings`
11
+ * — no hand-maintained list — so enabling a capability automatically brings its resources into the set.
12
+ */
13
+ export interface ProvisionableBinding {
14
+ /** The Worker binding name (e.g. "DB", "SESSIONS", "ASSETS"). */
15
+ binding: string;
16
+ /** The provisionable resource kind this binding needs. */
17
+ kind: FeatureResourceKind;
18
+ }
19
+
20
+ /**
21
+ * The subset of {@link BindingType}s `pithy feature` provisions as standalone, per-feature resources.
22
+ * D1 databases, KV namespaces, and R2 buckets each get their own ephemeral resource. Every other
23
+ * binding kind is either account-scoped (`ai`), wired in config rather than provisioned (`durable_object`,
24
+ * `service`, `workflow`, `queue`), or owned by another capability's provisioner (`secret`, `email`) — so
25
+ * `provision` skips them. Extend this map (and the CF provisioners) to grow the provisionable set.
26
+ */
27
+ const PROVISIONABLE: Partial<Record<BindingType, FeatureResourceKind>> = {
28
+ d1: "d1",
29
+ kv: "kv",
30
+ r2: "r2",
31
+ };
32
+
33
+ /**
34
+ * The provisionable resources for a set of enabled capabilities: every `d1`/`kv`/`r2` binding they
35
+ * declare, deduplicated by binding name (two capabilities may share one binding — one resource backs it),
36
+ * in a stable order. Non-provisionable binding kinds are dropped. This is the auto-discovered set
37
+ * `provision` creates one CF resource per and `destroy` reconciles against.
38
+ */
39
+ /** A worker-to-worker RPC binding: the env name it appears under, and the Worker it calls. */
40
+ export interface ServiceBinding {
41
+ /** The binding name in the Worker env (e.g. "API"). */
42
+ binding: string;
43
+ /** The target Worker, as named in `apps/<name>/`. */
44
+ target: string;
45
+ }
46
+
47
+ /**
48
+ * Every `service` binding the enabled capabilities declare, deduplicated by binding name. These are not
49
+ * provisioned — the target Worker is deployed, not created — but each one must be *rewritten per
50
+ * environment*: in a feature environment the binding has to point at that feature's own deployment of the
51
+ * target, not at production's. `provision` resolves each target to its environment-scoped script name.
52
+ */
53
+ export function serviceBindings(capabilities: Capability[]): ServiceBinding[] {
54
+ const seen = new Set<string>();
55
+ const result: ServiceBinding[] = [];
56
+ for (const capability of capabilities) {
57
+ for (const spec of capability.requiredBindings) {
58
+ // `service` is guaranteed by BindingSpec's check; the guard keeps this total for a hand-built spec.
59
+ if (spec.type !== "service" || !spec.service || seen.has(spec.name)) continue;
60
+ seen.add(spec.name);
61
+ result.push({ binding: spec.name, target: spec.service });
62
+ }
63
+ }
64
+ return result;
65
+ }
66
+
67
+ export function provisionableBindings(
68
+ capabilities: readonly Capability[],
69
+ declined: ReadonlySet<string> = new Set(),
70
+ ): ProvisionableBinding[] {
71
+ const seen = new Set<string>();
72
+ const result: ProvisionableBinding[] = [];
73
+ for (const capability of capabilities) {
74
+ for (const spec of capability.requiredBindings) {
75
+ const kind = PROVISIONABLE[spec.type];
76
+ if (!kind || seen.has(spec.name)) continue;
77
+ // **A declined binding gets no resource.** Stopping `pithy upgrade` writing the binding while
78
+ // provisioning still created the bucket behind it handed the adopter exactly the resource they had
79
+ // declined (#440). `declined` is resolved once, by `honoredDeclines` in the reconcile engine, so
80
+ // this reads an answer rather than deciding one — a second expression of that rule is how the two
81
+ // would come to disagree.
82
+ //
83
+ // `spec.optional` is asked again anyway, and deliberately. `resolveDeclines` already refuses a
84
+ // decline of a required binding, but this function is reached by a different command through a
85
+ // different path, and a provisioning step that could skip a required resource on a name alone is
86
+ // one bad set away from a project whose database was never created.
87
+ if (spec.optional && declined.has(spec.name)) continue;
88
+ seen.add(spec.name);
89
+ result.push({ binding: spec.name, kind });
90
+ }
91
+ }
92
+ return result;
93
+ }
@@ -0,0 +1,179 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { migrateProject } from "../migrations/run";
5
+ import { loadProject, loadProjectCloudflare, requireProjectName } from "../project/config";
6
+ import { detectPackageManager, type InstallRunner } from "../project/packageManager";
7
+ import type { WorkerTarget } from "../project/workers";
8
+ import { seedProject } from "../seed/run";
9
+ import type { DevConfig } from "./devConfig";
10
+ import { syncFeatureDevConfig } from "./sync";
11
+ import { createWorktree, defaultGit, type GitRunner } from "./worktree";
12
+
13
+ /**
14
+ * `pithy feature create` — the local, automatic half of the lifecycle, run from the main checkout. It
15
+ * creates the branch + worktree (the proven core), reserves a non-overlapping port block and pins one port
16
+ * per worker into the worktree's `.dev.config.json`, installs deps with the adopter's package manager, then
17
+ * migrates and seeds the local Miniflare backend. No Cloudflare spend — a working local env, ready for
18
+ * `pithy dev`, from one command.
19
+ *
20
+ * No `.dev.vars` is wired here, and none is copied. Each Worker's is generated by `pithy dev` from this
21
+ * machine's own sources (#154), so a fresh worktree needs nothing carried into it.
22
+ */
23
+
24
+ /** A local/dev backend step (migrate or seed) — a seam so create is testable without Miniflare. */
25
+ export type LocalRunner = (args: { projectDir: string }) => Promise<void>;
26
+
27
+ // A `dev` migrate names its project too: the local Miniflare store lives at the project root and is
28
+ // shared with `wrangler dev`, and this is usually the run that first stamps it. An unstamped database is
29
+ // one any project can later claim, so the first write is the only chance to record the owner.
30
+ const localMigrate: LocalRunner = async ({ projectDir }) => {
31
+ // One config load, two facts: the name the database is stamped with, and the account it is reached in.
32
+ // Both are read from the **worktree's** own `pithy.config.ts`, because the branch being created is what
33
+ // decides them and it may already differ from the checkout this command was run in (#234).
34
+ const config = await loadProject(projectDir);
35
+ await migrateProject({
36
+ env: "dev",
37
+ projectDir,
38
+ project: requireProjectName(config),
39
+ account: loadProjectCloudflare(config) ?? null,
40
+ });
41
+ };
42
+
43
+ // Even a `dev` seed names its project: Cloudflare Images and Stream have no local emulation, so a media
44
+ // fixture here writes into the same account-wide store production shares, and only the ownership metadata
45
+ // says who put it there.
46
+ const localSeed: LocalRunner = async ({ projectDir }) => {
47
+ const config = await loadProject(projectDir);
48
+ await seedProject({
49
+ env: "dev",
50
+ projectDir,
51
+ project: requireProjectName(config),
52
+ account: loadProjectCloudflare(config) ?? null,
53
+ json: true,
54
+ });
55
+ };
56
+
57
+ /**
58
+ * The structured outcome of `pithy feature create` — the `--json` payload and the human summary source.
59
+ *
60
+ * **It says nothing about migrate and seed, and that is the change (#231).** It used to carry
61
+ * `migrated: true, seeded: true`, both written as literals a few lines below the two `await`s. Neither
62
+ * step has a flag here and both throw on failure, so a report that exists at all is the proof they ran:
63
+ * the pair was a constant with the grammar of a fact, and the only branch a consumer could write on it
64
+ * — `if (migrated && !seeded)` — is one that can never fire and can never be tested. If create ever
65
+ * grows a `--skip-data` of its own, `feature sync`'s `data` is the field to copy, because a value that
66
+ * can be `false` is the only kind worth emitting.
67
+ */
68
+ export interface CreateReport {
69
+ /** The command that produced the report. */
70
+ command: "feature.create";
71
+ /** The feature branch created or attached. */
72
+ branch: string;
73
+ /** The absolute worktree path. */
74
+ worktree: string;
75
+ /**
76
+ * Whether a new worktree was created (false on an idempotent re-run over an existing one).
77
+ *
78
+ * **Named for what it created (#235).** It was `created`, and `pithy ui add` emits a `created` of its
79
+ * own that is a `string[]` of files — one name, two types, so `if (result.created)` is true for a
80
+ * non-empty file list and true for this boolean, and a consumer that guessed wrong got no error. The
81
+ * subject is the outlier here: `ui`'s says *what* was created, this one says *whether*, and the thing it
82
+ * says it about is already in the payload beside it. `feature.destroy` had settled the convention
83
+ * anyway — `portsFreed`, `worktreePruned`, `branchDeleted` — so a bare `created` was the odd one out
84
+ * inside its own command before it was one across the CLI.
85
+ */
86
+ worktreeCreated: boolean;
87
+ /**
88
+ * The ref the branch was cut from — the trunk's name, or `"HEAD"`. **Null when nothing was cut**: an
89
+ * already-registered worktree, or a branch that already existed and was attached to.
90
+ *
91
+ * The command reports how far the trunk is behind its remote, and only when this is non-null. On the
92
+ * attach path that sentence would be about a base somebody else chose months ago — a false sentence
93
+ * about a base, which is the defect `#454` is about.
94
+ */
95
+ base: string | null;
96
+ /** The feature's dev config — its reserved port block and each worker's pinned endpoint. */
97
+ dev: DevConfig;
98
+ }
99
+
100
+ /** Options for {@link createFeature}. */
101
+ export interface CreateFeatureOptions {
102
+ /** The main checkout root — the port registry's key, and where the source `.dev.vars` lives. */
103
+ projectDir: string;
104
+ /** The issue number. */
105
+ issue: string;
106
+ /** The kebab-case slug. */
107
+ slug: string;
108
+ /** Skip the dependency install (tests set this; a real run installs so the gates run in the tree). */
109
+ skipInstall?: boolean;
110
+ /** Ports per feature block (defaults to the registry's block size). */
111
+ blockSize?: number;
112
+ /** The registry file (default: `<config>/dev-ports.json`). A seam, so a test never writes the real one. */
113
+ registryPath?: string;
114
+ /** git runner seam. */
115
+ git?: GitRunner;
116
+ /** Install runner seam. */
117
+ install?: InstallRunner;
118
+ /** Local migrate seam (default: `migrateProject` against dev). */
119
+ migrate?: LocalRunner;
120
+ /** Local seed seam (default: `seedProject` against dev). */
121
+ seed?: LocalRunner;
122
+ /** Worker-discovery seam (default: `discoverWorkers`), so tests fix the worker set. */
123
+ discoverWorkers?: (projectDir: string) => Promise<WorkerTarget[]>;
124
+ }
125
+
126
+ /**
127
+ * Stand up a feature's local environment. Creates the worktree, reserves its port block and pins one port
128
+ * per worker into `.dev.config.json`, installs deps (unless skipped),
129
+ * then migrates and seeds the local backend. Idempotent: a re-run reuses the existing worktree, keeps its
130
+ * port block (so every worker's address is stable for the life of the feature), and re-runs migrate/seed
131
+ * harmlessly.
132
+ */
133
+ export async function createFeature(options: CreateFeatureOptions): Promise<CreateReport> {
134
+ const git = options.git ?? defaultGit;
135
+ const worktree = await createWorktree({ issue: options.issue, slug: options.slug, git });
136
+
137
+ // Install before discovering workers so a freshly-cut worktree has its dependencies in place.
138
+ if (!options.skipInstall) {
139
+ const pm = await detectPackageManager(worktree.wtPath);
140
+ const installer = options.install ?? defaultInstall;
141
+ await installer(pm, ["install"], worktree.wtPath);
142
+ }
143
+
144
+ // Reserve the block and pin every worker's port — the same reconciliation `pithy feature sync` runs, so
145
+ // creating and later adding a worker go through one implementation.
146
+ const { dev } = await syncFeatureDevConfig({
147
+ mainRoot: worktree.root,
148
+ worktreePath: worktree.wtPath,
149
+ branch: worktree.branch,
150
+ ...(options.registryPath !== undefined ? { registryPath: options.registryPath } : {}),
151
+ ...(options.blockSize !== undefined ? { blockSize: options.blockSize } : {}),
152
+ ...(options.discoverWorkers !== undefined ? { discoverWorkers: options.discoverWorkers } : {}),
153
+ });
154
+
155
+ // Migrate and seed run against the **worktree**, and discover its Workers themselves: the branch being
156
+ // created is what decides which Workers exist and what each composes, and it may already differ from the
157
+ // main checkout this command was run in.
158
+ const migrate = options.migrate ?? localMigrate;
159
+ const seed = options.seed ?? localSeed;
160
+ // Both throw on failure, so reaching the return is what says they ran. Nothing in the report repeats it.
161
+ await migrate({ projectDir: worktree.wtPath });
162
+ await seed({ projectDir: worktree.wtPath });
163
+
164
+ return {
165
+ command: "feature.create",
166
+ branch: worktree.branch,
167
+ worktree: worktree.wtPath,
168
+ worktreeCreated: worktree.created,
169
+ base: worktree.base,
170
+ dev,
171
+ };
172
+ }
173
+
174
+ /** The default install runner — spawn the detected package manager. Lazy import keeps it out of the seam type. */
175
+ const defaultInstall: InstallRunner = async (command, args, cwd) => {
176
+ const { execFile } = await import("node:child_process");
177
+ const { promisify } = await import("node:util");
178
+ await promisify(execFile)(command, args, { cwd });
179
+ };
@@ -0,0 +1,160 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { rm } from "node:fs/promises";
5
+ import type { Capability } from "@pithy-sh/core/src/capability/capability";
6
+ import type { FeatureIdentity } from "@pithy-sh/core/src/naming/feature";
7
+ import { partialWriteReport } from "@pithy-sh/secrets/src/cli/partialWrite";
8
+ import type { CliAuditEmit } from "../audit/cliAudit";
9
+ import type { ResourceProvisioners } from "../provision/resources";
10
+ import type { SecretsStore } from "../provision/store";
11
+ import { devConfigPath } from "./devConfig";
12
+ import { freePortBlock, portsRegistryPath, resolveMainRepoRoot } from "./ports";
13
+ import { type DeprovisionedResource, deletedBeforeFailure, deprovisionFeature } from "./provision";
14
+ import { defaultGit, type GitRunner, teardownWorktree } from "./worktree";
15
+
16
+ /**
17
+ * `pithy feature destroy` — the teardown half, run from within the worktree. It reverses both remote and
18
+ * local, in order: delete the manifest's Cloudflare resources then reconcile by prefix-scan, free the
19
+ * feature's port block, and finally prune the worktree the Linux-safe way. Every step is idempotent, so a
20
+ * partial-failed provision or a half-torn-down feature still tears down to zero, exiting 0. It is exactly
21
+ * what the merge-to-main CI job runs headlessly.
22
+ */
23
+
24
+ /** The structured outcome of `pithy feature destroy` — the `--json` payload and the human summary source. */
25
+ export interface DestroyReport {
26
+ /** The command that produced the report. */
27
+ command: "feature.destroy";
28
+ /** Every Cloudflare resource deleted (manifest + reconcile). Empty when nothing remained or remote was skipped. */
29
+ deleted: DeprovisionedResource[];
30
+ /** Whether the remote teardown ran (false when no provisioners were available, e.g. no CF credentials). */
31
+ remote: boolean;
32
+ /** Whether the feature's port block was freed. */
33
+ portsFreed: boolean;
34
+ /** Whether a registered worktree was pruned. */
35
+ worktreePruned: boolean;
36
+ /** Whether the feature branch was deleted (only when merged). */
37
+ branchDeleted: boolean;
38
+ }
39
+
40
+ /** A carried value arrives as `unknown`; this is the narrowing, never a cast. */
41
+ function isDestroyReport(value: unknown): value is DestroyReport {
42
+ if (typeof value !== "object" || value === null) return false;
43
+ const candidate = value as Partial<DestroyReport>;
44
+ return candidate.command === "feature.destroy" && Array.isArray(candidate.deleted);
45
+ }
46
+
47
+ /**
48
+ * **Where the record of a teardown that failed partway rides out of it (#380).**
49
+ *
50
+ * A teardown destroys infrastructure and reports what it destroyed. Until now a throw from the remote
51
+ * half took that report with it, so an operator whose token expired on the fourth of five deletes was
52
+ * told only that it failed — and the three databases that were already gone were gone unrecorded. The
53
+ * report is the whole product of this command, so it survives the failure the same way a partial mint
54
+ * does (#324).
55
+ */
56
+ const deprovisionReport = partialWriteReport<DestroyReport>("pithy.cli.destroyReport", isDestroyReport);
57
+
58
+ /** What a failed {@link destroyFeature} run tore down before it failed, or `undefined` for any other throw. */
59
+ export function destroyedBeforeFailure(error: unknown): DestroyReport | undefined {
60
+ return deprovisionReport.read(error);
61
+ }
62
+
63
+ /** Options for {@link destroyFeature}. */
64
+ export interface DestroyFeatureOptions {
65
+ /** The worktree root — where the manifest lives and the branch is checked out. */
66
+ projectDir: string;
67
+ /** The feature identity — project/issue/slug — for recomputing resource names and the branch name. */
68
+ identity: FeatureIdentity;
69
+ /**
70
+ * Every capability the feature spans — the union of its Workers' own configs, the same one `provision`
71
+ * derived resource names from. The remote reconcile recomputes those exact names from it.
72
+ */
73
+ capabilities: Capability[];
74
+ /** The environment being torn down. Recorded on each audit event. */
75
+ env: string;
76
+ /** The provisioners to delete through, or undefined to skip remote teardown (e.g. no CF credentials). */
77
+ provisioners?: ResourceProvisioners;
78
+ /**
79
+ * The account's Secrets Store, when one is reachable. Teardown removes the entries this feature
80
+ * created; a store entry left behind is a live credential in a flat namespace with nothing pointing
81
+ * at it.
82
+ */
83
+ store?: SecretsStore;
84
+ /** Audit emitter, so every deletion leaves a record. Defaults to recording nothing. */
85
+ audit?: CliAuditEmit;
86
+ /** git runner seam. */
87
+ git?: GitRunner;
88
+ /** Override the registry file (tests inject; a real run resolves `<config>/dev-ports.json`). */
89
+ registryPath?: string;
90
+ /** Override the main checkout root, the registry's key (tests inject; a real run resolves it via git-common-dir). */
91
+ root?: string;
92
+ }
93
+
94
+ /**
95
+ * Tear a feature down. Delete its Cloudflare resources (manifest ids, then prefix-scan reconcile) when
96
+ * provisioners are available, free its port block, and prune its worktree + branch — in that order.
97
+ * Idempotent end to end: already-gone resources, an unallocated port block, and an absent worktree are all
98
+ * clean no-ops, so re-running (or running on a never-provisioned feature) exits without error.
99
+ */
100
+ export async function destroyFeature(options: DestroyFeatureOptions): Promise<DestroyReport> {
101
+ const git = options.git ?? defaultGit;
102
+
103
+ let deleted: DeprovisionedResource[] = [];
104
+ const remote = options.provisioners !== undefined;
105
+ if (options.provisioners) {
106
+ try {
107
+ const report = await deprovisionFeature({
108
+ projectDir: options.projectDir,
109
+ identity: options.identity,
110
+ capabilities: options.capabilities,
111
+ env: options.env,
112
+ provisioners: options.provisioners,
113
+ ...(options.store !== undefined ? { store: options.store } : {}),
114
+ ...(options.audit !== undefined ? { audit: options.audit } : {}),
115
+ });
116
+ deleted = report.deleted;
117
+ } catch (error) {
118
+ // What the remote half destroyed before it failed, moved onto this report and carried on again so
119
+ // the command can print it beside the failure (#380). The local half below deliberately does not
120
+ // run: pruning the worktree would remove the checkout the re-run has to happen from, and the
121
+ // feature manifest that says what is left to delete lives in it.
122
+ throw deprovisionReport.carry(error, {
123
+ command: "feature.destroy",
124
+ deleted: deletedBeforeFailure(error),
125
+ remote,
126
+ portsFreed: false,
127
+ worktreePruned: false,
128
+ branchDeleted: false,
129
+ });
130
+ }
131
+ }
132
+
133
+ const registryPath = options.registryPath ?? portsRegistryPath();
134
+ // The same git-common-dir derivation the registry key was always freed by — `projectDir` is the
135
+ // worktree, and this is the main checkout it belongs to. `ports.test.ts` pins it against the
136
+ // `git worktree list` derivation `feature create` reserves under, because a key freed under a root
137
+ // create never wrote is a no-op that still reports `portsFreed: true` (#435).
138
+ const root = options.root ?? (await resolveMainRepoRoot(options.projectDir));
139
+ const branch = `feature/${options.identity.issue}-${options.identity.slug}`;
140
+ // Drop the feature's pinned ports **before** freeing its registry key, and in that order. Teardown leaves
141
+ // the worktree's files on disk by design (recursive deletion is what we must never do on Linux), and
142
+ // `.dev.config.json` is a port claim: every later `feature create`/`sync` rebuilds the registry from the
143
+ // pinned blocks it finds under `.worktrees`, so a surviving one hands this branch its block straight back —
144
+ // permanently, to a feature that no longer exists. Removing one file is not a recursive delete. If the run
145
+ // dies between the two steps, the registry is the only claim left and a re-run clears it; the reverse order
146
+ // would leave the stale claim to be reclaimed.
147
+ await rm(devConfigPath(options.projectDir), { force: true });
148
+ await freePortBlock({ registryPath, root, branch });
149
+
150
+ const teardown = await teardownWorktree({ issue: options.identity.issue, slug: options.identity.slug, git });
151
+
152
+ return {
153
+ command: "feature.destroy",
154
+ deleted,
155
+ remote,
156
+ portsFreed: true,
157
+ worktreePruned: teardown.pruned,
158
+ branchDeleted: teardown.branchDeleted,
159
+ };
160
+ }