@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,1088 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { lstatSync } from "node:fs";
5
+ import { cp, lstat, mkdir, readdir, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
6
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { ConflictError, InternalError, PithyError, ValidationError } from "@pithy-sh/core/src/error/pithyError";
9
+ import { NAMESPACE_PATTERN } from "@pithy-sh/core/src/migrations/registry";
10
+ import { DEFAULT_ENVIRONMENTS } from "@pithy-sh/core/src/naming/environment";
11
+ import {
12
+ assertValidProjectName,
13
+ isReservedProjectName,
14
+ kebab,
15
+ RESERVED_TEST_PREFIX,
16
+ } from "@pithy-sh/core/src/naming/resource";
17
+ import { PACKAGE_NAME, PACKAGE_VERSION } from "@pithy-sh/core/src/version.generated";
18
+ import { errnoOf } from "./atomic";
19
+ import { loadProjectEnvironments } from "./config";
20
+ import { committedFiles } from "./templateFiles";
21
+ import { readWranglerConfig, writeWranglerConfig } from "./wrangler";
22
+
23
+ export interface ScaffoldOptions {
24
+ /** Directory to scaffold into. Created if missing; must hold none of the paths the template writes. */
25
+ targetDir: string;
26
+ /** Application name, written into package.json and wrangler.jsonc. */
27
+ appName: string;
28
+ /** The first worker's name — it lives at `apps/<worker>/`. Defaults to {@link DEFAULT_WORKER}. */
29
+ worker?: string;
30
+ /**
31
+ * The environments this project has (#241). Defaults to {@link DEFAULT_ENVIRONMENTS}, which is what the
32
+ * template ships — so a run that does not pass this writes exactly the bytes it always wrote.
33
+ */
34
+ environments?: readonly string[];
35
+ }
36
+
37
+ /**
38
+ * Where the starter template sits, relative to this module, in the order the two layouts are tried.
39
+ *
40
+ * **The checkout first**, and only when this module really is inside `packages/cli` of a repo root —
41
+ * see {@link workspaceTemplate}. There the repo root's `templates/starter` is the single source of
42
+ * truth, and preferring it means a `packages/cli/templates/starter` left behind by a pack that failed
43
+ * after `prepack` cannot shadow it. That copy is gitignored, so `git status` says nothing while every
44
+ * later run in the checkout scaffolds from a stale template.
45
+ *
46
+ * The package second: `prepack` vendors the starter in, so an installed `@pithy-sh/cli` carries its own
47
+ * copy at `<package>/templates/starter`. That is the layout an adopter has, and the only one they have.
48
+ */
49
+ const PACKAGED_LAYOUT = ["..", "..", "templates", "starter"] as const;
50
+
51
+ /**
52
+ * The repo root's template, or nothing — and *nothing* unless `moduleDir` sits under that root's
53
+ * `packages/cli`.
54
+ *
55
+ * Four levels up from `src/project` is the repo root in a checkout. In an installed package it is
56
+ * `<node_modules>/templates/starter` — a path owned by any dependency called `templates`, an adopter's
57
+ * own or a squatter's, and `pithy init` would have scaffolded the customer's project out of it. The
58
+ * layout check is what makes that unreachable: `node_modules/@pithy-sh/cli/src/project` is not inside
59
+ * `node_modules/packages/cli`, whatever anybody installs.
60
+ */
61
+ function workspaceTemplate(moduleDir: string): string | null {
62
+ const root = resolve(moduleDir, "..", "..", "..", "..");
63
+ const within = relative(join(root, "packages", "cli"), moduleDir);
64
+ if (within.length === 0 || within.startsWith("..") || isAbsolute(within)) return null;
65
+ return join(root, "templates", "starter");
66
+ }
67
+
68
+ /**
69
+ * Where the starter came from, and — the part that matters — **which of the two layouts it is**.
70
+ *
71
+ * The layout is the only honest way to tell "git could not answer" from "there is nothing to ask": a
72
+ * checkout's `templates/starter` is tracked and always has an index, and the copy `prepack` vendored into
73
+ * the package never does. {@link templateContents} turns that into two different decisions about the same
74
+ * `null`, and getting it from the resolver means nothing has to guess later.
75
+ */
76
+ export interface TemplateSource {
77
+ /** The starter template directory. */
78
+ dir: string;
79
+ /** True for the copy `prepack` vendored into the package — the layout with no `.git` beside it. */
80
+ vendored: boolean;
81
+ }
82
+
83
+ /**
84
+ * The starter template, resolved from `moduleDir`, with the layout it was found in.
85
+ *
86
+ * Exported because the only honest way to test this is against an **extracted tarball**, not against
87
+ * the checkout the test runs in. This resolved the repo-root path and nothing else, which exists only
88
+ * in a workspace: a published CLI shipped no template at all and `pithy init` — the first command an
89
+ * adopter runs — could not work. Every scaffold test stayed green, because each one ran from the
90
+ * checkout where the missing path happened to be there.
91
+ */
92
+ export function resolveTemplateSource(moduleDir: string): TemplateSource {
93
+ const here = resolve(moduleDir);
94
+ const workspace = workspaceTemplate(here);
95
+ const candidates: TemplateSource[] = [
96
+ ...(workspace === null ? [] : [{ dir: workspace, vendored: false }]),
97
+ { dir: resolve(here, ...PACKAGED_LAYOUT), vendored: true },
98
+ ];
99
+ // `lstatSync`, not `existsSync`, and the reason is the module's own rule rather than a threat model:
100
+ // this file writes, and a writing module answers "is something at this path" one way — about the path
101
+ // itself. An exception here would be an exception somebody has to remember, and the escape this module
102
+ // exists to stop has five producers because nobody did. `throwIfNoEntry` off, so missing is a value.
103
+ for (const candidate of candidates) {
104
+ if (lstatSync(join(candidate.dir, "package.json"), { throwIfNoEntry: false })) return candidate;
105
+ }
106
+ throw new InternalError({
107
+ message: "This pithy install is missing its starter template.",
108
+ action: "Reinstall @pithy-sh/cli. Report it if a fresh install does the same.",
109
+ detail: `no starter template under ${here} at ${candidates.map((candidate) => candidate.dir).join(" or ")}`,
110
+ });
111
+ }
112
+
113
+ /** {@link resolveTemplateSource}, for the callers that only want the path. */
114
+ export function resolveTemplateDir(moduleDir: string): string {
115
+ return resolveTemplateSource(moduleDir).dir;
116
+ }
117
+
118
+ function templateSource(): TemplateSource {
119
+ return resolveTemplateSource(dirname(fileURLToPath(import.meta.url)));
120
+ }
121
+
122
+ /**
123
+ * Every path from `root` (exclusive) down to `target` (inclusive), outermost first.
124
+ *
125
+ * The walk stops at `root` on purpose. `root` is the directory the adopter handed us — a project they may
126
+ * legitimately keep behind a symlink, and not ours to judge. What has to be real is every path *we* invent
127
+ * out of a name below it, because those are the ones an attacker or an accident can aim somewhere else.
128
+ *
129
+ * A `target` outside `root` is a bug at the call site, and it is precisely the bug that would make this
130
+ * walk useless: the loop would climb to the filesystem root having checked nothing anyone meant. So it
131
+ * throws rather than quietly returning a chain nobody asked for.
132
+ */
133
+ function descent(root: string, target: string): string[] {
134
+ const within = relative(resolve(root), resolve(target));
135
+ if (within.length === 0) return [];
136
+ if (within.startsWith("..") || isAbsolute(within)) {
137
+ throw new InternalError({
138
+ message: "Pithy tried to scaffold outside the project.",
139
+ action: "Report this — the command should not have built that path.",
140
+ detail: `${target} is not under ${root}`,
141
+ });
142
+ }
143
+ const steps: string[] = [];
144
+ let step = resolve(root);
145
+ for (const segment of within.split(sep)) {
146
+ step = join(step, segment);
147
+ steps.push(step);
148
+ }
149
+ return steps;
150
+ }
151
+
152
+ /**
153
+ * Throw unless every path between `root` and `target` — `target` itself included — is a real directory or
154
+ * missing. **The one answer to "is this path safe to scaffold into", and every gate routes through it.**
155
+ *
156
+ * This escape has had four producers. `ensureScaffoldable`'s `exists` (#111), `ensureEmptyTarget`'s
157
+ * `readdir` (#147), `scaffoldFiles`' `exists` and `renameWorker`'s `exists` (#152) — four hand-rolled
158
+ * predicates over `access` or a following `readdir`, each asking about *the destination of a link* while
159
+ * the caller then wrote to *the link's path*. Each fix landed in one of them and review found the rest. So
160
+ * the rule lives in one function, and `scaffold.test.ts` fails the build if a writing module rolls another.
161
+ *
162
+ * **`lstat`, and the whole chain of it.** `access` and `existsSync` follow links, so a dangling one reads
163
+ * as missing and clears any gate that asks them; `lstat` answers about the path itself. And asking only
164
+ * about `target` is the half-fix #147 shipped — a link at `apps` carries the scaffold out of the project
165
+ * exactly as completely as a link at `apps/<name>`, and `pithy worker add` walked through it.
166
+ *
167
+ * **Missing stops the walk**, because everything below a missing directory is missing too, and creating it
168
+ * is what the scaffold is for.
169
+ *
170
+ * A non-directory in the way is refused here too, rather than left to blow up later: `mkdir`, `cp` and
171
+ * `rename` all die on it with a raw `node:fs` ENOTDIR — outside the `PithyError` contract `withErrorReporting`
172
+ * prints from and `--json` callers parse — and by then the run is usually half-written.
173
+ */
174
+ export async function ensureScaffoldPath(root: string, target: string, intent: PathIntent = "write"): Promise<void> {
175
+ for (const step of descent(root, target)) {
176
+ const entry = await probe(lstat(step));
177
+ if (entry.state === "unanswerable") throw cannotSee(root, step, entry.reason);
178
+ if (entry.state === "missing") return; // missing, and so is everything below it
179
+ if (entry.value.isDirectory()) continue;
180
+
181
+ const named = relative(root, step);
182
+ throw new ConflictError(
183
+ entry.value.isSymbolicLink()
184
+ ? {
185
+ message: `${named} is a symlink.`,
186
+ action: THROUGH_A_LINK[intent],
187
+ detail: `refusing to reach ${target} through the symlink at ${step}`,
188
+ }
189
+ : {
190
+ message: `${named} isn't a directory.`,
191
+ action: "Move it aside, or pick another name, and run the command again.",
192
+ detail: `refusing to reach ${target} through the non-directory at ${step}`,
193
+ },
194
+ );
195
+ }
196
+ }
197
+
198
+ /**
199
+ * What the caller is about to do to the path — the only thing the refusals differ by.
200
+ *
201
+ * A delete borrowing the write refusal told the adopter "the files would land outside the project" about a
202
+ * command that was writing nothing, which is the one sentence they would act on and the one that was
203
+ * false. The *rule* is identical for all three, so it stays in one walk; only the sentence moves.
204
+ */
205
+ type PathIntent = "write" | "delete" | "move";
206
+
207
+ /**
208
+ * The sentence each intent gives an adopter who has a link in the way. One line each, and every one of
209
+ * them has to be true of the command that is asking — see {@link PathIntent}.
210
+ */
211
+ const THROUGH_A_LINK: Record<PathIntent, string> = {
212
+ write:
213
+ "Pithy won't scaffold through a link — the files would land outside the project. Remove it, or pick another name, and run the command again.",
214
+ delete:
215
+ "Pithy won't delete through a link — the tree removed would be outside the project. Remove the link, or check the name, and run the command again.",
216
+ move: "Pithy won't move through a link — the directory would leave the project, and the files rewritten after the move would land outside it. Remove the link, or check the name, and run the command again.",
217
+ };
218
+
219
+ /**
220
+ * What a filesystem probe established about a path — **three answers, never two.**
221
+ *
222
+ * The rule is {@link survivorsOf}'s, generalized: **only `ENOENT` means gone.** Anything else the probe
223
+ * cannot answer — `EACCES`, `ELOOP`, a mount that went away — is `unanswerable`, and reading it as gone is
224
+ * how a gate clears a path it never saw and a delete reports a tree it never removed.
225
+ *
226
+ * Exported for the test. Two of the three callers below are reachable only by racing the walk above, and a
227
+ * suite cannot stage that against a real `realpath` — but a gate whose second question can be skipped is a
228
+ * gate that asks one.
229
+ */
230
+ export type Probed<T> =
231
+ | { readonly state: "answered"; readonly value: T }
232
+ | { readonly state: "missing" }
233
+ | { readonly state: "unanswerable"; readonly reason: string };
234
+
235
+ /** Run a filesystem probe, keeping "it isn't there" apart from "I could not find out". */
236
+ export async function probe<T>(ask: Promise<T>): Promise<Probed<T>> {
237
+ try {
238
+ return { state: "answered", value: await ask };
239
+ } catch (err) {
240
+ const errno = errnoOf(err) ?? "unknown error";
241
+ return errno === "ENOENT" ? { state: "missing" } : { state: "unanswerable", reason: errno };
242
+ }
243
+ }
244
+
245
+ /**
246
+ * The refusal for a path the filesystem would not answer about.
247
+ *
248
+ * One sentence for all three intents, because it is the only one that is true of all three: nothing is
249
+ * claimed about what is at the path, only that we could not find out. The intent-specific wording above
250
+ * describes what Pithy *won't do through a link*, and there is no link here — there is no answer at all.
251
+ *
252
+ * The errno is throw-site context, in `detail`, where the HTTP codec strips it. What the adopter gets is
253
+ * the path and the fact that the command stopped rather than guessed.
254
+ */
255
+ function cannotSee(root: string, step: string, reason: string): PithyError {
256
+ return new ConflictError({
257
+ message: `Pithy couldn't check ${relative(root, step) || step}.`,
258
+ action:
259
+ "Something blocked the check — a permission, or a mount that went away. Clear that and run the command again.",
260
+ detail: `${reason} while checking ${step}`,
261
+ });
262
+ }
263
+
264
+ /**
265
+ * Delete `target` and everything under it — **the one answer to "may this path be removed", and the `rm`
266
+ * is inside it so no caller can route around it.**
267
+ *
268
+ * This escape had two producers and both were deletes: `pithy worker remove` on `apps/<name>` and
269
+ * `pithy remove <capability>` on an ejected `apps/<worker>/capabilities/<cap>`. Reproduced with the real
270
+ * CLI — a symlink at `apps` pointing at a canary directory, and `pithy worker remove board` removed the
271
+ * canary's whole `board/` tree and printed "Done."
272
+ *
273
+ * **The gate is stricter than {@link ensureScaffoldPath}, and it has to be.** Every other producer in this
274
+ * series writes a file somewhere it should not, and recovery is deleting the file; these remove a tree and
275
+ * there is nothing to recover. So two questions are asked rather than one:
276
+ *
277
+ * - Every component between the root and the target is a real directory or missing — the write gate,
278
+ * unchanged, so a link at `apps` or at `apps/<name>` is refused and named.
279
+ * - **And the path actually lands inside the project.** `ensureScaffoldPath` judges components one at a
280
+ * time and stops at the first missing one; this asks the kernel where the whole thing resolves to. A
281
+ * link swapped in between the walk and the `rm` ends here instead of in a recursive delete. The root
282
+ * itself is resolved the same way, because a project kept behind a symlink is the adopter's arrangement
283
+ * and none of our business.
284
+ *
285
+ * **`realpath` resolves symlinks and nothing else, so this is not a containment guarantee.** A bind
286
+ * mount and a hard-linked directory are not links: the kernel resolves such a path to itself, so a bind
287
+ * mount at `apps/` answers *inside the project* while the bytes it covers live anywhere the mounter
288
+ * chose, and the `rm` below follows it out. Telling one apart needs the mount table, which is
289
+ * platform-specific and not portable. Accepted, with the threat model that decides how much it matters:
290
+ * `docs/ACCEPTED-LIMITS.md`, "Bind mounts and hard-linked directories".
291
+ *
292
+ * **The root is never the target.** A gate that permits deleting the directory it is containing to permits
293
+ * everything, and no command here has any business removing the project.
294
+ *
295
+ * A target that is not there is not a delete: `rm` is `force`, so a caller rolling back a step that never
296
+ * ran gets a clean no-op rather than a refusal it would have to special-case. **Not there means `ENOENT`
297
+ * and nothing else** — see {@link Probed}. Both probes here read every other errno as "gone", so a
298
+ * `realpath` the kernel refused returned from this function having removed nothing, and the caller printed
299
+ * success. Through `pithy remove <cap>` it was worse: config and wrangler are unwired first, so the run
300
+ * ended with the capability unwired, its source entire on disk, and an audit record saying
301
+ * `capability/removed`, `outcome: "success"`. A false audit record is the one failure this project cannot
302
+ * treat as cosmetic. Reproduced with `chmod 0600` on `apps/` — readable, not searchable.
303
+ *
304
+ * **And a delete that fails part-way says which part.** See {@link removeFailure}: the `rm` threw a raw
305
+ * `node:fs` errno through the contract, after it had already emptied some of the tree.
306
+ */
307
+ export async function removeScaffoldPath(root: string, target: string): Promise<void> {
308
+ await ensureScaffoldPath(root, target, "delete");
309
+
310
+ const doomed = await probe(realpath(target));
311
+ if (doomed.state === "unanswerable") throw cannotSee(root, target, doomed.reason);
312
+ if (doomed.state === "missing") return; // nothing there — and `rm` is force, so nothing to do
313
+
314
+ // The root's own resolution, and its failure is a *different* sentence. Swallowed, it made this refusal
315
+ // say "it isn't inside the project" about a target nothing had established anything about — advice to
316
+ // treat a path as hostile, printed because the project directory could not be resolved.
317
+ const anchor = await probe(realpath(root));
318
+ if (anchor.state !== "answered") {
319
+ throw new ConflictError({
320
+ message: `Pithy couldn't resolve the project directory, so ${relative(root, target) || target} was left alone.`,
321
+ action: "Check the project directory is there and readable, then run the command again.",
322
+ detail: `${anchor.state === "missing" ? "ENOENT" : anchor.reason} while resolving ${root}`,
323
+ });
324
+ }
325
+
326
+ const within = relative(anchor.value, doomed.value);
327
+ if (within.length === 0 || within.startsWith("..") || isAbsolute(within)) {
328
+ throw new ConflictError({
329
+ message: `Refusing to delete ${relative(root, target) || target}: it isn't inside the project.`,
330
+ action: "Check what apps/ points at. If you didn't put it there, treat it as hostile.",
331
+ detail: `${target} resolves to ${doomed.value}, which is not under ${anchor.value}`,
332
+ });
333
+ }
334
+
335
+ try {
336
+ await rm(target, { recursive: true, force: true });
337
+ } catch (err) {
338
+ throw await removeFailure(root, target, err);
339
+ }
340
+ }
341
+
342
+ /** How many surviving paths a failed delete names. Enough to see the shape of what is left, not a listing. */
343
+ const SURVIVOR_SAMPLE = 5;
344
+
345
+ /**
346
+ * The error a `rm` that could not finish should have thrown all along.
347
+ *
348
+ * A recursive delete fails part-way for ordinary reasons — a directory the adopter chmod'd, a file another
349
+ * process holds open, a mount that went read-only — and it fails **after** removing whatever it got to
350
+ * first. What escaped here was the raw `node:fs` errno and its stack: outside the `PithyError` contract
351
+ * `withErrorReporting` prints from and `--json` callers parse, so a CI wrapper got unparseable output, and
352
+ * silent about the half-deleted tree, which is the part the adopter has to act on. A worker directory
353
+ * missing its `src/` and still holding its `wrangler.jsonc` is a worse state than the failure, and nothing
354
+ * said it had happened.
355
+ *
356
+ * So the survivors are read back and named. Best effort — this is already the failure path, and a scan that
357
+ * cannot run must not replace the error with its own.
358
+ */
359
+ async function removeFailure(root: string, target: string, err: unknown): Promise<PithyError> {
360
+ if (err instanceof PithyError) return err;
361
+ const named = relative(root, target) || target;
362
+ const left = await survivorsOf(target);
363
+ return new InternalError(
364
+ {
365
+ message: `Could not finish deleting ${named}. ${whatSurvived(left)}`,
366
+ action: "Something blocked it — a permission, or a file in use. Clear that and run the command again.",
367
+ // Two errnos when the scan failed too, because they are two different failures and the adopter's
368
+ // copy above deliberately carries neither: the one that stopped the delete, and the one that
369
+ // stopped us finding out what it left.
370
+ detail:
371
+ `${errnoOf(err) ?? "unknown error"} while removing ${target}` +
372
+ (left.state === "unknown" ? `; ${left.reason} while reading it back` : ""),
373
+ },
374
+ { cause: err },
375
+ );
376
+ }
377
+
378
+ /**
379
+ * What a failed delete left behind — **three states, never two.**
380
+ *
381
+ * The bug this shape exists to make unrepresentable: one `null` carried both "the target is gone" and
382
+ * "the scan threw", and {@link whatSurvived} rendered the pair as *"Nothing of it is left"*. An unreadable
383
+ * directory fails the `rm` and fails the scan for the same reason, so the one case where the adopter is
384
+ * told least was the case where the whole tree survived — and they were told the opposite of the truth.
385
+ * An error path that lies is worse than the raw errno it replaced: the adopter reads it, moves on, and
386
+ * the half-deleted worker stays on their disk.
387
+ *
388
+ * `unknown` carries the errno so the failure can say *why* it cannot answer, in `detail` where every
389
+ * other throw-site fact in this module lives.
390
+ */
391
+ export type Survivors =
392
+ | { readonly state: "gone" }
393
+ | { readonly state: "left"; readonly paths: readonly string[] }
394
+ | { readonly state: "unknown"; readonly reason: string };
395
+
396
+ /**
397
+ * Read back what is still under `target`, relative to it.
398
+ *
399
+ * `lstat` first, and it is the whole distinction: only `ENOENT` means gone. That rule is {@link probe}
400
+ * now, shared with the two gates above rather than stated once here — it was written down in this
401
+ * docstring while three sites nine lines up read every errno as "gone", which is how the delete gate came
402
+ * to report a removal it never made. Best effort about *what* is left, never about *whether* something is.
403
+ *
404
+ * A target that is there but not a directory is left with no paths under it: it is still there, which is
405
+ * the fact the adopter acts on.
406
+ *
407
+ * Exported for the test. `gone` needs the target to vanish between the `rm` and this call — a race no
408
+ * suite can stage against a real `rm`, and the only state the old sentence was ever true for.
409
+ */
410
+ export async function survivorsOf(target: string): Promise<Survivors> {
411
+ const entry = await probe(lstat(target));
412
+ if (entry.state === "missing") return { state: "gone" };
413
+ if (entry.state === "unanswerable") return { state: "unknown", reason: entry.reason };
414
+ if (!entry.value.isDirectory()) return { state: "left", paths: [] };
415
+ try {
416
+ return { state: "left", paths: (await readdir(target, { recursive: true })).sort() };
417
+ } catch (err) {
418
+ return { state: "unknown", reason: errnoOf(err) ?? "unknown error" };
419
+ }
420
+ }
421
+
422
+ /** The sentence that tells the adopter which half of the tree they are holding. Exported with {@link survivorsOf}. */
423
+ export function whatSurvived(left: Survivors): string {
424
+ if (left.state === "gone") return "Nothing of it is left, but the delete did not report finishing.";
425
+ if (left.state === "unknown") return "Pithy could not read it back, so what is left of it is unknown. Check it.";
426
+ if (left.paths.length === 0) return "It is empty, and still there.";
427
+ const rest = left.paths.length - SURVIVOR_SAMPLE;
428
+ const shown = left.paths.slice(0, SURVIVOR_SAMPLE).join(", ");
429
+ const counted = left.paths.length === 1 ? "1 path is" : `${left.paths.length} paths are`;
430
+ return `${counted} still there: ${shown}${rest > 0 ? `, and ${rest} more` : ""}.`;
431
+ }
432
+
433
+ /**
434
+ * Throw unless `targetDir` is missing or a real, empty directory, reached through real directories.
435
+ *
436
+ * This is the guard for a directory **Pithy owns outright** — `apps/<worker>`, which `scaffoldWorker`
437
+ * creates and fills. Nothing else may already live there, so emptiness is the right question. The
438
+ * project root is the adopter's directory and asks a narrower one: see {@link ensureScaffoldable}.
439
+ *
440
+ * Safety is {@link ensureScaffoldPath}'s answer, not this function's, which is the fix #152 asked for:
441
+ * #147 left this gate lstat-ing `apps/<worker>` and nothing above it, so a symlink at `apps` still carried
442
+ * the whole worker outside the project. Emptiness is all that is left here, and {@link occupied} asks it.
443
+ *
444
+ * The gate also has to run *before* anything is created. `addWorker` rolls `apps/<worker>` back on failure,
445
+ * and `rm` unlinks a symlink rather than its destination — so escaped files would have stayed outside the
446
+ * project while the command reported a clean rollback. Refusing first puts that path out of reach, which is
447
+ * why `scaffoldWorker` calls this before its `mkdir` and not after.
448
+ */
449
+ export async function ensureEmptyTarget(root: string, targetDir: string): Promise<void> {
450
+ await ensureScaffoldPath(root, targetDir);
451
+ if (!(await occupied(targetDir))) return;
452
+ throw new ConflictError({
453
+ message: `${targetDir} isn't an empty directory.`,
454
+ action: "Move what's there aside, or pick another name, and run the command again.",
455
+ });
456
+ }
457
+
458
+ /**
459
+ * True if anything is at `path` — including a symlink whose target is gone.
460
+ *
461
+ * `lstat`, not `access`, for the reason {@link blocksDirectory} gives: the link itself is the thing in
462
+ * the way. `access` follows it, so a **dangling** symlink at a template file path answered "does not
463
+ * exist", cleared the gate, and was never named in the refusal — and then `cp` and `stampPackageName`
464
+ * both wrote *through* the link, landing the scaffolded file outside `targetDir` while the run reported
465
+ * success. Node and Bun do not even agree on that copy, which makes it worse rather than narrower: the
466
+ * unit tests and the shipped CLI would answer differently on one input.
467
+ *
468
+ * Exported for the same reason {@link ensureScaffoldPath} is: `scaffoldFiles` and `renameWorker` each had
469
+ * their own copy of this over `access`, and each got it wrong. One predicate, one place to get it wrong.
470
+ */
471
+ export async function pathExists(path: string): Promise<boolean> {
472
+ try {
473
+ await lstat(path);
474
+ return true;
475
+ } catch {
476
+ return false;
477
+ }
478
+ }
479
+
480
+ /**
481
+ * True if `path` is anything other than a real directory — a file, or a symlink, even one pointing at a
482
+ * directory. Missing is fine: the scaffold creates it.
483
+ *
484
+ * `lstat`, not `stat`, because the symlink itself is the problem. `cp` refuses to copy a directory onto
485
+ * a symlinked one (`ERR_FS_CP_DIR_TO_NON_DIR`) and `rename` onto one is `ENOTDIR`, so following the link
486
+ * would answer a question nobody asked.
487
+ */
488
+ async function blocksDirectory(path: string): Promise<boolean> {
489
+ try {
490
+ return !(await lstat(path)).isDirectory();
491
+ } catch {
492
+ return false;
493
+ }
494
+ }
495
+
496
+ /**
497
+ * True unless `path` is missing or a real, empty directory — the question to ask of a path Pithy takes
498
+ * over outright. {@link ensureEmptyTarget} is that question asked of `apps/<worker>` under
499
+ * `pithy worker add`; `ensureScaffoldable` asks it of the rename source and destination.
500
+ *
501
+ * `lstat` first, and that is the security half: a symlink is not a directory here however empty its
502
+ * destination reads, because the scaffold would write through it and land outside the project.
503
+ *
504
+ * Two `try`s, because the two calls fail for opposite reasons. A missing directory is nothing to take
505
+ * over; a directory that cannot be *read* is certainly occupied. The read used to sit outside any `try`
506
+ * at all, so an unreadable `apps/<worker>` threw a raw `node:fs` error straight through the `PithyError`
507
+ * contract this module and `withErrorReporting` both promise — `pithy init --json` printed a stack trace
508
+ * where a CI wrapper parses `{"error":{…}}`.
509
+ */
510
+ async function occupied(path: string): Promise<boolean> {
511
+ try {
512
+ if (!(await lstat(path)).isDirectory()) return true;
513
+ } catch {
514
+ return false; // missing — nothing to take over
515
+ }
516
+ try {
517
+ return (await readdir(path)).length > 0;
518
+ } catch {
519
+ return true; // unreadable — not empty as far as anyone can tell, and not ours to take
520
+ }
521
+ }
522
+
523
+ /**
524
+ * Template files that land under a different name, source → target.
525
+ *
526
+ * Two files cannot ship under the name they land as. `gitignore` because npm strips dotfiles from a
527
+ * published package. `biome.template.jsonc` because Biome discovers `biome.jsonc` by name and refuses a
528
+ * nested one inside a repository that already has a root config — shipping it as-is broke *this* repo's
529
+ * own `biome check .`, which is a fair warning about what it would do inside any monorepo that vendored
530
+ * the template.
531
+ */
532
+ export const RENAMED_ON_LANDING: Record<string, string> = {
533
+ gitignore: ".gitignore",
534
+ "biome.template.jsonc": "biome.jsonc",
535
+ };
536
+
537
+ /**
538
+ * What the starter is allowed to hand an adopter: **its committed files, and the directories holding
539
+ * them** — relative to the template, sorted.
540
+ *
541
+ * The index is the allowlist, for the reason {@link committedFiles} gives. `pithy init` copied the
542
+ * template directory wholesale, so from a checkout it also copied whatever the maintainer's working tree
543
+ * happened to hold — and it held `.dev.vars`, the file `pithy add` and `pithy token mint` write
544
+ * `CLOUDFLARE_API_TOKEN` and `SECRETS_ENCRYPTION_KEYS` into. Reproduced: a maintainer's live token in a
545
+ * stranger's brand-new project, mode 0664 because `cp` copies the source's, and nothing downstream
546
+ * looked twice at a `.dev.vars` that was already there. `git status` said nothing: the file is ignored.
547
+ *
548
+ * #145 read the index for the *published tarball* and stopped at the packer. This is the same rule for
549
+ * the other reader of the same directory.
550
+ *
551
+ * **No index is two different facts, and only one of them is safe.** `committedFiles` answers `null` for
552
+ * "this is not a checkout" *and* for "git is not installed", "the repository is broken", "nothing here is
553
+ * tracked yet". #145's fix read them all as the first, so on a real checkout where git failed for any
554
+ * reason at all, `pithy init` went straight back to copying whatever the maintainer's working tree held —
555
+ * `.dev.vars` included — silently, and only on the machines where that is hardest to notice.
556
+ *
557
+ * {@link TemplateSource.vendored} is what tells them apart, and it comes from the layout rather than from
558
+ * a probe: the copy an installed `@pithy-sh/cli` carries was built by `prepack` from this same allowlist
559
+ * and has no `.git` beside it to ask, so it is taken as it stands. A checkout's template is tracked and
560
+ * always has an index — so `null` there means the question failed, and the run refuses rather than
561
+ * guessing. Refusing the vendored case too would break `pithy init` for every adopter to protect a
562
+ * checkout none of them have; excusing the checkout case is how the leak came back.
563
+ *
564
+ * Exported for the test: the branch that matters is the one that only happens on a machine where git is
565
+ * broken, and a suite that runs in a healthy checkout can never reach it any other way.
566
+ */
567
+ export async function templateContents(source: TemplateSource): Promise<{ files: string[]; directories: string[] }> {
568
+ const committed = committedFiles(source.dir);
569
+ if (committed !== null) {
570
+ const directories = new Set<string>();
571
+ for (const path of committed) {
572
+ for (let parent = dirname(path); parent !== "."; parent = dirname(parent)) directories.add(parent);
573
+ }
574
+ return { files: committed, directories: [...directories].sort() };
575
+ }
576
+
577
+ if (!source.vendored) {
578
+ throw new InternalError({
579
+ message: "Pithy couldn't ask git what the starter template ships.",
580
+ action:
581
+ "Install git and run this from a complete checkout — or install @pithy-sh/cli, which carries its own template. Pithy won't copy the template directory unread.",
582
+ detail: `git listed nothing committed under ${source.dir}, and that path is a checkout's template, not a vendored one`,
583
+ });
584
+ }
585
+
586
+ const entries = await readdir(source.dir, { recursive: true, withFileTypes: true });
587
+ const named = entries.map((entry) => ({
588
+ path: relative(source.dir, join(entry.parentPath, entry.name)),
589
+ directory: entry.isDirectory(),
590
+ }));
591
+ return {
592
+ files: named.filter((entry) => !entry.directory).map(({ path }) => path),
593
+ directories: named.filter((entry) => entry.directory).map(({ path }) => path),
594
+ };
595
+ }
596
+
597
+ /**
598
+ * Every path {@link scaffoldProject} writes, relative to the target — read from the template rather
599
+ * than listed here, so a file added to the starter is covered without anyone remembering to.
600
+ *
601
+ * Files and directories are separated because the two ask different questions. A file that already
602
+ * exists is a clobber. A directory that already exists is fine — `cp` merges into it — but a *file* or a
603
+ * symlink where one belongs kills `mkdir` and `cp` outright, and the gate has to see that before the
604
+ * copy starts rather than halfway through it.
605
+ *
606
+ * Two adjustments, both because the copy is not a straight copy. Each file in {@link RENAMED_ON_LANDING}
607
+ * is checked under **both** names: the copy writes over the shipped name and the rename then moves it
608
+ * away, which destroyed an adopter's own undotted `gitignore` without ever naming it. And the first
609
+ * worker is copied to `apps/api` and *then* renamed, so a run naming another worker also collides on
610
+ * `apps/<worker>`.
611
+ *
612
+ * Nothing is added to the walk. It used to carry `apps/<worker>/.dev.vars`, because the scaffold wrote
613
+ * one there and the template ships only `.dev.vars.example` — so the one path `init` wrote that held
614
+ * secrets was the one path this walk could not see, and a pre-existing worker `.dev.vars` was replaced
615
+ * with a link to a file that was not theirs. `init` writes no `.dev.vars` at all now (#154): each one is
616
+ * generated, and the generator refuses any file it did not write itself, by name.
617
+ */
618
+ async function templatePaths(worker: string): Promise<{ files: string[]; directories: string[] }> {
619
+ const contents = await templateContents(templateSource());
620
+
621
+ const files = contents.files.flatMap((path) => {
622
+ const landed = RENAMED_ON_LANDING[path];
623
+ return landed ? [path, landed] : [path];
624
+ });
625
+ const directories = [...contents.directories];
626
+ if (worker === DEFAULT_WORKER) return { files, directories };
627
+
628
+ const from = `apps${sep}${DEFAULT_WORKER}${sep}`;
629
+ const rename = (paths: string[]): string[] =>
630
+ paths.filter((path) => path.startsWith(from)).map((path) => `apps${sep}${worker}${sep}${path.slice(from.length)}`);
631
+ return {
632
+ files: [...files, ...rename(files)],
633
+ directories: [...directories, `apps${sep}${worker}`, ...rename(directories)],
634
+ };
635
+ }
636
+
637
+ /**
638
+ * Copy the starter into `targetDir` — every path {@link templateContents} allows, and no other.
639
+ *
640
+ * The filter is the whole point: `cp` with `recursive` and nothing else copied the directory as it sits
641
+ * on the maintainer's disk. It runs on directories too, and a `false` there skips the subtree, so every
642
+ * ancestor of an allowed file has to be allowed with it — which is what {@link templateContents} returns
643
+ * the second list for.
644
+ *
645
+ * Held to the same allowlist {@link templatePaths} builds the collision gate from, and that is not a
646
+ * coincidence worth leaving to chance: a gate that refuses over a file the copy would never write is a
647
+ * `pithy init` that cannot run in a directory it has no quarrel with.
648
+ */
649
+ async function copyTemplate(targetDir: string): Promise<void> {
650
+ const source = templateSource();
651
+ const contents = await templateContents(source);
652
+ const allowed = new Set([...contents.files, ...contents.directories]);
653
+ await cp(source.dir, targetDir, {
654
+ recursive: true,
655
+ filter: (candidate) => {
656
+ const path = relative(source.dir, candidate);
657
+ return path.length === 0 || allowed.has(path);
658
+ },
659
+ });
660
+ }
661
+
662
+ /**
663
+ * Throw if the target already holds anything `pithy init` would write, naming what.
664
+ *
665
+ * **Collision, not emptiness.** A directory holding only `.git`, a README, a license, a CLAUDE.md, or an
666
+ * editor config is not a project — and refusing it meant `pithy init` could not scaffold into a repo the
667
+ * adopter had just cloned, which is how projects normally start. What actually protects them is the
668
+ * narrower question: is anything I am about to write already there. That still refuses to clobber a real
669
+ * project, and stops caring about the rest.
670
+ *
671
+ * **Except where the scaffold moves rather than copies.** Naming a worker other than the default makes
672
+ * `scaffoldProject` rename `apps/api` onto `apps/<worker>`, and a rename is not a merge: it fails on an
673
+ * occupied destination and carries an occupied source wholesale into the new name. So those two paths
674
+ * are held to emptiness, not to collision. Get that wrong and the run dies on a raw `ENOTEMPTY` from
675
+ * `node:fs` — after the copy, with the root half-written, and outside the `PithyError` contract every
676
+ * other refusal here honors.
677
+ *
678
+ * The precondition `pithy init` checks *before* prompting, so a doomed run fails fast instead of after
679
+ * the user answers. A missing directory passes — `scaffoldProject` creates it, and re-checks, so the
680
+ * guard holds even called direct. The worker name is validated first, because every path below is built
681
+ * out of it and an illegal one would send the probe walking outside the project.
682
+ */
683
+ export async function ensureScaffoldable(targetDir: string, worker?: string): Promise<void> {
684
+ const name = worker ?? DEFAULT_WORKER;
685
+ assertWorkerName(name);
686
+ const { files, directories } = await templatePaths(name);
687
+
688
+ const collisions = new Set<string>();
689
+ for (const path of files) {
690
+ if (await pathExists(join(targetDir, path))) collisions.add(path);
691
+ }
692
+ for (const path of directories) {
693
+ if (await blocksDirectory(join(targetDir, path))) collisions.add(path);
694
+ }
695
+ if (name !== DEFAULT_WORKER) {
696
+ for (const path of [join("apps", DEFAULT_WORKER), join("apps", name)]) {
697
+ if (await occupied(join(targetDir, path))) collisions.add(path);
698
+ }
699
+ }
700
+
701
+ if (collisions.size === 0) return;
702
+ throw new ConflictError({
703
+ message: `${targetDir} already has ${[...collisions].sort().join(", ")}.`,
704
+ action: "Move those aside, or pick a directory without them. Run pithy init again.",
705
+ });
706
+ }
707
+
708
+ /**
709
+ * Refuse a project name inside the reserved test namespace, before anything is written.
710
+ *
711
+ * This is the one place the reservation is enforced. Every provisioned name leads with the project
712
+ * (`<project>-<env>-<thing>`, project verbatim), so a project outside the namespace cannot generate a
713
+ * name inside it — and the debris reaper, which deletes on that prefix alone, can never reach a real
714
+ * project's resources. Every future capability inherits the guarantee for free.
715
+ *
716
+ * **Creation only, never resolution.** The suites are *meant* to run as `pithy-int-test`, so every
717
+ * resolver — `requireProjectName` included — must keep accepting the name. It is only minting a new
718
+ * project under it that is refused.
719
+ *
720
+ * The predicate is {@link isReservedProjectName}, in `@pithy-sh/core` beside the composer, because the
721
+ * comparison it makes is a fact about how names are composed rather than about scaffolding.
722
+ */
723
+ function assertNotReserved(appName: string): void {
724
+ if (!isReservedProjectName(appName)) return;
725
+ throw new ValidationError({
726
+ message: `"${RESERVED_TEST_PREFIX}" is reserved — Pithy's integration tests own that name, and their cleanup deletes everything under it.`,
727
+ action: "Pick a project name that doesn't start with pithy-int-. Run pithy init again.",
728
+ });
729
+ }
730
+
731
+ /** The Worker `pithy init` scaffolds first. Every Worker lives in `apps/<name>/`; this is just the default one. */
732
+ /**
733
+ * The scaffolded app capability's name — which is also its **migration namespace**, and namespaces admit no
734
+ * separators (`NAMESPACE_PATTERN`, `^[a-z][a-z0-9]*$`). A worker directory is kebab-case, so the two cannot
735
+ * be the same string: stamping `admin-api` verbatim writes a config whose first migration is rejected.
736
+ *
737
+ * So the directory stays kebab-case and the namespace is derived from it — hyphens dropped, keeping the
738
+ * worker's identity (`admin-api` → `adminapi`, distinct from every sibling's). A name that starts with a
739
+ * digit cannot open a namespace, so it takes the `app` prefix the starter's own capability uses
740
+ * (`2fa-api` → `app2faapi`).
741
+ */
742
+ export function workerNamespace(name: string): string {
743
+ const stripped = name.replace(/[^a-z0-9]/g, "");
744
+ return NAMESPACE_PATTERN.test(stripped) ? stripped : `app${stripped}`;
745
+ }
746
+
747
+ export const DEFAULT_WORKER = "api";
748
+
749
+ /**
750
+ * A worker name is a kebab-case directory under `apps/` — the same shape a package name takes.
751
+ *
752
+ * **Deliberately looser than `NAME_SEGMENT`** (`@pithy-sh/core/src/naming/segment`), which every
753
+ * *Cloudflare* name segment answers to: this one allows a leading digit, because `apps/2fa` is a
754
+ * legitimate directory and a legitimate package name. It never leads a composed name — a worker's
755
+ * script name is `<app>-<worker>` and its feature name is `<project>-f<issue>-<slug>-<worker>`, both
756
+ * of which lead with a letter-leading project — so the strict rule would refuse a name that is legal
757
+ * everywhere it is actually used. Divergence on purpose, not a stale copy.
758
+ */
759
+ export const WORKER_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
760
+
761
+ /**
762
+ * Refuse a worker name that could not be a directory under `apps/`.
763
+ *
764
+ * Called by {@link ensureScaffoldable} as well as by {@link scaffoldProject}, because the gate builds
765
+ * `apps/<worker>/…` out of the name before anything else has looked at it: `--worker ../../etc` had it
766
+ * probing paths outside the project and reporting the hits back.
767
+ */
768
+ function assertWorkerName(worker: string): void {
769
+ if (WORKER_NAME.test(worker)) return;
770
+ throw new ValidationError({
771
+ message: `Worker name must be kebab-case (got "${worker}").`,
772
+ action: "Use lowercase words joined by hyphens, e.g. api or admin-api.",
773
+ });
774
+ }
775
+
776
+ /** Stamp `appName` into a JSON file's `name` field, preserving the rest. */
777
+ async function stampPackageName(path: string, name: string): Promise<void> {
778
+ const pkg = JSON.parse(await readFile(path, "utf8")) as { name: string };
779
+ pkg.name = name;
780
+ await writeFile(path, `${JSON.stringify(pkg, null, 2)}\n`);
781
+ }
782
+
783
+ /** The version every package in this workspace carries until Changesets cuts a release. */
784
+ const UNPUBLISHED = "0.0.0";
785
+
786
+ /**
787
+ * The range a scaffolded Worker should declare for the kit at `version` — or **null**, meaning declare
788
+ * nothing at all.
789
+ *
790
+ * This is the {@link https://github.com/pithy-sh/pithy/issues/112 #112} rule, applied to the template.
791
+ * Nothing under `@pithy-sh/*` is published, so a range is a promise the registry cannot keep: the
792
+ * template's `"^0.0.0"` 404s the very first `bun install` a new project runs, before any of the tooling
793
+ * that would have helped gets to run at all. A project consuming the kit from a checkout resolves it
794
+ * from `node_modules` either way — the same reason `pithy ui add` omits `@pithy-sh/vite` — so the
795
+ * absent line costs that project nothing and the failed install costs it everything.
796
+ *
797
+ * **The day the packages publish this inverts, and the same line handles it.** `version` is core's own,
798
+ * stamped by `scripts/stampVersions.ts` from the package.json Changesets rewrites — so the first release
799
+ * makes it real, the range gets written, and a scaffolded project installs the kit from npm with no code
800
+ * change here. `0.0.0` is not a version anyone releases; it is precisely the marker for "not released",
801
+ * which is why the whole rule fits in one comparison.
802
+ */
803
+ export function kitRange(version: string): string | null {
804
+ return version === UNPUBLISHED ? null : `^${version}`;
805
+ }
806
+
807
+ /**
808
+ * What to tell an adopter whose freshly written manifest declares no `@pithy-sh/*` dependency — or
809
+ * **null**, meaning there is nothing to say because the range was written.
810
+ *
811
+ * Three commands scaffold a manifest {@link kitRange} can drop a line from, and all three must say the
812
+ * same thing: `pithy init` (the worker's `@pithy-sh/core`), `pithy worker add` (the same), and
813
+ * `pithy ui add` (`@pithy-sh/vite`). Only `init` did. The other two dropped the line and then said
814
+ * "Done." — `worker add` scaffolding a `src/index.ts` that imports a package its `package.json` does not
815
+ * declare, `ui add` following up with "Install the packages: npm install. Then pithy dev.", which
816
+ * installs cleanly and then fails the build on a Vite plugin nothing asked for. A silent gap is worse
817
+ * than a loud one: the adopter meets it as an unresolved import on an unrelated command.
818
+ *
819
+ * One function rather than three literals, because the wording is the contract. Lines rather than a
820
+ * string, so each caller dims and places them itself.
821
+ */
822
+ export function unpublishedKitNotice(): string[] | null {
823
+ if (kitRange(PACKAGE_VERSION) !== null) return null;
824
+ return [
825
+ "@pithy-sh/* isn't published yet, so this worker declares no kit dependency.",
826
+ "Link the kit from a checkout, then install.",
827
+ ];
828
+ }
829
+
830
+ /**
831
+ * Stamp the scaffolded Worker's manifest: the package name, and the kit dependency at a range that can
832
+ * actually resolve ({@link kitRange}).
833
+ *
834
+ * One read-modify-write for both, because they are one file — a second pass over it is a second chance to
835
+ * leave it half-stamped.
836
+ *
837
+ * Only core is touched, keyed on the name core reports for itself rather than a literal. Changesets
838
+ * versions these packages independently (`.changeset/config.json` links and fixes nothing), so core's
839
+ * version is a fact about core alone and there is no honest range to invent for a sibling from it.
840
+ * `scaffold.test.ts` holds the template to exactly that: declare a second `@pithy-sh/*` dependency and it
841
+ * fails until this function is taught that package's version.
842
+ */
843
+ async function stampWorkerManifest(path: string, name: string): Promise<void> {
844
+ const pkg = JSON.parse(await readFile(path, "utf8")) as { name: string; dependencies?: Record<string, string> };
845
+ pkg.name = name;
846
+ const range = kitRange(PACKAGE_VERSION);
847
+ if (pkg.dependencies && PACKAGE_NAME in pkg.dependencies) {
848
+ if (range === null) delete pkg.dependencies[PACKAGE_NAME];
849
+ else pkg.dependencies[PACKAGE_NAME] = range;
850
+ }
851
+ await writeFile(path, `${JSON.stringify(pkg, null, 2)}\n`);
852
+ }
853
+
854
+ /**
855
+ * The commented line the starter's root config ships, and the one this replaces when a project declares
856
+ * something other than the default. Matched literally: a scaffold that guesses at the shape of a file is
857
+ * how a scaffold eats an edit, and a miss here leaves the template's own comment rather than a wrong line.
858
+ */
859
+ const ENVIRONMENTS_PLACEHOLDER = ` // environments: ${renderEnvironments(DEFAULT_ENVIRONMENTS)},`;
860
+
861
+ /** `["staging", "prod"]` — the form an adopter would type, not `JSON.stringify`'s comma-tight one. */
862
+ function renderEnvironments(environments: readonly string[]): string {
863
+ return `[${environments.map((environment) => JSON.stringify(environment)).join(", ")}]`;
864
+ }
865
+
866
+ /**
867
+ * The `environments` line for the scaffolded root config — **uncommented only when it says something**.
868
+ *
869
+ * A project on the default pair gets the template's commented example, because a declaration that repeats
870
+ * the default is a line an adopter has to read and learn nothing from. A project that answered the prompt
871
+ * differently gets a real declaration, because from that moment the file is the only place the answer
872
+ * lives and every command reads it back.
873
+ */
874
+ function renderEnvironmentsBlock(environments: readonly string[]): string {
875
+ const isDefault =
876
+ environments.length === DEFAULT_ENVIRONMENTS.length &&
877
+ environments.every((environment, index) => environment === DEFAULT_ENVIRONMENTS[index]);
878
+ return isDefault ? ENVIRONMENTS_PLACEHOLDER : ` environments: ${renderEnvironments(environments)},`;
879
+ }
880
+
881
+ /**
882
+ * Rewrite the first Worker's `env.<name>` stanzas to be the project's declared environments (#241).
883
+ *
884
+ * ## Why `init` still writes stanzas at all
885
+ *
886
+ * #241 proposes the invariant *a stanza exists if and only if that environment is provisioned*, with
887
+ * provisioning as the only writer. That is the right end state and it is #240's to build. It is not
888
+ * something this can do half of, because **`pithy add <capability>` writes a capability's bindings into
889
+ * the stanzas that already exist and creates none** (`capabilities/add.ts`). Ship a fresh project with no
890
+ * `env.*` and the very next `pithy add auth` binds `dev` and silently leaves staging and prod unbound —
891
+ * a worse silence than the one this issue set out to close, and one nothing would report.
892
+ *
893
+ * So the stanzas stay, and what changes is where their *names* come from: the declaration, rather than
894
+ * whichever two the template happened to ship. The bindings inside them stay empty, which is the honest
895
+ * part — an empty `d1_databases` claims nothing, where a `database_name` with no `database_id` asserts a
896
+ * database that has never existed.
897
+ *
898
+ * Nothing here deletes a stanza on an existing project: this runs once, on a directory `init` just
899
+ * created, and #142's rule — an adopter's stanza is theirs — is untouched.
900
+ */
901
+ async function stampEnvironmentStanzas(
902
+ workerDir: string,
903
+ identity: { project: string; worker: string; environments: readonly string[] },
904
+ ): Promise<void> {
905
+ const config = (await readWranglerConfig(workerDir)) as { env?: Record<string, unknown> };
906
+ const declared = [...identity.environments];
907
+ // The template already ships the default pair, comments and all. Rewriting it to produce the same
908
+ // names would only cost the prose that explains them.
909
+ if (JSON.stringify(Object.keys(config.env ?? {})) === JSON.stringify(declared)) return;
910
+ // The template's note above `env` names staging and production. For any other set it would be a false
911
+ // sentence in a checked-in file, which is the failure mode this whole issue is about.
912
+ delete (config as Record<symbol, unknown>)[Symbol.for("before:env")];
913
+ config.env = Object.fromEntries(
914
+ declared.map((environment) => [
915
+ environment,
916
+ {
917
+ // All three repeat per stanza: `env.<name>.vars` REPLACES the top-level block, never merges it.
918
+ vars: { ENVIRONMENT: environment, PROJECT: identity.project, WORKER: identity.worker },
919
+ d1_databases: [],
920
+ kv_namespaces: [],
921
+ },
922
+ ]),
923
+ );
924
+ await writeWranglerConfig(workerDir, config);
925
+ }
926
+
927
+ /**
928
+ * Copy the starter template into `targetDir` and stamp the app name — the pure logic behind `pithy init`.
929
+ *
930
+ * The scaffold is the `apps/` layout: the root carries project identity and policy (`pithy.config.ts`,
931
+ * `package.json` with the `apps/*` workspace), and the first Worker lives in `apps/api/` with its own
932
+ * `pithy.config.ts`, `wrangler.jsonc`, and `pithy.worker.jsonc`. There is no root Worker — `pithy worker add`
933
+ * is then purely additive, and each Worker's capabilities, bindings, and DO class migrations attach to it.
934
+ *
935
+ * It also carries the project's gates — a root `tsconfig.json` solution file, a split Vitest config, a
936
+ * Biome config, and the `typecheck`/`test`/`lint` scripts that run them. A scaffold that can be deployed
937
+ * but not checked is a scaffold whose adopter builds the checking themselves, every time.
938
+ *
939
+ * Two files land under a different name than they ship as — see {@link RENAMED_ON_LANDING}.
940
+ */
941
+ export async function scaffoldProject(options: ScaffoldOptions): Promise<void> {
942
+ // Both name guards run before the directory is created, let alone copied into: a refusal must leave
943
+ // nothing behind. Legality first — an illegal name is not a name, reserved or not.
944
+ //
945
+ // `assertValidProjectName` is here rather than only downstream because the namespaces disagree and the
946
+ // permissive ones come first. A digit-leading project scaffolds, adds capabilities, and provisions real
947
+ // D1, KV, and R2; it is the first host-worker deploy that refuses it, and by then renaming the project —
948
+ // the only fix — orphans everything already created. The one moment it costs nothing is this one.
949
+ assertValidProjectName(options.appName);
950
+ assertNotReserved(options.appName);
951
+
952
+ // The declaration is checked with the names, before the directory exists: an environment name reaches
953
+ // Cloudflare resource names verbatim, and `init` is the one moment a project can still be told no.
954
+ // Through the config loader, so a name refused here is refused with the same sentence `loadProject`
955
+ // would give it the next time anyone opened the file.
956
+ const environments = loadProjectEnvironments({ environments: options.environments });
957
+
958
+ /**
959
+ * The one form of the name that gets written anywhere.
960
+ *
961
+ * `assertValidProjectName` accepts what `kebab` *would* normalize to a legal segment, so `Acme` clears
962
+ * it — and every command that later composes a resource name reads the project back through
963
+ * `requireProjectName`, which kebabs. Stamping the raw string would therefore write a project name no
964
+ * resource carries, and one Cloudflare refuses outright: wrangler rejects `"name": "Acme-api"` at
965
+ * config-parse time ("alphanumeric and lowercase with dashes only"), so an uppercase `--name` scaffolded
966
+ * a project that could not deploy or even run `wrangler dev`.
967
+ *
968
+ * Normalizing here rather than at each stamp is what keeps the config honest: what the adopter reads in
969
+ * `pithy.config.ts` is the exact first segment of every Cloudflare resource this project provisions.
970
+ */
971
+ const project = kebab(options.appName);
972
+
973
+ // The template ships its first worker as `apps/<DEFAULT_WORKER>`; rename it when the caller chose
974
+ // another name, so the directory, the deploy name, and the capability namespace all agree. Resolved
975
+ // *before* the collision check, because the check has to know which `apps/<name>` the copy ends at.
976
+ const worker = options.worker ?? DEFAULT_WORKER;
977
+ assertWorkerName(worker);
978
+
979
+ await mkdir(options.targetDir, { recursive: true });
980
+ await ensureScaffoldable(options.targetDir, worker);
981
+
982
+ await copyTemplate(options.targetDir);
983
+ for (const [shipped, landed] of Object.entries(RENAMED_ON_LANDING)) {
984
+ await rename(join(options.targetDir, shipped), join(options.targetDir, landed));
985
+ }
986
+
987
+ const workerDir = join(options.targetDir, "apps", worker);
988
+ if (worker !== DEFAULT_WORKER) {
989
+ await rename(join(options.targetDir, "apps", DEFAULT_WORKER), workerDir);
990
+ }
991
+
992
+ await stampPackageName(join(options.targetDir, "package.json"), project);
993
+ await stampWorkerManifest(join(workerDir, "package.json"), `${project}-${worker}`);
994
+
995
+ // The project's identity — the prefix every feature resource name derives from.
996
+ const configPath = join(options.targetDir, "pithy.config.ts");
997
+ const config = await readFile(configPath, "utf8");
998
+ // A replacement *function*, not a replacement string, so `$&` and `$1` could never be read as patterns.
999
+ // `project` is kebabed and cannot contain either today; the function stays because it costs nothing and
1000
+ // the guard, not the call site, is what makes that true.
1001
+ await writeFile(
1002
+ configPath,
1003
+ config
1004
+ .replace('name: "pithy-app"', () => `name: "${project}"`)
1005
+ .replace(ENVIRONMENTS_PLACEHOLDER, () => renderEnvironmentsBlock(environments)),
1006
+ );
1007
+
1008
+ // Three stamps into the worker's wrangler.jsonc. `name` is the deploy name (project + worker);
1009
+ // `PROJECT` is the project alone; `WORKER` is this Worker's own directory name. `PROJECT` and the
1010
+ // deploy name are the kebabed form — the string `requireProjectName` hands every command that composes
1011
+ // a `<project>-<env>-<thing>` name. A `PROJECT` that differed would attribute the Worker's
1012
+ // Images/Stream assets to a project no sweep filters on, and a `name` that differed would not deploy.
1013
+ //
1014
+ // `WORKER` is keyed off `DEFAULT_WORKER` rather than a literal, because the template ships that name
1015
+ // and the directory has just been renamed to `worker` above — a literal here would be two places to
1016
+ // change and one of them would be forgotten.
1017
+ //
1018
+ // `replaceAll`, because `env.<name>.vars` replaces rather than merges, so each placeholder appears once
1019
+ // per environment stanza and a first-occurrence replace would leave staging and prod owned by `pithy-app`.
1020
+ const wranglerPath = join(workerDir, "wrangler.jsonc");
1021
+ const wrangler = await readFile(wranglerPath, "utf8");
1022
+ await writeFile(
1023
+ wranglerPath,
1024
+ wrangler
1025
+ .replace('"name": "pithy-app"', () => `"name": "${project}-${worker}"`)
1026
+ .replaceAll('"PROJECT": "pithy-app"', () => `"PROJECT": "${project}"`)
1027
+ .replaceAll(`"WORKER": "${DEFAULT_WORKER}"`, () => `"WORKER": "${worker}"`),
1028
+ );
1029
+
1030
+ await stampEnvironmentStanzas(workerDir, { project, worker, environments });
1031
+
1032
+ // The worker's own `pithy.config.ts` was copied verbatim, so on any project not scaffolded with the
1033
+ // default worker it named a worker that does not exist — three times. The header pointed at
1034
+ // `apps/api/`, the comment above the managed region told the adopter to run
1035
+ // `pithy add <capability> --worker api`, and the app capability took the template's flat `"app"` where
1036
+ // `pithy worker add` derives a namespace from the worker's own name.
1037
+ //
1038
+ // `pithy worker add` got all three right by generating the file rather than copying one. Two producers
1039
+ // of the same file disagreeing is the shape #136 and #144 were both about, and `scaffoldParity.test.ts`
1040
+ // now holds these two to each other.
1041
+ const workerConfigPath = join(workerDir, "pithy.config.ts");
1042
+ const workerConfig = await readFile(workerConfigPath, "utf8");
1043
+ await writeFile(
1044
+ workerConfigPath,
1045
+ workerConfig
1046
+ .replaceAll(`apps/${DEFAULT_WORKER}/pithy.config.ts`, () => `apps/${worker}/pithy.config.ts`)
1047
+ .replaceAll(`--worker ${DEFAULT_WORKER}`, () => `--worker ${worker}`)
1048
+ .replace('name: "app"', () => `name: "${workerNamespace(worker)}"`),
1049
+ );
1050
+
1051
+ await stampWorkerPrograms(options.targetDir, workerDir, worker);
1052
+
1053
+ // The worker's `.dev.vars` is generated, not copied and not linked (#154). `pithy init` used to seed
1054
+ // one at the root from the committed example and symlink it into `apps/<worker>/`, which is the design
1055
+ // that produced #137, #139, #142 and #146. Nothing is written here at all: `pithy dev` and `pithy seed`
1056
+ // build each worker's file from the machine-local sources on every run, so a freshly scaffolded project
1057
+ // and a fresh clone of it reach the same state by the same path, with no postinstall and nothing to
1058
+ // remember.
1059
+ }
1060
+
1061
+ /**
1062
+ * Point the solution file at the Worker's real directory, and give its build state a name no sibling
1063
+ * Worker will take.
1064
+ *
1065
+ * Both strings name `apps/<DEFAULT_WORKER>` in the template and both would otherwise survive a rename:
1066
+ * the root `tsconfig.json` would reference a path that no longer exists — `tsc -b` fails outright on that,
1067
+ * so the whole `typecheck` gate would be broken by the one flag that renames the Worker — and every
1068
+ * Worker's `tsBuildInfoFile` would resolve to the same file under the project's `dist/`, where two
1069
+ * composite programs overwriting each other's state makes incremental builds silently wrong.
1070
+ *
1071
+ * Keyed off {@link DEFAULT_WORKER} rather than a literal, for the reason the wrangler stamps above are:
1072
+ * the template ships that name, and a literal here is a second place to change.
1073
+ */
1074
+ async function stampWorkerPrograms(targetDir: string, workerDir: string, worker: string): Promise<void> {
1075
+ const solutionPath = join(targetDir, "tsconfig.json");
1076
+ const solution = await readFile(solutionPath, "utf8");
1077
+ await writeFile(
1078
+ solutionPath,
1079
+ solution.replaceAll(`./apps/${DEFAULT_WORKER}/`, () => `./apps/${worker}/`),
1080
+ );
1081
+
1082
+ const programPath = join(workerDir, "tsconfig.json");
1083
+ const program = await readFile(programPath, "utf8");
1084
+ await writeFile(
1085
+ programPath,
1086
+ program.replaceAll(`/${DEFAULT_WORKER}.server.tsbuildinfo`, () => `/${worker}.server.tsbuildinfo`),
1087
+ );
1088
+ }