@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,362 @@
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 { ValidationError } from "@pithy-sh/core/src/error/pithyError";
7
+ import { FEATURE_ENVIRONMENT } from "@pithy-sh/core/src/naming/environment";
8
+ import { type FeatureIdentity, type FeatureResourceKind, featureResourceName } from "@pithy-sh/core/src/naming/feature";
9
+ import { featureScope } from "@pithy-sh/core/src/naming/provisionScope";
10
+ import { partialWriteReport } from "@pithy-sh/secrets/src/cli/partialWrite";
11
+ import { MASTER_KEY_BINDING } from "@pithy-sh/secrets/src/env/bindings";
12
+ import { initialMasterKeyConfig } from "@pithy-sh/secrets/src/provision/provisionSecrets";
13
+ import type { SecretRegistry } from "@pithy-sh/secrets/src/registry";
14
+ import type { CliAuditEmit } from "../audit/cliAudit";
15
+ import { storeSecretMinter } from "../capabilities/mintSecrets";
16
+ import {
17
+ type BackendRunner,
18
+ type ProvisionReport,
19
+ type ProvisionWorker,
20
+ provisionEnvironment,
21
+ } from "../provision/environment";
22
+ import { AUDIT_RESOURCE_TYPE, ProvisionAuditActions, type ResourceProvisioners } from "../provision/resources";
23
+ import { secretsStoreBindings, workerSecretRegistry } from "../provision/secretBindings";
24
+ import type { SecretsStore } from "../provision/store";
25
+ import { provisionableBindings } from "./bindings";
26
+ import {
27
+ emptyManifest,
28
+ type FeatureManifest,
29
+ type FeatureResource,
30
+ manifestPath,
31
+ readManifest,
32
+ writeManifest,
33
+ } from "./manifest";
34
+
35
+ /**
36
+ * `pithy provision --feature` — one branch's ephemeral Cloudflare environment.
37
+ *
38
+ * The work is `provisionEnvironment`'s, unchanged for every environment a project has. What is a
39
+ * feature's own, and lives here, is the two things a *deployed* environment has no equivalent of: the
40
+ * branch-derived naming ({@link featureScope}, with no environment segment because a feature *is* an
41
+ * environment), and the manifest that lets `destroy` delete exactly what was created and nothing else.
42
+ *
43
+ * `destroy` reverses it: delete the manifest's resources, then reconcile by recomputing each expected
44
+ * name, so a partial-failed provision still cleans up fully.
45
+ */
46
+
47
+ /**
48
+ * Whether a manifest entry is one **this** feature could have created.
49
+ *
50
+ * The manifest is a file in the worktree, so it is repository content, not a trusted record: a branch can
51
+ * carry a crafted `.pithy-feature.json` (git-ignored stops an accidental commit, not `git add -f`, and does
52
+ * nothing for an already-tracked file on a fetched branch). Deleting an id straight out of it would make the
53
+ * file an unauthenticated "delete this resource" instruction — and `destroy` runs headlessly in CI, with a
54
+ * live token, against a branch that may have come from anyone.
55
+ *
56
+ * So an entry is honored only when its recorded name is exactly the name `provision` would have generated
57
+ * for that binding and kind under this identity. The check needs nothing from the file but the entry's own
58
+ * `binding`/`kind`, so a legitimate record still validates even after its capability is removed from config.
59
+ */
60
+ function isOwnedByFeature(identity: FeatureIdentity, resource: FeatureResource): boolean {
61
+ return resource.name === featureResourceName(identity, resource.binding, resource.kind);
62
+ }
63
+
64
+ /**
65
+ * Reject a manifest whose header names a different feature. The per-entry name check below is the real
66
+ * control, but a mismatched header means the file was authored for something else entirely — failing loudly
67
+ * beats silently ignoring every entry, which would look like a successful teardown that removed nothing.
68
+ */
69
+ function assertManifestBelongs(identity: FeatureIdentity, manifest: FeatureManifest | null): void {
70
+ if (!manifest) return;
71
+ if (manifest.project === identity.project && manifest.issue === identity.issue && manifest.slug === identity.slug) {
72
+ return;
73
+ }
74
+ throw new ValidationError({
75
+ message: "The feature manifest belongs to a different feature.",
76
+ action: "Delete .pithy-feature.json and re-run, or check out the branch it was written for.",
77
+ detail: `Manifest names ${manifest.project}-f${manifest.issue}-${manifest.slug}; this feature is ${identity.project}-f${identity.issue}-${identity.slug}.`,
78
+ });
79
+ }
80
+
81
+ /** Options for {@link provisionFeature}. */
82
+ export interface ProvisionFeatureOptions {
83
+ /** The worktree root — where `apps/` and the manifest live. */
84
+ projectDir: string;
85
+ /** Every capability the feature spans — the union of its Workers' own configs. */
86
+ capabilities: Capability[];
87
+ /** The feature identity — project/issue/slug — for the resource-naming convention. */
88
+ identity: FeatureIdentity;
89
+ /** The provisioners to use (`cloudflareProvisioners` over live CF clients in a real run). */
90
+ provisioners: ResourceProvisioners;
91
+ /** Migration runner seam (default: `migrateProject`). */
92
+ migrate?: BackendRunner;
93
+ /** Seed runner seam (default: `seedProject`). */
94
+ seed?: BackendRunner;
95
+ /** Worker-resolution seam (default: the real `apps/` resolver), so tests fix the worker set. */
96
+ resolveWorkers?: (projectDir: string) => Promise<ProvisionWorker[]>;
97
+ /**
98
+ * The account's Secrets Store, when one is reachable. Given it, the feature gets its **own** master
99
+ * key and its Workers get their `secrets_store_secrets` stanza; without it the feature is provisioned
100
+ * exactly as it was before, and the omission is visible in the report rather than silent.
101
+ */
102
+ store?: SecretsStore;
103
+ /** Audit emitter. Defaults to recording nothing, so a caller without audit wiring still works. */
104
+ audit?: CliAuditEmit;
105
+ }
106
+
107
+ /**
108
+ * Provision (or resume provisioning) a feature's Cloudflare environment, recording every resource in the
109
+ * per-feature manifest **after each step** so an interrupted run resumes cleanly and `destroy` knows
110
+ * exactly what to remove. Idempotent; safe to re-run.
111
+ *
112
+ * **There is no `env` argument, and that is the fix rather than an omission.** With the environment as a
113
+ * separate parameter, `<project>-f<issue>-<slug>-db` could be composed and written into the `staging`
114
+ * stanza of a checked-in `wrangler.jsonc`, then migrated against. The scope carries both halves, so the
115
+ * combination cannot be expressed. A declared environment is `pithy provision --env`'s job.
116
+ *
117
+ * **The report carries no `command` of its own** (#251). The command's name belongs to the command:
118
+ * `pithy provision` stamps `"provision"` on what it prints, and a second name stamped here would be a
119
+ * caller's own field silently overwritten by a spread.
120
+ */
121
+ export async function provisionFeature(options: ProvisionFeatureOptions): Promise<ProvisionReport> {
122
+ const path = manifestPath(options.projectDir);
123
+ const scope = featureScope(options.identity);
124
+
125
+ // The feature's own master key, before anything binds it (#239).
126
+ //
127
+ // **Its own, not the project's, and that is the decision this issue asked to be argued rather than
128
+ // typed.** `deprovisionSecrets` preserves a key unless explicitly asked, because losing it orphans
129
+ // every secret encrypted under it. For an ephemeral environment that reasoning inverts: nothing
130
+ // outlives the feature, so the key is the feature's and goes with it at teardown.
131
+ //
132
+ // **And `ManagedEnvironment` does not widen to include it.** Since #241 that type is *the set the
133
+ // project declared*, and everything iterating it multiplies with it — most of all a manager Worker
134
+ // with its own D1 and its own rotation cron, per environment. A branch does not want one, and
135
+ // `pithy secrets provision` must not deploy one per open pull request. So the feature takes the
136
+ // narrow route: a key of its own and the bindings that reach it, and none of the durable machinery.
137
+ // The consequence is stated where an operator meets it — a feature has no manager, so
138
+ // `pithy secrets create` targets a declared environment, never a branch.
139
+ const store = options.store;
140
+ if (store) {
141
+ const masterKey = scope.secretEntry(MASTER_KEY_BINDING, "environment");
142
+ if (!(await store.exists(masterKey))) {
143
+ await store.put(masterKey, JSON.stringify(await initialMasterKeyConfig()));
144
+ }
145
+ }
146
+
147
+ const report = await provisionEnvironment({
148
+ projectDir: options.projectDir,
149
+ scope,
150
+ capabilities: options.capabilities,
151
+ provisioners: options.provisioners,
152
+ // A feature environment is created empty; without fixtures there is nothing in it to check.
153
+ seedData: true,
154
+ record: {
155
+ load: async () => {
156
+ const existing = await readManifest(path);
157
+ assertManifestBelongs(options.identity, existing);
158
+ // Carry forward only entries this feature could have created. Keeping a foreign one would
159
+ // re-persist it under a freshly-written, legitimate-looking header — laundering it into what
160
+ // `destroy` later deletes.
161
+ return (existing?.resources ?? []).filter((resource) => isOwnedByFeature(options.identity, resource));
162
+ },
163
+ save: async (resources) => {
164
+ const manifest: FeatureManifest = {
165
+ ...emptyManifest({ ...options.identity, env: FEATURE_ENVIRONMENT }),
166
+ resources,
167
+ };
168
+ await writeManifest(path, manifest);
169
+ },
170
+ },
171
+ ...(options.migrate !== undefined ? { migrate: options.migrate } : {}),
172
+ ...(options.seed !== undefined ? { seed: options.seed } : {}),
173
+ ...(options.resolveWorkers !== undefined ? { resolveWorkers: options.resolveWorkers } : {}),
174
+ ...(store
175
+ ? {
176
+ secretBindings: async (capabilities) =>
177
+ secretsStoreBindings({
178
+ // A Worker composing no secrets capability declares no secrets, and gets no stanza.
179
+ registry: workerSecretRegistry(capabilities) ?? {},
180
+ scope,
181
+ storeId: store.storeId,
182
+ exists: (name) => store.exists(name),
183
+ // **What makes `pithy feature` true (#321).** It says "an isolated, fully-provisioned
184
+ // feature environment", and one that needed three follow-up commands per branch was not
185
+ // that. Every secret the registry declares mintable is created here, in the branch's own
186
+ // scope, so nothing is shared with a declared environment and nothing is left to do.
187
+ mint: storeSecretMinter({
188
+ store,
189
+ environment: scope.stanza,
190
+ ...(options.audit !== undefined ? { audit: options.audit } : {}),
191
+ }),
192
+ }),
193
+ }
194
+ : {}),
195
+ ...(options.audit !== undefined ? { audit: options.audit } : {}),
196
+ // Which feature, on every creation event. `provisionEnvironment` knows the names it wrote; only
197
+ // this caller knows the branch they came from, and that is what an operator reads the trail for.
198
+ auditMetadata: { feature: options.identity.slug, issue: options.identity.issue },
199
+ });
200
+
201
+ return report;
202
+ }
203
+
204
+ /** One deleted resource in the teardown report. */
205
+ export interface DeprovisionedResource {
206
+ /** The resource kind. */
207
+ kind: FeatureResourceKind;
208
+ /** The resource name. */
209
+ name: string;
210
+ /** The resource id that was deleted. */
211
+ id: string;
212
+ }
213
+
214
+ /** The structured outcome of the remote half of `pithy feature destroy`. */
215
+ export interface DeprovisionReport {
216
+ /** Every resource deleted — from the manifest and from the expected-name reconcile. */
217
+ deleted: DeprovisionedResource[];
218
+ }
219
+
220
+ /** A carried value arrives as `unknown`; this is the narrowing, never a cast. */
221
+ function isDeletedList(value: unknown): value is DeprovisionedResource[] {
222
+ return Array.isArray(value);
223
+ }
224
+
225
+ /**
226
+ * **Where the record of a partial teardown rides out of a failure (#380).**
227
+ *
228
+ * A teardown deletes real infrastructure one resource at a time and has no transaction across them. The
229
+ * fourth delete throws, three databases are already gone, and the return value that would have named
230
+ * them never happens — so the operator is told the teardown failed and nothing about what it destroyed.
231
+ * That is the report `pithy feature destroy` exists to produce, and it was the one the throw took.
232
+ *
233
+ * The mechanism is `partialWriteReport`'s, the same one `mintDeclaredSecrets` carries its minted secrets
234
+ * on (#324). Carried, never replaced: the failure the operator reads is the failure that happened.
235
+ */
236
+ const deprovisionReport = partialWriteReport<DeprovisionedResource[]>("pithy.cli.deprovisionReport", isDeletedList);
237
+
238
+ /**
239
+ * What a failed {@link deprovisionFeature} run deleted before it failed, in deletion order. Empty when
240
+ * the thrown thing carries no report — which is the honest answer for a throw from anywhere else.
241
+ */
242
+ export function deletedBeforeFailure(error: unknown): DeprovisionedResource[] {
243
+ return deprovisionReport.read(error) ?? [];
244
+ }
245
+
246
+ /** Options for {@link deprovisionFeature}. */
247
+ export interface DeprovisionFeatureOptions {
248
+ /** The worktree root — where the manifest lives. */
249
+ projectDir: string;
250
+ /** The feature identity — project/issue/slug — for recomputing expected resource names. */
251
+ identity: FeatureIdentity;
252
+ /**
253
+ * Every capability the feature spans (the union of its Workers'), whose bindings define the exact set of
254
+ * names this feature could have created — the same union `provision` named them from.
255
+ */
256
+ capabilities: Capability[];
257
+ /** The environment being torn down — recorded on each audit event, since the trail lands elsewhere. */
258
+ env: string;
259
+ /** The provisioners to delete through. */
260
+ provisioners: ResourceProvisioners;
261
+ /**
262
+ * The account's Secrets Store, when one is reachable. Teardown removes every entry this feature could
263
+ * have created — and only those. An entry left behind is a live credential in a flat, account-wide
264
+ * namespace with nothing pointing at it.
265
+ */
266
+ store?: SecretsStore;
267
+ /** Audit emitter. Defaults to recording nothing, so a caller without audit wiring still works. */
268
+ audit?: CliAuditEmit;
269
+ }
270
+
271
+ /**
272
+ * Delete a feature's Cloudflare resources: first the exact ids recorded in the manifest, then reconcile —
273
+ * for every binding the enabled capabilities declare, recompute the exact resource name (the same function
274
+ * `provision` named it with) and delete it if it still exists. This catches a partial-failed `provision`
275
+ * (a resource created in the tiny window before the manifest recorded it) without a prefix scan — an exact
276
+ * name can never collide with a sibling feature whose slug is a hyphen-prefix of this one's, which a
277
+ * `startsWith` scan would. Idempotent: every delete tolerates an already-gone resource, and a missing
278
+ * manifest is fine, so it exits 0 even when there is nothing left to remove. Removes the manifest file last.
279
+ *
280
+ * **There is no deployed-environment counterpart, deliberately.** `pithy provision --env prod` creates; no
281
+ * command deletes. A staging or production database is not a build artifact, and the one-word difference
282
+ * between tearing down a branch and tearing down production is not a difference a flag should carry.
283
+ */
284
+ export async function deprovisionFeature(options: DeprovisionFeatureOptions): Promise<DeprovisionReport> {
285
+ const path = manifestPath(options.projectDir);
286
+ const manifest = await readManifest(path);
287
+ const deleted: DeprovisionedResource[] = [];
288
+ const seen = new Set<string>(); // `${kind}:${id}` — never delete the same resource twice.
289
+
290
+ const audit = options.audit ?? (async () => {});
291
+ const remove = async (kind: FeatureResourceKind, name: string, id: string): Promise<void> => {
292
+ const key = `${kind}:${id}`;
293
+ if (seen.has(key)) return;
294
+ await options.provisioners[kind].delete(id);
295
+ seen.add(key);
296
+ deleted.push({ kind, name, id });
297
+ // `warning`, not `info`: this destroys real infrastructure, and in CI no human saw it happen.
298
+ await audit({
299
+ environment: options.env,
300
+ action: ProvisionAuditActions.resourceDeleted,
301
+ outcome: "success",
302
+ severity: "warning",
303
+ resourceType: AUDIT_RESOURCE_TYPE[kind],
304
+ resourceId: id,
305
+ metadata: { name, feature: options.identity.slug, issue: options.identity.issue },
306
+ });
307
+ };
308
+
309
+ // Everything from here destroys infrastructure, and `deleted` grows one resource at a time. A throw
310
+ // anywhere inside used to take the whole list with it — the resources were gone and the record of
311
+ // which ones was not, on the command whose entire output is that record (#380). It is carried on the
312
+ // failure instead, and the failure itself is rethrown untouched: teardown still stops, because a
313
+ // delete that failed for a reason belonging to the account — a revoked token, a resource another
314
+ // project holds — is not a reason to keep deleting.
315
+ try {
316
+ assertManifestBelongs(options.identity, manifest);
317
+ for (const resource of manifest?.resources ?? []) {
318
+ // Only delete what this feature could have named. An entry pointing anywhere else is not ours to
319
+ // remove — the reconcile pass below re-derives every real name from the identity anyway, so nothing
320
+ // legitimate is lost by distrusting the file.
321
+ if (isOwnedByFeature(options.identity, resource)) await remove(resource.kind, resource.name, resource.id);
322
+ }
323
+
324
+ // Reconcile by exact expected name — a resource `provision` may have created but not yet recorded.
325
+ //
326
+ // **Deliberately unfiltered by `declinedBindings`.** Every other reader of this function skips a
327
+ // declined binding, and this one must not: a decline stops a resource being *created*, and says
328
+ // nothing about one created before the decline was written. Filtering here would leave exactly those
329
+ // resources behind — a teardown that exits 0 having orphaned the thing it was run to remove, which is
330
+ // the worst failure a cleanup path has. Teardown scans the whole declared set, always.
331
+ for (const { binding, kind } of provisionableBindings(options.capabilities)) {
332
+ const name = featureResourceName(options.identity, binding, kind);
333
+ const found = await options.provisioners[kind].find(name);
334
+ if (found) await remove(kind, name, found.id);
335
+ }
336
+
337
+ // The feature's own store entries, by recomputed name — the same rule the resources above follow, and
338
+ // the same reason: an exact name is the only thing that cannot reach a sibling's or an environment's.
339
+ // A `global` secret is never touched: it is one account-level value every environment binds, and this
340
+ // feature was binding the project's rather than a copy of it.
341
+ if (options.store) {
342
+ const scope = featureScope(options.identity);
343
+ const registry: SecretRegistry = Object.assign(
344
+ {},
345
+ ...options.capabilities.map((capability) => workerSecretRegistry([capability]) ?? {}),
346
+ );
347
+ for (const [binding, entry] of Object.entries(registry)) {
348
+ if (entry.backend !== "cf-secrets-store" || entry.scope !== "environment" || entry.keyed) continue;
349
+ await options.store.remove(scope.secretEntry(binding, "environment"));
350
+ }
351
+ }
352
+ } catch (error) {
353
+ // Carried, never replaced. `deleted` is what this run destroyed, by kind, name and id — the three
354
+ // facts an operator needs to finish the teardown by hand. Nothing from the throw is copied into it.
355
+ throw deprovisionReport.carry(error, deleted);
356
+ }
357
+
358
+ // The manifest is removed only on a clean pass. It is the record of what is left to delete, and a
359
+ // teardown that failed partway is precisely when a re-run needs it.
360
+ await rm(path, { force: true });
361
+ return { deleted };
362
+ }
@@ -0,0 +1,148 @@
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 { ValidationError } from "@pithy-sh/core/src/error/pithyError";
7
+ import { discoverWorkers as discoverWorkersDefault, type WorkerTarget } from "../project/workers";
8
+ import {
9
+ buildDevConfig,
10
+ type DevConfig,
11
+ devConfigPath,
12
+ readDevConfig,
13
+ scanPinnedBlocks,
14
+ writeDevConfig,
15
+ } from "./devConfig";
16
+ import { allocatePortBlock, type PortBlock, portsRegistryPath, reclaimPortBlocks } from "./ports";
17
+
18
+ /**
19
+ * Reconcile a feature's worktree with the workers actually in it — the operation behind `pithy feature sync`
20
+ * and the shared middle of `pithy feature create`.
21
+ *
22
+ * Adding a worker to a feature is a normal thing to do, and it must not require re-running creation or
23
+ * re-typing the feature's identity. This re-discovers the worker set, gives any new worker a port from the
24
+ * feature's *already reserved* block, and leaves every existing worker exactly where it was — so an addition
25
+ * never moves a sibling's address, and never reaches into another feature's block.
26
+ */
27
+
28
+ /** Whether a path exists. */
29
+ async function exists(path: string): Promise<boolean> {
30
+ return access(path).then(
31
+ () => true,
32
+ () => false,
33
+ );
34
+ }
35
+
36
+ /**
37
+ * Whether a pinned block still belongs to a worktree that exists.
38
+ *
39
+ * Reclaim rebuilds a lost registry from the blocks worktrees still hold, so it must only re-register blocks
40
+ * a **live** feature is using. `pithy feature destroy` prunes a worktree by dropping its gitlink and pruning
41
+ * the registration — never a recursive delete (CLAUDE.md) — so a torn-down worktree is a directory with no
42
+ * `.git`. Re-registering its block would hand a destroyed feature its ports back forever.
43
+ *
44
+ * Judged only where a positive answer is possible: a directory that is not where this branch's worktree
45
+ * would live says nothing about liveness, so it stays reclaimable. Only "the directory is there and its
46
+ * gitlink is gone" is treated as dead — the one state teardown leaves behind.
47
+ */
48
+ async function isLiveWorktree(mainRoot: string, branch: string): Promise<boolean> {
49
+ const dir = join(mainRoot, ".worktrees", branch.replace(/^feature\//, ""));
50
+ if (!(await exists(dir))) return true;
51
+ return exists(join(dir, ".git"));
52
+ }
53
+
54
+ /** The outcome of a sync: the reconciled config plus what actually changed. */
55
+ export interface SyncReport {
56
+ /** The feature branch. */
57
+ branch: string;
58
+ /** The feature's reserved port block. */
59
+ block: PortBlock;
60
+ /** The reconciled dev config. */
61
+ dev: DevConfig;
62
+ /** Workers that gained a port on this run. */
63
+ added: string[];
64
+ /** Workers that went away and released their port. */
65
+ removed: string[];
66
+ }
67
+
68
+ /** Options for {@link syncFeatureDevConfig}. */
69
+ export interface SyncFeatureOptions {
70
+ /** The main checkout root — the port registry's key, and the tree the worktree scan walks. */
71
+ mainRoot: string;
72
+ /** The registry file (default: `<config>/dev-ports.json`). A seam, so a test never writes the real one. */
73
+ registryPath?: string;
74
+ /** The worktree to reconcile. */
75
+ worktreePath: string;
76
+ /** The feature branch, the registry's key. */
77
+ branch: string;
78
+ /** Ports per block, when a block still has to be reserved. */
79
+ blockSize?: number;
80
+ /** Worker-discovery seam (default: `discoverWorkers`). */
81
+ discoverWorkers?: (projectDir: string) => Promise<WorkerTarget[]>;
82
+ }
83
+
84
+ /**
85
+ * Reconcile the feature's `.dev.config.json` with its current workers. Idempotent: with no worker changes
86
+ * it rewrites the same config and reports nothing added or removed. The port block is reserved on first use and reused thereafter, so a
87
+ * feature's ports are stable for its whole life.
88
+ *
89
+ * Refuses to run when `worktreePath` is the main checkout root. `pithy feature sync` derives its identity
90
+ * from the current branch and takes no path argument, so running it from the main checkout while on a
91
+ * feature branch is a plausible slip: it would reserve a port block against the main checkout, write a
92
+ * feature's `.dev.config.json` at the project root, and take a port block a real feature is holding. The
93
+ * main checkout is not a feature, and none of that is recoverable by re-running anything.
94
+ *
95
+ * It touches no `.dev.vars` at all. It used to `unlink` and replace every real `apps/*.dev.vars` it found,
96
+ * which permanently lost git-ignored content, and this guard was the only thing standing in front of it —
97
+ * for one caller. A worktree generates its own from the same machine-local sources now (#154), so there is
98
+ * nothing here to share and nothing to lose. This guard stays because syncing the main checkout as a
99
+ * feature is still wrong, not because it is the last line of defense.
100
+ */
101
+ export async function syncFeatureDevConfig(options: SyncFeatureOptions): Promise<SyncReport> {
102
+ if (options.worktreePath === options.mainRoot) {
103
+ throw new ValidationError({
104
+ message: "Refusing to sync the main checkout as if it were a feature worktree.",
105
+ action:
106
+ "Run pithy feature sync from inside the feature's worktree (.worktrees/<issue>-<slug>), not the main checkout.",
107
+ detail: `worktreePath (${options.worktreePath}) is the main repository root.`,
108
+ });
109
+ }
110
+
111
+ // Rebuild any registry entry lost since the worktrees were created before reserving, so a fresh registry
112
+ // can never hand out a block a live feature still holds. A destroyed feature's leftover config is not a
113
+ // claim: reclaiming it would undo the teardown that just freed its block.
114
+ //
115
+ // The path comes from the one resolver (#435). It was composed here, from `mainRoot`, which made this a
116
+ // second derivation of a location that has exactly one — and a second derivation is how `create`/`sync`
117
+ // end up writing one file while `destroy` frees a key in another, both exiting 0.
118
+ const registryPath = options.registryPath ?? portsRegistryPath();
119
+ const pinned = await scanPinnedBlocks(options.mainRoot);
120
+ const reservations: typeof pinned = [];
121
+ for (const reservation of pinned) {
122
+ if (await isLiveWorktree(options.mainRoot, reservation.branch)) reservations.push(reservation);
123
+ }
124
+ await reclaimPortBlocks({ registryPath, root: options.mainRoot, reservations });
125
+
126
+ const block = await allocatePortBlock({
127
+ registryPath,
128
+ root: options.mainRoot,
129
+ branch: options.branch,
130
+ ...(options.blockSize !== undefined ? { size: options.blockSize } : {}),
131
+ });
132
+
133
+ const workers = await (options.discoverWorkers ?? discoverWorkersDefault)(options.worktreePath);
134
+ const configPath = devConfigPath(options.worktreePath);
135
+ const previous = await readDevConfig(configPath);
136
+ const dev = buildDevConfig({ branch: options.branch, block, workers, previous });
137
+ await writeDevConfig(configPath, dev);
138
+
139
+ const before = new Set(Object.keys(previous?.workers ?? {}));
140
+ const after = new Set(Object.keys(dev.workers));
141
+ return {
142
+ branch: options.branch,
143
+ block,
144
+ dev,
145
+ added: [...after].filter((name) => !before.has(name)),
146
+ removed: [...before].filter((name) => !after.has(name)),
147
+ };
148
+ }