@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
package/src/ui/flow.ts ADDED
@@ -0,0 +1,451 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { readdir } from "node:fs/promises";
5
+ import { basename, join, sep } from "node:path";
6
+ import { ConflictError, NotFoundError, ValidationError } from "@pithy-sh/core/src/error/pithyError";
7
+ import { allCapabilities, projectEnvironments, type WorkerConfig } from "../project/config";
8
+ import { detectPackageManager, type PackageManager } from "../project/packageManager";
9
+ import { readOptionalFile } from "../project/readOptionalFile";
10
+ import { withRollback } from "../project/rollback";
11
+ import { pathExists } from "../project/scaffold";
12
+ import { deriveWorkerFirst, uncoveredRoutes } from "./routeAllowlist";
13
+ import { scaffoldFiles } from "./scaffold";
14
+ import { PITHY_SCREEN_DIR, unstyledScreenClasses } from "./screenStyles";
15
+ import { resolveStub, UI_STUBS, type UiStub } from "./stubs";
16
+ import { loadStubFiles } from "./templates";
17
+ import { readAssets, wireAssets, wireManifest, wirePackage, wireSolution } from "./wire";
18
+ import { readWorkerUi } from "./workerUi";
19
+
20
+ /**
21
+ * `pithy ui add` and `pithy ui sync`, as flows — the command file stays argument parsing and output,
22
+ * the way `capabilities/flow.ts` sits behind `pithy add`.
23
+ */
24
+
25
+ /** The capability the auth template's screens are written against. */
26
+ const AUTH_CAPABILITY = "auth";
27
+
28
+ /** The capability the paywall and subscription screens are written against. */
29
+ const PAYMENTS_CAPABILITY = "payments";
30
+
31
+ /** Which capability-gated screen set a decision is about. */
32
+ export type UiScreenSet = typeof AUTH_CAPABILITY | typeof PAYMENTS_CAPABILITY;
33
+
34
+ /**
35
+ * Ask whether to scaffold one capability's screens.
36
+ *
37
+ * One seam for every screen set rather than one per set: a third capability's screens must be a new member
38
+ * of {@link UiScreenSet} and nothing else, and a prompt-per-capability is exactly the shape that stops
39
+ * being true.
40
+ */
41
+ export type UiScreenPrompt = (request: { screens: UiScreenSet; suggestion: boolean }) => Promise<boolean>;
42
+
43
+ /**
44
+ * The one name this file uses for a Worker: the directory it lives in, `apps/<name>`.
45
+ *
46
+ * Derived here rather than accepted as an argument, because a Worker has **two** names and the wrong one
47
+ * is what a caller has to hand. `ResolvedWorker.name` is the *deployed* name — `<project>-<worker>`, the
48
+ * string in `wrangler.jsonc` — and taking it wrote `apps/<project>-<worker>/tsconfig.client.json` into the
49
+ * root solution file, a reference to a directory that has never existed. `tsc -b` stops on it with TS6053
50
+ * and Vite refuses to load the Worker's `vite.config.ts`, so a freshly scaffolded project could neither
51
+ * typecheck nor start.
52
+ *
53
+ * Everything below is either a path or a `--worker` value, and both are the directory. The deployed name
54
+ * belongs in `wrangler.jsonc` and nowhere else.
55
+ */
56
+ function workerName(workerDir: string): string {
57
+ return basename(workerDir);
58
+ }
59
+
60
+ /** One entry of `pithy ui list`. */
61
+ export interface UiStubListing {
62
+ /** The `<framework>` positional. */
63
+ id: string;
64
+ /** One line describing what it scaffolds. */
65
+ description: string;
66
+ }
67
+
68
+ /** Every framework `pithy ui add` can scaffold, sorted. */
69
+ export function listStubs(): UiStubListing[] {
70
+ return Object.values(UI_STUBS)
71
+ .map((stub) => ({ id: stub.id, description: stub.description }))
72
+ .sort((a, b) => a.id.localeCompare(b.id));
73
+ }
74
+
75
+ /** Options for {@link runUiAdd} — the target worker, already resolved, plus the choices. */
76
+ export interface UiAddOptions {
77
+ /** The project root — where the lockfile that names the package manager lives. */
78
+ projectDir: string;
79
+ /** The target worker's directory, `apps/<name>` — which is also where its name comes from. */
80
+ workerDir: string;
81
+ /** The target worker's loaded `pithy.config.ts` — the route table the allowlist is derived from. */
82
+ config: WorkerConfig;
83
+ /** The `<framework>` positional. */
84
+ framework: string;
85
+ /** `--auth` / `--no-auth`. Undefined means "decide", by prompt when one is attached. */
86
+ auth?: boolean;
87
+ /** `--payments` / `--no-payments`. Undefined means "decide", the same way. */
88
+ payments?: boolean;
89
+ /**
90
+ * Ask whether to scaffold a capability's screens. Supplied only when a human is attached; without it
91
+ * every decision falls to whether that capability is composed, so no invocation can ever block.
92
+ */
93
+ prompt?: UiScreenPrompt;
94
+ /** Package-manager override. Tests set it; otherwise it is detected from the project's lockfile. */
95
+ packageManager?: PackageManager;
96
+ }
97
+
98
+ /** What `pithy ui add` did. */
99
+ export interface UiAddReport {
100
+ /** The worker the front end landed in. */
101
+ worker: string;
102
+ /** The stub that wrote it. */
103
+ framework: string;
104
+ /** Whether the auth template was included. */
105
+ auth: boolean;
106
+ /** Whether the payments screens were included. */
107
+ payments: boolean;
108
+ /** Worker-relative paths created by this run, sorted. */
109
+ created: string[];
110
+ /** Worker-relative paths that already existed and were left byte-identical, sorted. */
111
+ skipped: string[];
112
+ /**
113
+ * Class names Pithy's screens render that nothing in this Worker's stylesheets defines — empty on
114
+ * every ordinary run, and the exact list to fix when it is not.
115
+ *
116
+ * Separate from {@link created} because "wrote the screens" and "the screens are styled" are two
117
+ * claims and only one of them was ever reported. A backfill writes `routes/pithy/sign-in.tsx` and
118
+ * correctly skips the adopter's `src/styles.css`; if the rules those screens need are not on disk,
119
+ * the run said `created` and the adopter met an unstyled login page with nothing pointing at why.
120
+ *
121
+ * Measured against the files as they stand **after** the write, not against the template, because
122
+ * the template is not what renders: a stylesheet the adopter has since edited, or deleted, is the
123
+ * case this exists for.
124
+ */
125
+ unstyled: string[];
126
+ /** The derived `assets.run_worker_first` allowlist now in `wrangler.jsonc`. */
127
+ runWorkerFirst: string[];
128
+ /** The project's package manager. */
129
+ packageManager: PackageManager;
130
+ /** Dependency names added to the worker's `package.json`. */
131
+ dependencies: string[];
132
+ /** Dev-dependency names added to the worker's `package.json`. */
133
+ devDependencies: string[];
134
+ /** Script names added (or superseded, for a `dev` that still held `wrangler dev`). */
135
+ scripts: string[];
136
+ }
137
+
138
+ /**
139
+ * Whether to write one capability's screens. `--auth`/`--no-auth` and `--payments`/`--no-payments` decide
140
+ * outright. With neither, a human is asked (defaulting to yes when the capability is composed on this
141
+ * worker) and anyone else — `--json`, an agent, CI — gets that same default with no prompt, because no
142
+ * invocation may block.
143
+ *
144
+ * Asking for a capability's screens on a worker that does not compose it is an error, not a scaffold of
145
+ * broken imports.
146
+ */
147
+ async function resolveScreens(
148
+ options: UiAddOptions,
149
+ screens: UiScreenSet,
150
+ requested: boolean | undefined,
151
+ composed: boolean,
152
+ ): Promise<boolean> {
153
+ let wanted = requested;
154
+ if (wanted === undefined)
155
+ wanted = options.prompt ? await options.prompt({ screens, suggestion: composed }) : composed;
156
+ if (wanted && !composed) {
157
+ const worker = workerName(options.workerDir);
158
+ throw new ValidationError({
159
+ message: `The ${screens} screens need the ${screens} capability, and ${worker} doesn't compose it.`,
160
+ action: `Run pithy add ${screens} --worker ${worker} first, or leave them out with --no-${screens}.`,
161
+ });
162
+ }
163
+ return wanted;
164
+ }
165
+
166
+ /** Whether a worker composes a capability by name. */
167
+ function composes(config: WorkerConfig, capability: string): boolean {
168
+ return allCapabilities(config).some((composed) => composed.name === capability);
169
+ }
170
+
171
+ /**
172
+ * The files this run may write: the whole template on a first scaffold, and only what does not exist
173
+ * yet on a backfill (`--auth` after `--no-auth`).
174
+ *
175
+ * A worker that already carries a front end and gains nothing new is the "adding a UI twice" error —
176
+ * clean and actionable, never a partial overwrite. A worker carrying a *different* stub is refused
177
+ * outright: two frameworks in one worker is not a merge anyone should attempt for you.
178
+ */
179
+ async function planFiles(
180
+ options: UiAddOptions,
181
+ stub: UiStub,
182
+ screens: { auth: boolean; payments: boolean },
183
+ ): Promise<{ files: Record<string, string>; strict: boolean }> {
184
+ const current = await readWorkerUi(options.workerDir);
185
+ const worker = workerName(options.workerDir);
186
+ const files = await loadStubFiles(stub, {
187
+ worker,
188
+ ...screens,
189
+ packageManager: options.packageManager ?? "npm",
190
+ });
191
+ if (!current) return { files, strict: true };
192
+
193
+ if (current.stub !== stub.id) {
194
+ throw new ConflictError({
195
+ message: `${worker} already has a ${current.stub} front end.`,
196
+ action: "One worker, one front end. Remove it, or add the new one to a different worker with --worker.",
197
+ });
198
+ }
199
+
200
+ // `pathExists`, the shared `lstat`, and not the local `access` this used to roll. `access` follows a
201
+ // link, so a dangling one planted at a template path answered "nothing there" — the file counted as
202
+ // fresh, and the plan handed it to `scaffoldFiles` to write. That is the same predicate five other
203
+ // modules got wrong, in the one module the tripwire could not see because it imports no writer of its
204
+ // own. One question, one implementation.
205
+ let fresh = 0;
206
+ for (const rel of Object.keys(files)) {
207
+ if (!(await pathExists(join(options.workerDir, rel)))) fresh += 1;
208
+ }
209
+ if (fresh === 0) {
210
+ throw new ConflictError({
211
+ message: `${worker} already has a ${current.stub} front end.`,
212
+ action: "Add screens by dropping files into src/routes/app/. Run pithy ui sync to re-derive its route allowlist.",
213
+ });
214
+ }
215
+ // The whole record, non-strict: the writer skips what exists and reports it, so the run says what
216
+ // it added AND what it left alone.
217
+ return { files, strict: false };
218
+ }
219
+
220
+ /**
221
+ * Whether Pithy's screens in this worker render styled, read off the worker exactly as it stands.
222
+ *
223
+ * The template's own files are not the answer, and neither is the plan. On a backfill the plan still
224
+ * carries Pithy's `src/styles.css` while the file on disk is the adopter's — so checking the plan would
225
+ * report a screen as styled by rules that exist in this process and nowhere else. Only the disk renders.
226
+ *
227
+ * **The whole of `src/` is swept, not the paths this run happened to plan (#401).** Two reasons, and the
228
+ * second is the one that matters now the finding can fail a build:
229
+ *
230
+ * - `pithy ui sync` plans nothing. A check that could only run over a plan could only run at scaffold,
231
+ * which is the defect: the report ran once, printed, and nothing ever asked again.
232
+ * - A class defined in a stylesheet Pithy did not write is defined. An adopter with `src/brand.css` was
233
+ * previously told their screens were unstyled, because that file was in no plan. A false finding is
234
+ * affordable in a warning and not in a gate.
235
+ */
236
+ async function unstyledOnDisk(workerDir: string): Promise<string[]> {
237
+ const src = join(workerDir, "src");
238
+ // `recursive` gives worker-relative paths under `src/`, in whatever separator the platform uses;
239
+ // normalized to `/` because `PITHY_SCREEN_DIR` is a path *within a scaffolded worker*, and those are
240
+ // written the way the templates write them. A worker with no `src/` at all answers the empty set.
241
+ const entries = await readdir(src, { recursive: true }).catch(() => [] as string[]);
242
+ const onDisk: Record<string, string> = {};
243
+ for (const entry of entries) {
244
+ const path = `src/${entry.split(sep).join("/")}`;
245
+ const stylesheet = path.endsWith(".css");
246
+ const screen = path.startsWith(PITHY_SCREEN_DIR) && path.endsWith(".tsx");
247
+ if (!stylesheet && !screen) continue;
248
+ const contents = await readOptionalFile(join(workerDir, path));
249
+ if (contents !== null) onDisk[path] = contents;
250
+ }
251
+ return unstyledScreenClasses(onDisk);
252
+ }
253
+
254
+ /**
255
+ * Every file this run may create or edit, absolute — what a failure has to put back.
256
+ *
257
+ * The template's own paths, plus the four documents `ui/wire.ts` edits rather than creates. Stated here
258
+ * rather than discovered afterwards, because a rollback that guesses is one that misses a file.
259
+ */
260
+ function touchedPaths(options: UiAddOptions, planned: Record<string, string>): string[] {
261
+ return [
262
+ ...Object.keys(planned).map((rel) => join(options.workerDir, rel)),
263
+ join(options.workerDir, "wrangler.jsonc"),
264
+ join(options.workerDir, "pithy.worker.jsonc"),
265
+ join(options.workerDir, "package.json"),
266
+ join(options.projectDir, "tsconfig.json"),
267
+ ];
268
+ }
269
+
270
+ /**
271
+ * Scaffold a front end into one worker and wire it end to end: the client files, the `assets` stanza
272
+ * that serves them, the dev command `pithy dev` runs, the build command `pithy deploy` runs, and the
273
+ * packages the build needs. The SPA and the API stay one deploy on one origin.
274
+ *
275
+ * **A run that fails leaves the project as it found it**, and that is two things rather than one.
276
+ *
277
+ * The allowlist is derived *before* anything is written: composing the app is the step most likely to
278
+ * throw — a capability misconfigured, a required binding missing (#258), a syntax error in a file the
279
+ * adopter has edited — and it used to run after the whole template had landed. So a failure left the
280
+ * files written, the wiring absent, and the retry refused by this command's own "already has a front
281
+ * end" guard, which cannot tell a finished front end from one abandoned a minute ago (#259).
282
+ *
283
+ * And every write after that runs under {@link withRollback}, because ordering alone only removes the
284
+ * failure anyone has already seen: the next step added goes back on the end of the list, and the
285
+ * property quietly stops being true. Stated over the outcome, it survives the next step.
286
+ */
287
+ export async function runUiAdd(options: UiAddOptions): Promise<UiAddReport> {
288
+ const stub = resolveStub(options.framework);
289
+ const packageManager = options.packageManager ?? (await detectPackageManager(options.projectDir));
290
+ const auth = await resolveScreens(options, AUTH_CAPABILITY, options.auth, composes(options.config, AUTH_CAPABILITY));
291
+ const payments = await resolveScreens(
292
+ options,
293
+ PAYMENTS_CAPABILITY,
294
+ options.payments,
295
+ composes(options.config, PAYMENTS_CAPABILITY),
296
+ );
297
+
298
+ const plan = await planFiles({ ...options, packageManager }, stub, { auth, payments });
299
+ // Compose first. `deriveWorkerFirst` assembles every capability's routes, once per environment, and
300
+ // reads nothing this run is about to write — which is what makes moving it ahead of the write legal.
301
+ const patterns = deriveWorkerFirst(options.config, await projectEnvironments(options.projectDir));
302
+
303
+ const scope = { root: options.projectDir, paths: touchedPaths(options, plan.files) };
304
+ const result = await withRollback(scope, async () => {
305
+ const written = await scaffoldFiles({ workerDir: options.workerDir, files: plan.files, strict: plan.strict });
306
+ const assets = await wireAssets(options.workerDir, patterns);
307
+ await wireManifest(options.workerDir, stub, packageManager);
308
+ const pkg = await wirePackage(options.projectDir, options.workerDir, stub);
309
+ // Last, and project-wide rather than per-worker: the client's programs join the root solution file so
310
+ // `bun run typecheck` builds them. Two tsconfigs nothing references are two tsconfigs nothing checks.
311
+ await wireSolution(options.projectDir, workerName(options.workerDir));
312
+ // Read inside the scope, because it reads the files this block just wrote — outside, a rollback
313
+ // would already have taken them away.
314
+ const unstyled = await unstyledOnDisk(options.workerDir);
315
+ return { written, assets, pkg, unstyled };
316
+ });
317
+
318
+ return {
319
+ worker: workerName(options.workerDir),
320
+ framework: stub.id,
321
+ auth,
322
+ payments,
323
+ created: result.written.written,
324
+ skipped: result.written.skipped,
325
+ unstyled: result.unstyled,
326
+ runWorkerFirst: result.assets.after,
327
+ packageManager,
328
+ dependencies: result.pkg.dependencies,
329
+ devDependencies: result.pkg.devDependencies,
330
+ scripts: result.pkg.scripts,
331
+ };
332
+ }
333
+
334
+ /** What `pithy ui sync` did. */
335
+ export interface UiSyncReport {
336
+ /** The worker whose allowlist was re-derived. */
337
+ worker: string;
338
+ /** The allowlist before this run. */
339
+ before: string[];
340
+ /** The allowlist after it. */
341
+ after: string[];
342
+ /** Whether anything actually moved — false on a re-run, which is the point. */
343
+ changed: boolean;
344
+ /**
345
+ * Routes the allowlist **in the file** does not cover: the ones the SPA shell is answering with a 200
346
+ * and the wrong body. Always empty after a write — the run just re-derived the list — so this is the
347
+ * finding `--check` exists to produce, and the one thing that fails the exit.
348
+ */
349
+ uncovered: string[];
350
+ /**
351
+ * Class names Pithy's screens render that nothing in this worker's stylesheets defines — read off the
352
+ * worker as it stands now, not as it stood when it was scaffolded.
353
+ *
354
+ * **This is the check that used to run once and never again (#401).** It was written at `pithy ui add`
355
+ * because that is where the defect it caught happened: a backfill wrote the sign-in screens and
356
+ * correctly skipped the adopter's `src/styles.css`, and the run reported `created` over a login page
357
+ * whose `stack`, `divider` and `secondary` nothing defined. But `styles.css` is theirs, and the
358
+ * ordinary case is an edit a week later — deleting a rule, replacing the file, dropping
359
+ * `pithy-screens.css` in a tidy-up. None of that errors. The screen renders, unstyled, with a 200.
360
+ *
361
+ * So it belongs here too, on the one command that re-checks a front end after scaffold, and under
362
+ * `--check` it is one of the two findings that fail the exit. The counter-argument is real and worth
363
+ * stating: a class with no rule is a cosmetic defect, and a build that fails over one teaches people
364
+ * to write `.stack {}` and move on. What settles it is that the fix anybody actually reaches for is
365
+ * restoring `pithy-screens.css` or defining the rule they meant to define — and that deleting the
366
+ * class from the screen is also a fix, because the screen is theirs. A stub rule is available and is
367
+ * not what the message asks for.
368
+ */
369
+ unstyled: string[];
370
+ /**
371
+ * `assets.not_found_handling` as it stands. Reported because SPA routing depends on it: an adopter
372
+ * who set it to something else has deep links 404ing in Hono rather than serving the app shell, and
373
+ * `ui sync` does not overwrite a value they chose.
374
+ */
375
+ notFoundHandling: string | undefined;
376
+ }
377
+
378
+ /**
379
+ * Re-derive one worker's `assets.run_worker_first` from its current route table, or — with `check` —
380
+ * only report how far the file has drifted from it.
381
+ *
382
+ * The allowlist is written once, at `pithy ui add`, and every route mounted afterwards is a route the
383
+ * asset router answers before the worker runs. `pithy add <capability>` is one way that happens; the
384
+ * adopter writing a route into their own app capability is the other, and that one runs no command at
385
+ * all. So the allowlist has to be re-derivable on demand *and* checkable in CI, because a list that
386
+ * has gone stale does not fail — it returns 200 with the SPA shell.
387
+ *
388
+ * **It re-runs the unstyled check too (#401), which is the other thing that goes stale in silence.**
389
+ * Both findings have the same shape: a 200, the wrong output, and no error anywhere. One is the shell
390
+ * answering a route; the other is a screen rendering with no rules. This is the command that re-checks a
391
+ * front end, so it checks both, and `--check` fails on either.
392
+ *
393
+ * Creates no files either way, and re-running changes nothing.
394
+ */
395
+ export async function runUiSync(options: {
396
+ /** The project root — where the `environments` declaration the derivation needs lives. */
397
+ projectDir: string;
398
+ /** The target worker's directory, `apps/<name>` — which is also where its name comes from. */
399
+ workerDir: string;
400
+ /** The target worker's loaded `pithy.config.ts`. */
401
+ config: WorkerConfig;
402
+ /** Report the drift and write nothing — the CI gate. */
403
+ check?: boolean;
404
+ }): Promise<UiSyncReport> {
405
+ const worker = workerName(options.workerDir);
406
+ const current = await readWorkerUi(options.workerDir);
407
+ if (!current) {
408
+ throw new NotFoundError({
409
+ message: `${worker} has no front end.`,
410
+ action: `Run pithy ui add react --worker ${worker} to scaffold one.`,
411
+ });
412
+ }
413
+
414
+ const environments = await projectEnvironments(options.projectDir);
415
+ const after = deriveWorkerFirst(options.config, environments);
416
+ // Read on both paths, and before the write on the one that writes. `wireAssets` touches
417
+ // `wrangler.jsonc` and no stylesheet, so the answer is the same either side of it — taking it first
418
+ // means the report is assembled from one reading of the worker rather than two.
419
+ const unstyled = await unstyledOnDisk(options.workerDir);
420
+ if (options.check) {
421
+ const assets = await readAssets(options.workerDir);
422
+ return {
423
+ worker,
424
+ before: assets.runWorkerFirst,
425
+ after,
426
+ changed: assets.runWorkerFirst.join("\n") !== after.join("\n") || assets.notFoundHandling === undefined,
427
+ uncovered: uncoveredRoutes(options.config, assets.runWorkerFirst, environments),
428
+ unstyled,
429
+ notFoundHandling: assets.notFoundHandling,
430
+ };
431
+ }
432
+
433
+ // `after` above is the same derivation the write needs, so it is computed once and handed over —
434
+ // and, incidentally, before anything is written here too.
435
+ const change = await wireAssets(options.workerDir, after);
436
+ const before = change.before ?? [];
437
+ return {
438
+ worker,
439
+ before,
440
+ after: change.after,
441
+ // Every way this call can have moved the file, not just the allowlist — a report that says
442
+ // nothing changed while the file did is worse than no report, because CI keys off it.
443
+ changed: before.join("\n") !== change.after.join("\n") || change.wroteNotFoundHandling,
444
+ // The list was just re-derived from this same route table, so nothing is left outside it.
445
+ uncovered: [],
446
+ // Unlike `uncovered`, this one is not emptied by the write. Nothing here touches a stylesheet, so a
447
+ // missing rule is still missing — and saying so is the point of re-running the check at all.
448
+ unstyled,
449
+ notFoundHandling: change.notFoundHandling,
450
+ };
451
+ }
@@ -0,0 +1,112 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { PACKAGE_VERSION } from "@pithy-sh/core/src/version.generated";
5
+ import { HOME_SCREEN, TEMPLATE_DIR, TEMPLATE_GROUPS, WORKER_TOKEN } from "@pithy-sh/ui-react/src/templates";
6
+ import { kitRange } from "../project/scaffold";
7
+ import { WRANGLER_RANGE } from "../project/workerScaffold";
8
+ import type { UiStub, UiStubContext, UiStubFile } from "./stubs";
9
+
10
+ /**
11
+ * The React 19 stub — the first framework `pithy ui add` scaffolds, registered through the same
12
+ * {@link UiStub} declaration any later framework would use.
13
+ *
14
+ * Two templates. **bare** is a SPA with one typed `fetch("/health")`: no auth imports, no dead files.
15
+ * **auth** is that plus the passwordless screens, a session hook, and a route guard.
16
+ *
17
+ * The screens live in `@pithy-sh/ui-react`, a template library with its own React toolchain, so they
18
+ * are typechecked and linted like any other source. This module is the part that belongs to the CLI:
19
+ * which of them a given invocation writes, and how the result is run and built. A second framework
20
+ * brings its own library and its own module here; neither needs to touch the other's toolchain.
21
+ *
22
+ * Every client file is `.tsx`. That is load-bearing: the Worker's own `tsconfig.json` includes
23
+ * `src/**\/*.ts`, which does **not** match `.tsx`, so the client never enters the Worker's program and
24
+ * the Worker's config needs no edit. The ambient declarations live at the Worker root as
25
+ * `client-env.d.ts` rather than under `src/`, because `.d.ts` *would* match that glob.
26
+ */
27
+
28
+ /** The same path in and out — true of every template except the home screen. */
29
+ function inPlace(paths: readonly string[]): UiStubFile[] {
30
+ return paths.map((path) => ({ source: path, target: path }));
31
+ }
32
+
33
+ function manifest(context: UiStubContext): UiStubFile[] {
34
+ const files = inPlace(TEMPLATE_GROUPS.base);
35
+ files.push({
36
+ source: context.auth ? HOME_SCREEN.auth : HOME_SCREEN.bare,
37
+ target: HOME_SCREEN.target,
38
+ });
39
+ // The capability groups stack rather than choose: they name disjoint files over one layout, so a Worker
40
+ // composing both auth and payments gets both screen sets and no path moves.
41
+ if (context.auth) files.push(...inPlace(TEMPLATE_GROUPS.auth));
42
+ if (context.payments) files.push(...inPlace(TEMPLATE_GROUPS.payments));
43
+ return files;
44
+ }
45
+
46
+ /** React 19, through Vite 8 and `@cloudflare/vite-plugin`. */
47
+ export const reactStub: UiStub = {
48
+ id: "react",
49
+ description: "React 19 SPA on Vite, served by the worker as static assets",
50
+ templateDir: TEMPLATE_DIR,
51
+ manifest,
52
+ substitutions: (context) => ({ [WORKER_TOKEN]: context.worker }),
53
+ dependencies: {
54
+ react: "^19.2.8",
55
+ "react-dom": "^19.2.8",
56
+ // **Every screen renders through the translator seam, so the front end depends on it the way it
57
+ // depends on React.** This is not the auth/payments pattern and could not be: those screens are
58
+ // written only when their capability is composed, and their package arrives with `pithy add auth`.
59
+ // `src/pithy-locale.tsx`, `src/router.tsx` and the home screen all reach it — all `base`, all
60
+ // written on every scaffold — so a project that never composes `i18n` still imports the package, and
61
+ // before this line `vite build` could not resolve it. The alternative was to un-translate the base
62
+ // screens, which is the feature.
63
+ //
64
+ // A dependency rather than a devDependency, for the reason `react` is one: it is bundled into what
65
+ // the browser downloads. And core's `PACKAGE_VERSION` for the reason `@pithy-sh/vite` uses it —
66
+ // the release train below is what makes that honest, and `react.test.ts` holds both halves.
67
+ "@pithy-sh/i18n": kitRange(PACKAGE_VERSION),
68
+ },
69
+ devDependencies: {
70
+ "@cloudflare/vite-plugin": "^1.48.0",
71
+ // Derived, never a literal: `"^0.0.0"` 404'd the adopter's next install, and no release would ever
72
+ // have moved it. `kitRange` answers `null` while nothing under the scope is published, which drops
73
+ // the line entirely, and writes a real range the day one exists.
74
+ //
75
+ // **`PACKAGE_VERSION` is core's, and this is a sibling** — which `stampWorkerManifest` forbids
76
+ // outright, because two packages that version independently share no honest range. What makes it
77
+ // honest here is `.changeset/config.json`: `@pithy-sh/core` and `@pithy-sh/vite` are a `fixed` group,
78
+ // always released together at one version, so core's number IS vite's number. `linked` would not do
79
+ // it — a release touching only core leaves a linked sibling on its old version, and the range would
80
+ // then name something vite never published. `react.test.ts` holds the group and the equality; break
81
+ // either and this line goes back to inventing a version.
82
+ "@pithy-sh/vite": kitRange(PACKAGE_VERSION),
83
+ "@types/react": "^19.2.18",
84
+ "@types/react-dom": "^19.2.4",
85
+ "@vitejs/plugin-react": "^6.0.4",
86
+ // A DOM for the seeded gate, and for every client test the adopter writes after it. The starter's
87
+ // node project already collects `.tsx` co-located tests (#245) but runs them in `environment:
88
+ // "node"`, where `document` does not exist — so `src/turnstile.test.tsx` names its own environment
89
+ // in a docblock, and this is the package that has to be installed for that name to resolve (#383).
90
+ "happy-dom": "^20.11.2",
91
+ vite: "^8.2.1",
92
+ // The Worker's own pin, imported from the producer that writes it — this was a third copy of a
93
+ // literal that `scaffoldParity.test.ts` held between only the other two.
94
+ wrangler: WRANGLER_RANGE,
95
+ },
96
+ // `--configLoader runner` is load-bearing, not a preference. Vite's default config loader bundles
97
+ // vite.config.ts and leaves every bare import external, so Node itself has to import
98
+ // `@pithy-sh/vite` — which, like every package here, ships raw TypeScript with extensionless
99
+ // relative imports. Node cannot resolve those, and refuses to strip types under node_modules at
100
+ // all. The runner loads the config through Vite's own resolver instead, where both are ordinary.
101
+ devCommand: (port) => ["vite", "dev", "--configLoader", "runner", "--strictPort", "--port", port],
102
+ readySignal: "ready in \\d+",
103
+ buildCommand: ["vite", "build", "--configLoader", "runner"],
104
+ // The adopter's own entry points. Every one loads vite.config.ts, so every one needs the runner —
105
+ // and `dev` deliberately omits `--port`/`--strictPort`: a bare `vite dev` is someone working on the
106
+ // front end alone, where Vite picking its own port is right. `pithy dev` is what pins ports.
107
+ scripts: {
108
+ dev: "vite dev --configLoader runner",
109
+ build: "vite build --configLoader runner",
110
+ preview: "vite preview --configLoader runner",
111
+ },
112
+ };