@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,201 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { readdir, readFile } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+ import { fromZodError, InternalError } from "@pithy-sh/core/src/error/pithyError";
7
+ import { z } from "zod";
8
+ import { writeFileAtomic } from "../project/atomic";
9
+ import type { WorkerTarget } from "../project/workers";
10
+ import type { PortBlock } from "./ports";
11
+
12
+ /**
13
+ * The per-feature dev config — a git-ignored `.dev.config.json` at the worktree root, written once by
14
+ * `pithy feature create` and fixed for the life of the feature.
15
+ *
16
+ * It exists because `.dev.vars` is a single file shared repo-wide across every worktree (each worktree's
17
+ * copy is a symlink to the main checkout's), so it cannot carry per-feature values without one feature
18
+ * clobbering another. This file is that per-feature surface.
19
+ *
20
+ * Ports are **assigned here at creation time, not discovered at startup** — that is the whole point.
21
+ * Probing for a free port when a worker boots is a time-of-check/time-of-use race: two `pithy dev`
22
+ * processes in two worktrees can both see the same port free and both try to bind it. Pre-assigning every
23
+ * worker its own port from the feature's reserved block makes N features run simultaneously with no
24
+ * probing, no race, and stable addresses each worker can use to auto-wire to its siblings.
25
+ *
26
+ * It is named for what it is — the feature's dev config — not for ports alone, so later per-feature dev
27
+ * settings land here too without a rename.
28
+ */
29
+
30
+ /** One worker's fixed local endpoint for the life of the feature. */
31
+ export const DevWorkerConfig = z
32
+ .object({
33
+ port: z.number().int().positive().describe("The port this worker binds locally, reserved at feature creation."),
34
+ origin: z.string().describe("The origin sibling workers reach this one at, e.g. http://localhost:8787."),
35
+ })
36
+ .describe("One worker's fixed local endpoint within its feature's reserved port block.");
37
+ export type DevWorkerConfig = z.output<typeof DevWorkerConfig>;
38
+
39
+ /** The feature's reserved slice of the central port registry. */
40
+ export const DevPortBlock = z
41
+ .object({
42
+ index: z
43
+ .number()
44
+ .int()
45
+ .nonnegative()
46
+ .describe("The block's index in the machine's <config>/dev-ports.json registry."),
47
+ base: z.number().int().positive().describe("The first port in the block."),
48
+ size: z.number().int().positive().describe("How many ports the block spans — its worker capacity."),
49
+ })
50
+ .describe("The contiguous port block this feature owns, allocated from the central registry.");
51
+ export type DevPortBlock = z.output<typeof DevPortBlock>;
52
+
53
+ /** The `.dev.config.json` document. */
54
+ export const DevConfig = z
55
+ .object({
56
+ version: z.literal(1).describe("Dev-config schema version."),
57
+ branch: z.string().describe("The feature branch this config belongs to, e.g. feature/69-media-cli."),
58
+ ports: DevPortBlock.describe("The feature's reserved port block."),
59
+ workers: z
60
+ .record(z.string(), DevWorkerConfig)
61
+ .describe("Each worker's fixed endpoint, keyed by worker name — the map pithy dev starts from."),
62
+ })
63
+ .describe("A feature's per-worktree dev configuration (git-ignored): its reserved ports and per-worker endpoints.");
64
+ export type DevConfig = z.output<typeof DevConfig>;
65
+
66
+ /** The dev-config path for a worktree: `<worktreeDir>/.dev.config.json`. */
67
+ export function devConfigPath(worktreeDir: string): string {
68
+ return join(worktreeDir, ".dev.config.json");
69
+ }
70
+
71
+ /**
72
+ * Build the feature's dev config: one port per worker, drawn from the reserved block.
73
+ *
74
+ * **Assignment is sticky.** A worker that already has a port in `previous` keeps it, whatever the current
75
+ * discovery order — worker discovery is alphabetical, so a purely positional assignment would renumber every
76
+ * later worker the moment someone adds one that sorts earlier, moving addresses out from under a running dev
77
+ * session. Only genuinely new workers are assigned, each taking the lowest port in the block that nothing
78
+ * else holds; a worker that goes away releases its port back to the block for reuse.
79
+ *
80
+ * Fails when the feature has more workers than its block holds rather than spilling into the next feature's
81
+ * ports — a silent overlap is exactly the collision the block allocation exists to prevent.
82
+ */
83
+ export function buildDevConfig(args: {
84
+ branch: string;
85
+ block: PortBlock;
86
+ workers: WorkerTarget[];
87
+ /** The feature's current dev config, whose worker→port pairs are preserved. Omit on first creation. */
88
+ previous?: DevConfig | null;
89
+ }): DevConfig {
90
+ if (args.workers.length > args.block.size) {
91
+ throw new InternalError({
92
+ message: `This feature has ${args.workers.length} workers but its port block holds only ${args.block.size}.`,
93
+ // The count includes each composed capability's host Worker, which `pithy dev` starts beside the
94
+ // `apps/*` set — so removing a capability is as much a fix here as removing a Worker.
95
+ action: "Remove a worker or a capability, or widen the feature port-block size.",
96
+ });
97
+ }
98
+
99
+ const inBlock = (port: number): boolean => port >= args.block.base && port < args.block.base + args.block.size;
100
+ const kept = new Map<string, number>();
101
+ for (const worker of args.workers) {
102
+ // Carry a port over only when it still falls inside the block (a resized or reallocated block invalidates it).
103
+ const port = args.previous?.workers[worker.name]?.port;
104
+ if (port !== undefined && inBlock(port)) kept.set(worker.name, port);
105
+ }
106
+
107
+ const taken = new Set(kept.values());
108
+ const nextFree = (): number => {
109
+ for (let port = args.block.base; port < args.block.base + args.block.size; port += 1) {
110
+ if (!taken.has(port)) return port;
111
+ }
112
+ // Unreachable: the worker-count guard above already bounds demand to the block's size.
113
+ throw new InternalError({ detail: `No free port left in block ${args.block.block}.` });
114
+ };
115
+
116
+ const workers: Record<string, DevWorkerConfig> = {};
117
+ for (const worker of args.workers) {
118
+ let port = kept.get(worker.name);
119
+ if (port === undefined) {
120
+ port = nextFree();
121
+ taken.add(port);
122
+ }
123
+ workers[worker.name] = { port, origin: `http://localhost:${port}` };
124
+ }
125
+
126
+ return {
127
+ version: 1,
128
+ branch: args.branch,
129
+ ports: { index: args.block.block, base: args.block.base, size: args.block.size },
130
+ workers,
131
+ };
132
+ }
133
+
134
+ /** Zod-validate then write the dev config atomically (pretty JSON, trailing newline). */
135
+ export async function writeDevConfig(path: string, config: DevConfig): Promise<void> {
136
+ const parsed = DevConfig.safeParse(config);
137
+ if (!parsed.success) {
138
+ throw fromZodError(parsed.error, {
139
+ message: "The feature dev config is invalid.",
140
+ action: "Re-run pithy feature create to rebuild .dev.config.json.",
141
+ });
142
+ }
143
+ await writeFileAtomic(path, `${JSON.stringify(parsed.data, null, 2)}\n`);
144
+ }
145
+
146
+ /**
147
+ * Every port block currently pinned on disk, read from each worktree's own `.dev.config.json` under
148
+ * `<mainRoot>/.worktrees`. This is what lets a lost port registry be rebuilt from the worktrees that
149
+ * outlived it (see `reclaimPortBlocks`).
150
+ * Unreadable or malformed configs are skipped rather than failing the scan — one bad worktree must not block
151
+ * creating a new feature.
152
+ */
153
+ export async function scanPinnedBlocks(mainRoot: string): Promise<{ branch: string; block: PortBlock }[]> {
154
+ let entries: string[];
155
+ try {
156
+ entries = await readdir(join(mainRoot, ".worktrees"));
157
+ } catch {
158
+ return []; // no .worktrees yet — nothing to reclaim.
159
+ }
160
+
161
+ const found: { branch: string; block: PortBlock }[] = [];
162
+ for (const entry of entries) {
163
+ const config = await readDevConfig(devConfigPath(join(mainRoot, ".worktrees", entry))).catch(() => null);
164
+ if (!config) continue;
165
+ found.push({
166
+ branch: config.branch,
167
+ block: { block: config.ports.index, base: config.ports.base, size: config.ports.size },
168
+ });
169
+ }
170
+ return found;
171
+ }
172
+
173
+ /** Read and validate the dev config, or `null` when the file does not exist. */
174
+ export async function readDevConfig(path: string): Promise<DevConfig | null> {
175
+ let raw: string;
176
+ try {
177
+ raw = await readFile(path, "utf8");
178
+ } catch {
179
+ return null;
180
+ }
181
+
182
+ let value: unknown;
183
+ try {
184
+ value = JSON.parse(raw);
185
+ } catch (cause) {
186
+ throw new InternalError({
187
+ message: "The feature dev config is corrupt.",
188
+ action: "Delete .dev.config.json and re-run pithy feature create.",
189
+ detail: cause instanceof Error ? cause.message : String(cause),
190
+ });
191
+ }
192
+
193
+ const parsed = DevConfig.safeParse(value);
194
+ if (!parsed.success) {
195
+ throw fromZodError(parsed.error, {
196
+ message: "The feature dev config is invalid.",
197
+ action: "Delete .dev.config.json and re-run pithy feature create.",
198
+ });
199
+ }
200
+ return parsed.data;
201
+ }
@@ -0,0 +1,100 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { Capability } from "@pithy-sh/core/src/capability/capability";
5
+ import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
6
+ import type { FeatureIdentity } from "@pithy-sh/core/src/naming/feature";
7
+ import { loadProject, requireProjectName } from "../project/config";
8
+ import { projectCapabilities, resolveWorkers } from "../project/workerScope";
9
+ import { defaultGit, type GitRunner } from "./worktree";
10
+
11
+ /** A feature's identity as read from its branch: the issue number, the slug, and the full branch name. */
12
+ export interface FeatureBranchIdentity {
13
+ /** The issue number as a string, e.g. "69". */
14
+ issue: string;
15
+ /** The kebab-case slug, e.g. "media-cli". */
16
+ slug: string;
17
+ /** The full branch, `feature/<issue>-<slug>`. */
18
+ branch: string;
19
+ }
20
+
21
+ /** `feature/<digits>-<kebab-slug>` — the branch shape `pithy feature` owns. */
22
+ const FEATURE_BRANCH = /^feature\/(\d+)-([a-z0-9]+(?:-[a-z0-9]+)*)$/;
23
+
24
+ /** Parse a branch name into a feature identity, or null when it is not a `feature/<issue>-<slug>` branch. */
25
+ export function parseFeatureBranch(branch: string): FeatureBranchIdentity | null {
26
+ const match = FEATURE_BRANCH.exec(branch);
27
+ if (!match) return null;
28
+ const [, issue, slug] = match;
29
+ if (!issue || !slug) return null;
30
+ return { issue, slug, branch };
31
+ }
32
+
33
+ /**
34
+ * Derive the feature identity from the current git branch — the source of truth for `provision` and
35
+ * `destroy`, which take no positional args and run from within the worktree. Fails with an actionable
36
+ * error when the checkout is not on a `feature/<issue>-<slug>` branch.
37
+ */
38
+ export async function deriveIdentityFromBranch(
39
+ cwd: string,
40
+ git: GitRunner = defaultGit,
41
+ ): Promise<FeatureBranchIdentity> {
42
+ const branch = await git(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
43
+ const identity = parseFeatureBranch(branch);
44
+ if (!identity) {
45
+ throw new ValidationError({
46
+ message: `Not on a feature branch (${branch}).`,
47
+ action: "Run this from inside a feature worktree, or create one with pithy feature create.",
48
+ });
49
+ }
50
+ return identity;
51
+ }
52
+
53
+ /**
54
+ * Resolve the feature identity (project + issue + slug) from the current branch and the root config, plus
55
+ * the capabilities the feature spans.
56
+ *
57
+ * The two come from different places, deliberately. **Identity** is project-wide policy and lives in the
58
+ * root `pithy.config.ts`. **Capabilities** are per Worker (`apps/<name>/pithy.config.ts`), so they are
59
+ * unioned: a feature provisions one resource per binding name for the whole feature — two Workers that both
60
+ * declare `DB` deliberately share one database — and the migrate/seed it runs must cover every table any
61
+ * Worker owns.
62
+ *
63
+ * **Reached only once an operator has said `--feature` or run `pithy feature`.** The branch names a
64
+ * feature; it never decides that this run is one.
65
+ */
66
+ export async function branchIdentity(
67
+ projectDir: string,
68
+ ): Promise<{ identity: FeatureIdentity; capabilities: Capability[] }> {
69
+ const { issue, slug } = await deriveIdentityFromBranch(projectDir);
70
+ const config = await loadProject(projectDir);
71
+ // Never guessed: this name is the first segment of every resource name, and the only key teardown has
72
+ // to find them again. A fallback that differs between a worktree and a clone would make destroy
73
+ // recompute names that match nothing, delete nothing, and exit 0 — a silent leak.
74
+ const project = requireProjectName(config);
75
+ const capabilities = projectCapabilities(await resolveWorkers({ projectDir }));
76
+ return { identity: { project, issue, slug }, capabilities };
77
+ }
78
+
79
+ /**
80
+ * The feature's identity without loading a single Worker config — `#454`.
81
+ *
82
+ * {@link branchIdentity} answers identity *and* capabilities, and the capabilities come from every
83
+ * `apps/<name>/pithy.config.ts`. That is right for `provision`, which cannot act without knowing what it
84
+ * is acting on. It is wrong for `destroy`, whose local half — free the port block, prune the worktree —
85
+ * needs none of it, and which is most needed in exactly the state where a Worker config will not load.
86
+ *
87
+ * A `feature create` that failed partway used to leave a worktree whose config threw, and `destroy` threw
88
+ * on the same config before it reached the teardown. The one command that removes the worktree and frees
89
+ * the port block was unavailable in the state it exists for, and the block leaked: the registry kept a
90
+ * branch that no longer existed, and the way out was editing `<config>/dev-ports.json` by hand.
91
+ *
92
+ * The project name still comes from the **root** config, which is project identity and holds no
93
+ * capabilities — so it loads when a Worker's does not, and teardown keeps deriving resource names the same
94
+ * way it always did rather than guessing them.
95
+ */
96
+ export async function branchIdentityWithoutWorkers(projectDir: string): Promise<FeatureIdentity> {
97
+ const { issue, slug } = await deriveIdentityFromBranch(projectDir);
98
+ const project = requireProjectName(await loadProject(projectDir));
99
+ return { project, issue, slug };
100
+ }
@@ -0,0 +1,132 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { join } from "node:path";
5
+ import { fromZodError, InternalError } from "@pithy-sh/core/src/error/pithyError";
6
+ import { z } from "zod";
7
+ import { writeFileAtomic } from "../project/atomic";
8
+ import { readOptionalFile } from "../project/readOptionalFile";
9
+
10
+ /**
11
+ * The remedy for a file that is there and would not open, chosen from the errno (#217).
12
+ *
13
+ * `readOptionalFile`'s `unreadable` is **every errno but `ENOENT`**. *Check permissions on X* answers
14
+ * one of them. An adopter whose `.pithy-feature.json` is a directory, a symlink loop, or a bad sector
15
+ * reads a sentence about permissions, runs `chmod`, and learns nothing — which is the shape #217 is
16
+ * about: a `catch` reachable more than one way may not name a single remedy. Unrecognised errnos get no
17
+ * remedy at all, only the errno, because a wrong action is worse than no action.
18
+ *
19
+ * `ports.ts` carries its own copy for its own file. Two is under this repository's threshold for hoisting
20
+ * a rule out of its call sites (see `readOptionalFile.ts`: *three is this repository's count*). The home
21
+ * for a third is beside `readOptionalFile`, which already owns the absent/unreadable decision itself.
22
+ */
23
+ function unreadableAction(code: string | undefined, name: string): string {
24
+ switch (code) {
25
+ case "EACCES":
26
+ case "EPERM":
27
+ return `Check permissions on ${name}.`;
28
+ case "EISDIR":
29
+ return `${name} is a directory, not a file. Remove it, then re-run.`;
30
+ case "ELOOP":
31
+ return `${name} is a symlink loop. Replace it with a regular file, then re-run.`;
32
+ default:
33
+ return `${name} is there and would not open (${code ?? "unknown error"}). Check that file, then re-run.`;
34
+ }
35
+ }
36
+
37
+ /** One provisioned Cloudflare resource recorded for teardown. */
38
+ export const FeatureResource = z
39
+ .object({
40
+ kind: z.enum(["d1", "kv", "r2"]).describe("The Cloudflare resource type."),
41
+ binding: z.string().describe("The Worker binding name this resource backs, e.g. DB."),
42
+ name: z.string().describe("The full CF resource name, e.g. acme-f69-media-cli-db-d1."),
43
+ id: z.string().describe("The CF-assigned id (D1 uuid, KV namespace id) or the bucket name for r2."),
44
+ })
45
+ .describe("One provisioned Cloudflare resource recorded for teardown.");
46
+ export type FeatureResource = z.output<typeof FeatureResource>;
47
+
48
+ /** The per-feature record of provisioned Cloudflare resources (git-ignored, in the worktree). */
49
+ export const FeatureManifest = z
50
+ .object({
51
+ version: z.literal(1).describe("Manifest schema version."),
52
+ project: z.string().describe('The project name, from pithy.config (e.g. "acme").'),
53
+ issue: z.string().describe('The issue number this feature was created for (e.g. "69").'),
54
+ slug: z.string().describe('The kebab-case feature slug (e.g. "media-cli").'),
55
+ env: z.string().describe("The environment these resources belong to."),
56
+ resources: z.array(FeatureResource).describe("Every provisioned resource, for exact-id teardown."),
57
+ })
58
+ .describe("The per-feature record of provisioned Cloudflare resources (git-ignored, in the worktree).");
59
+ export type FeatureManifest = z.output<typeof FeatureManifest>;
60
+
61
+ /** The manifest path for a worktree dir: `<worktreeDir>/.pithy-feature.json`. */
62
+ export function manifestPath(worktreeDir: string): string {
63
+ return join(worktreeDir, ".pithy-feature.json");
64
+ }
65
+
66
+ /**
67
+ * Read + Zod-validate the manifest, or `null` if the file does not exist.
68
+ *
69
+ * "Does not exist" is {@link readOptionalFile}'s decision — a manifest that is there and will not open
70
+ * would otherwise read as "this worktree provisioned nothing", and `pithy feature destroy` tears down by
71
+ * exact id from this record. The words stay here; the errno does not.
72
+ */
73
+ export async function readManifest(path: string): Promise<FeatureManifest | null> {
74
+ const raw = await readOptionalFile(path, {
75
+ unreadable: ({ code, cause }) =>
76
+ new InternalError({
77
+ message: "Could not read the feature manifest.",
78
+ action: unreadableAction(code, ".pithy-feature.json"),
79
+ detail: `${code ?? "unknown error"}: ${cause instanceof Error ? cause.message : String(cause)}`,
80
+ }),
81
+ });
82
+ if (raw === null) return null;
83
+
84
+ let parsed: unknown;
85
+ try {
86
+ parsed = JSON.parse(raw);
87
+ } catch (err) {
88
+ throw new InternalError({
89
+ message: "The feature manifest is corrupt.",
90
+ action: "Delete .pithy-feature.json and re-run pithy provision --feature.",
91
+ detail: err instanceof Error ? err.message : String(err),
92
+ });
93
+ }
94
+
95
+ const result = FeatureManifest.safeParse(parsed);
96
+ if (!result.success) {
97
+ throw fromZodError(result.error, {
98
+ message: "The feature manifest is corrupt.",
99
+ action: "Delete .pithy-feature.json and re-run pithy provision --feature.",
100
+ });
101
+ }
102
+ return result.data;
103
+ }
104
+
105
+ /** Zod-validate then write the manifest atomically (pretty JSON, 2-space indent, trailing newline). */
106
+ export async function writeManifest(path: string, manifest: FeatureManifest): Promise<void> {
107
+ const parsed = FeatureManifest.parse(manifest);
108
+ await writeFileAtomic(path, `${JSON.stringify(parsed, null, 2)}\n`);
109
+ }
110
+
111
+ /**
112
+ * Return a NEW manifest with `resource` upserted — deduped by (kind, binding, name); a match is
113
+ * replaced in place, otherwise the resource is appended. Pure: never mutates `manifest`.
114
+ */
115
+ export function upsertResource(manifest: FeatureManifest, resource: FeatureResource): FeatureManifest {
116
+ const index = manifest.resources.findIndex(
117
+ (existing) =>
118
+ existing.kind === resource.kind && existing.binding === resource.binding && existing.name === resource.name,
119
+ );
120
+
121
+ const resources =
122
+ index === -1
123
+ ? [...manifest.resources, resource]
124
+ : manifest.resources.map((existing, i) => (i === index ? resource : existing));
125
+
126
+ return { ...manifest, resources };
127
+ }
128
+
129
+ /** Build an empty manifest for an identity + environment. */
130
+ export function emptyManifest(args: { project: string; issue: string; slug: string; env: string }): FeatureManifest {
131
+ return { version: 1, ...args, resources: [] };
132
+ }