@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,206 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
5
+ import type { SeedSet } from "@pithy-sh/core/src/seed/seed";
6
+
7
+ /**
8
+ * The layered env-safety model for `pithy seed`. Two gates stand between a fixture and a write:
9
+ *
10
+ * 1. **Allowlist** — a set is seeded into an environment only if its `environments` lists it. Core's
11
+ * `composeSeeds` already filters on this; {@link assertSetAllowedForEnv} re-asserts it per set at
12
+ * write time, so a set can never reach a disallowed environment even if the plan is bypassed.
13
+ * 2. **Escalating confirmation** — `dev` runs freely; `staging` and `production` require `--yes`; and
14
+ * `production` additionally requires a hard, exact type-to-confirm phrase. {@link assertSeedConfirmed}
15
+ * is the gate.
16
+ *
17
+ * `production` is never seeded by accident: the allowlist keeps a set out unless it opts in, and the
18
+ * confirm phrase keeps a run from starting unless a human (or an explicit CI flag) types the exact words.
19
+ */
20
+
21
+ /** The exact phrase that unlocks a production seed. Compared case-insensitively after trimming. */
22
+ export const PRODUCTION_CONFIRM_PHRASE = "yes, i really want to seed production";
23
+
24
+ /**
25
+ * The built-in env names that trigger the strongest gate — the hard type-to-confirm phrase — not just
26
+ * the literal `"production"`. Recognized case-insensitively after trimming, so `prod` and `Production`
27
+ * cannot slip past the phrase with only `--yes`. A project extends this with `seed.productionEnvironments`
28
+ * in `pithy.config.ts` (see {@link isProductionEnv}), so a prod env named `live`/`prod-eu`/`main` is gated
29
+ * identically. Any env classified as production by neither is still guarded by `--yes`.
30
+ */
31
+ const PRODUCTION_ENV_NAMES = new Set(["production", "prod"]);
32
+
33
+ /**
34
+ * Whether `env` names a production environment (case-insensitive), and so needs the hard confirm phrase.
35
+ * `productionEnvironments` are the extra names the project declared in `pithy.config.ts`
36
+ * (`seed.productionEnvironments`), unioned with the built-in `production`/`prod` — the fix for a prod
37
+ * environment whose name is neither canonical value silently dropping to the weaker `--yes`-only gate.
38
+ */
39
+ export function isProductionEnv(env: string, productionEnvironments: readonly string[] = []): boolean {
40
+ const normalized = env.trim().toLowerCase();
41
+ if (PRODUCTION_ENV_NAMES.has(normalized)) return true;
42
+ return productionEnvironments.some((name) => name.trim().toLowerCase() === normalized);
43
+ }
44
+
45
+ /**
46
+ * Re-assert the env allowlist for one set (safety layer 1). Throws if `env` is not among the set's
47
+ * declared `environments` — the write-time guard behind "seed refuses to write a set into a disallowed
48
+ * env". Compose already filters disallowed sets out; this makes the invariant impossible to bypass.
49
+ */
50
+ export function assertSetAllowedForEnv(set: SeedSet, env: string): void {
51
+ if (!set.environments.includes(env)) {
52
+ throw new ValidationError({
53
+ message: `Seed set "${set.name}" is not allowed in ${env}.`,
54
+ action: `Add "${env}" to the set's environments, or seed one of: ${set.environments.join(", ")}.`,
55
+ });
56
+ }
57
+ }
58
+
59
+ /** Inputs to the escalating-confirmation gate (safety layer 2). */
60
+ export interface ConfirmSeedOptions {
61
+ /** The environment being seeded. `dev` is free; `staging`/`production` escalate. */
62
+ env: string;
63
+ /** The `--yes` flag. Required for any non-`dev` environment. */
64
+ yes: boolean;
65
+ /**
66
+ * Non-interactive mode (set by `--json`, or any headless/CI invocation). No prompt is ever shown;
67
+ * production must be unlocked by {@link ConfirmSeedOptions.confirmProduction} instead.
68
+ */
69
+ json: boolean;
70
+ /**
71
+ * The `--confirm-production` flag value. For production it must equal {@link PRODUCTION_CONFIRM_PHRASE}
72
+ * (case-insensitive, trimmed). When present it is authoritative — used even in interactive mode, so a
73
+ * script never has to answer a prompt.
74
+ */
75
+ confirmProduction?: string;
76
+ /**
77
+ * Interactive confirm seam: prompt the operator for the production phrase and resolve their answer.
78
+ * Injected by the command (an `@clack/prompts` text prompt); omitted in tests and never called when
79
+ * {@link ConfirmSeedOptions.json} is set or {@link ConfirmSeedOptions.confirmProduction} is supplied.
80
+ */
81
+ prompt?: () => Promise<string>;
82
+ /**
83
+ * Extra environment names the project classifies as production (`pithy.config.ts`
84
+ * `seed.productionEnvironments`), unioned with the built-in `production`/`prod`. An env named here also
85
+ * requires the hard confirm phrase, so a project whose production environment is named `live`/`prod-eu`
86
+ * is protected like the canonical names rather than passing on `--yes` alone.
87
+ */
88
+ productionEnvironments?: readonly string[];
89
+ }
90
+
91
+ /** Whether `input`, trimmed and lower-cased, is exactly the production confirm phrase. */
92
+ function phraseMatches(input: string | undefined): boolean {
93
+ return input !== undefined && input.trim().toLowerCase() === PRODUCTION_CONFIRM_PHRASE;
94
+ }
95
+
96
+ /**
97
+ * The exact phrase that unlocks a `--redo` schema reset for an environment. **Environment-specific on
98
+ * purpose** — a phrase naming `staging` cannot be pasted into a command targeting another environment,
99
+ * which a single fixed phrase would allow.
100
+ */
101
+ export function resetConfirmPhrase(env: string): string {
102
+ return `yes, i really want to reset ${env.trim().toLowerCase()}`;
103
+ }
104
+
105
+ /** Inputs to the reset gate — the stronger confirmation `--redo` requires. */
106
+ export interface ConfirmResetOptions {
107
+ /** The environment being reset. `dev` is free; everything else needs the phrase. */
108
+ env: string;
109
+ /** Non-interactive mode: no prompt is shown, so the phrase must arrive by flag. */
110
+ json: boolean;
111
+ /** The `--confirm-reset` flag value; authoritative wherever present. */
112
+ confirmReset?: string;
113
+ /** Interactive confirm seam: prompt the operator for the reset phrase. */
114
+ prompt?: () => Promise<string>;
115
+ }
116
+
117
+ /**
118
+ * Enforce the **reset** gate, which is deliberately stricter than the seed gate.
119
+ *
120
+ * `--yes` means "yes, this is not dev" — it was designed to authorize *writing seed rows*, which is
121
+ * additive and non-destructive. `--redo` drops every table first. Letting one flag authorize both would
122
+ * mean a script (or a hand) that knew only to pass `--yes` could destroy an environment's entire dataset.
123
+ * So a reset requires the exact, environment-named phrase for **any** non-`dev` environment — not only
124
+ * production. `dev` stays free, because a local Miniflare store is the thing reset is for.
125
+ *
126
+ * Automation is preserved: CI passes `--confirm-reset` explicitly, so a headless reset still works — it
127
+ * simply cannot happen by accident.
128
+ */
129
+ export async function assertResetConfirmed(options: ConfirmResetOptions): Promise<void> {
130
+ if (options.env === "dev") return;
131
+
132
+ const expected = resetConfirmPhrase(options.env);
133
+ const matches = (input: string | undefined): boolean =>
134
+ input !== undefined && input.trim().toLowerCase() === expected;
135
+
136
+ // The flag is authoritative wherever present (CI or interactive).
137
+ if (options.confirmReset !== undefined) {
138
+ if (matches(options.confirmReset)) return;
139
+ throw new ValidationError({
140
+ message: `That is not the confirmation phrase for resetting ${options.env}.`,
141
+ action: `Pass --confirm-reset "${expected}" to drop and recreate the ${options.env} schema.`,
142
+ });
143
+ }
144
+
145
+ if (!options.json && options.prompt) {
146
+ if (matches(await options.prompt())) return;
147
+ throw new ValidationError({
148
+ message: `Reset of ${options.env} not confirmed.`,
149
+ action: `Type the exact phrase, or pass --confirm-reset "${expected}".`,
150
+ });
151
+ }
152
+
153
+ throw new ValidationError({
154
+ message: `Resetting ${options.env} destroys all of its data.`,
155
+ action: `Pass --confirm-reset "${expected}" to drop and recreate the ${options.env} schema.`,
156
+ });
157
+ }
158
+
159
+ /**
160
+ * Enforce the escalating-confirmation gate before any write. Resolves when the run is authorized and
161
+ * throws a `ValidationError` otherwise:
162
+ *
163
+ * - `dev` → always authorized.
164
+ * - `staging` (and any other non-`dev`, non-production environment) → requires `--yes`.
165
+ * - production ({@link isProductionEnv}: the built-in `production`/`prod` plus any name the project
166
+ * declares in `seed.productionEnvironments`, case-insensitive) → requires `--yes` **and** the exact
167
+ * confirm phrase. A prod environment named `live`/`prod-eu` is gated only if it is declared. The
168
+ * phrase comes from `--confirm-production` when supplied (the CI/non-interactive path); otherwise,
169
+ * if interactive, the injected `prompt` is asked for it. `--json` forbids the prompt, so production
170
+ * without the flag is refused. A supplied-but-wrong phrase is always refused.
171
+ */
172
+ export async function assertSeedConfirmed(options: ConfirmSeedOptions): Promise<void> {
173
+ if (options.env === "dev") return;
174
+
175
+ if (!options.yes) {
176
+ throw new ValidationError({
177
+ message: `Seeding ${options.env} needs confirmation.`,
178
+ action: `Re-run with --yes to seed ${options.env}.`,
179
+ });
180
+ }
181
+
182
+ if (!isProductionEnv(options.env, options.productionEnvironments)) return;
183
+
184
+ // Production: the flag is authoritative wherever it is present (CI or interactive).
185
+ if (options.confirmProduction !== undefined) {
186
+ if (phraseMatches(options.confirmProduction)) return;
187
+ throw new ValidationError({
188
+ message: "The production confirmation phrase did not match.",
189
+ action: `Pass --confirm-production "${PRODUCTION_CONFIRM_PHRASE}" to seed production.`,
190
+ });
191
+ }
192
+
193
+ // No flag: only an interactive prompt can unlock production. `--json` (or a missing prompt) forbids it.
194
+ if (options.json || !options.prompt) {
195
+ throw new ValidationError({
196
+ message: "Seeding production needs an explicit confirmation phrase.",
197
+ action: `Pass --confirm-production "${PRODUCTION_CONFIRM_PHRASE}" to seed production.`,
198
+ });
199
+ }
200
+
201
+ if (phraseMatches(await options.prompt())) return;
202
+ throw new ValidationError({
203
+ message: "The production confirmation phrase did not match.",
204
+ action: `Type "${PRODUCTION_CONFIRM_PHRASE}" exactly to seed production.`,
205
+ });
206
+ }
@@ -0,0 +1,42 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { createLocalLogger, type LocalPalette } from "@pithy-sh/core/src/logger/local";
5
+ import type { Logger } from "@pithy-sh/core/src/logger/logger";
6
+ import { cyan, dim, red, yellow } from "./style";
7
+
8
+ /**
9
+ * The CLI's Mode 1 diagnostic logger — the process-scoped side of the one unified local layer. It is
10
+ * the runtime-agnostic core `createLocalLogger` configured for a terminal: the `style.ts` color seam,
11
+ * output on `stderr` (a command's machine-readable stdout stays clean), and level gated by `--debug`.
12
+ *
13
+ * This is *diagnostic logging only*. Interactive CLI UX — @clack prompts and spinners, `Done.`,
14
+ * `PithyError` `renderTerminal` — stays on `output.ts`/`style.ts` and never routes through here.
15
+ */
16
+
17
+ /** The level-keyed palette, drawn from the one color seam so all CLI color still flows through `style.ts`. */
18
+ const palette: LocalPalette = { debug: dim, info: cyan, warn: yellow, error: red, dim };
19
+
20
+ /** Options for {@link createCliLogger}, wired from a command's `--debug` / `--json` args. */
21
+ export interface CliLoggerOptions {
22
+ /** `--debug`: drop the threshold to `debug` (verbose diagnostics). Off → `warn`, so it stays quiet. */
23
+ debug?: boolean;
24
+ /** `--json`: emit a structured line stream (for agents/CI) instead of the colorized human format. */
25
+ json?: boolean;
26
+ /** Override the sink (tests capture it). Defaults to `stderr`. */
27
+ write?: (line: string) => void;
28
+ }
29
+
30
+ /**
31
+ * Build the CLI process logger. Quiet by default (`warn`), verbose under `--debug`. In `--json` mode it
32
+ * emits the same structured records the Worker adapter does — one line each — so an agent driving the
33
+ * CLI parses diagnostics the same way it parses a deployed Worker's logs.
34
+ */
35
+ export function createCliLogger(options: CliLoggerOptions = {}): Logger {
36
+ return createLocalLogger({
37
+ level: options.debug ? "debug" : "warn",
38
+ json: options.json,
39
+ palette: options.json ? undefined : palette,
40
+ write: options.write,
41
+ });
42
+ }
@@ -0,0 +1,64 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { ErrorPayload } from "@pithy-sh/core/src/error/payload";
5
+ import { PithyError } from "@pithy-sh/core/src/error/pithyError";
6
+ import { operatorError, renderTerminal } from "@pithy-sh/core/src/error/terminal";
7
+ import { red, saffron } from "./style";
8
+
9
+ /** Completion, brand voice: `Done.` with the saffron period (docs/CLI.md §3.2). */
10
+ export function formatDone(): string {
11
+ return `Done${saffron(".")}`;
12
+ }
13
+
14
+ /** One machine-readable line — every command's `--json` output shape. */
15
+ export function formatJsonLine(payload: Record<string, unknown>): string {
16
+ return JSON.stringify(payload);
17
+ }
18
+
19
+ /**
20
+ * A two-column name/description list, whitespace-aligned — the multi-row output
21
+ * shape from docs/CLI.md §3.5 (no borders; the whitespace is the layout). Names
22
+ * pad to the longest, then two spaces, then the description. Empty in → empty out.
23
+ */
24
+ export function formatList(rows: { name: string; description: string }[]): string {
25
+ const width = Math.max(0, ...rows.map((row) => row.name.length));
26
+ return rows.map((row) => `${row.name.padEnd(width)} ${row.description}`).join("\n");
27
+ }
28
+
29
+ /** Problem line (red), then action line (docs/CLI.md §3.3). */
30
+ export function formatError(payload: ErrorPayload): string {
31
+ const rendered = renderTerminal(payload);
32
+ const newline = rendered.indexOf("\n");
33
+ if (newline === -1) return red(rendered);
34
+ return red(rendered.slice(0, newline)) + rendered.slice(newline);
35
+ }
36
+
37
+ /**
38
+ * The `--json` error line: `{ error: <operator payload> }` — the public fields plus
39
+ * the `action` line, which is what {@link formatError} prints two lines above.
40
+ *
41
+ * Not the HTTP encoder, and that is the point. Both surfaces drop `detail`, but they
42
+ * drop it for different people: a browser is a caller, and whoever ran the command is
43
+ * the operator the remedy was written for. Reusing `HttpError.encode` here would have
44
+ * classified `action` by the encoder that happened to be shared rather than by who reads
45
+ * the line — and taken the fix out of a scripted `pithy` run for no gain anywhere.
46
+ */
47
+ export function formatErrorJson(payload: ErrorPayload): string {
48
+ return JSON.stringify({ error: operatorError(payload) });
49
+ }
50
+
51
+ /**
52
+ * Run a command body; on `PithyError`, report it to stderr and exit 1 — as the
53
+ * `{ error: … }` JSON line when `json` is set, otherwise the problem/action
54
+ * lines. Anything else is a CLI bug and keeps its stack trace.
55
+ */
56
+ export async function withErrorReporting(json: boolean, work: () => Promise<void>): Promise<void> {
57
+ try {
58
+ await work();
59
+ } catch (error) {
60
+ if (!(error instanceof PithyError)) throw error;
61
+ process.stderr.write(`${json ? formatErrorJson(error.payload) : formatError(error.payload)}\n`);
62
+ process.exit(1);
63
+ }
64
+ }
@@ -0,0 +1,132 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import pc from "picocolors";
5
+
6
+ /**
7
+ * The color seam. Every colored character the CLI prints flows through here —
8
+ * no raw ANSI anywhere else (docs/CLI.md §3.4). `picocolors` carries the
9
+ * terminal-themed tiers (dim, basic-16); `saffron` is the one truecolor brand
10
+ * accent, constant everywhere it renders.
11
+ */
12
+
13
+ const SAFFRON_TRUECOLOR = "\x1b[38;2;212;160;23m"; // #D4A017
14
+ const SAFFRON_256 = "\x1b[38;5;178m";
15
+ const RESET = "\x1b[0m";
16
+
17
+ function supportsTruecolor(): boolean {
18
+ const colorterm = process.env.COLORTERM;
19
+ return colorterm === "truecolor" || colorterm === "24bit";
20
+ }
21
+
22
+ /**
23
+ * Color is on only for an interactive terminal — never when the output is piped,
24
+ * redirected, or captured. `NO_COLOR` forces it off, `FORCE_COLOR` forces it on
25
+ * (the standard env overrides). We decide here rather than trusting
26
+ * `pc.isColorSupported`: picocolors treats any `CI` env as color-capable, which
27
+ * would bleed ANSI into our `--json` and `Done.` output the moment a CI runner
28
+ * (or a piped consumer) reads it. Our output is parsed; a TTY is the real signal.
29
+ */
30
+ function detectColor(): boolean {
31
+ if (process.env.NO_COLOR) return false;
32
+ if (process.env.FORCE_COLOR) return true;
33
+ return Boolean(process.stdout.isTTY);
34
+ }
35
+
36
+ // Decided once at import, the way picocolors itself latches its detection.
37
+ const enabled = detectColor();
38
+
39
+ /**
40
+ * The latched decision, for the one caller that needs the rule rather than a colored string: `bin.ts`
41
+ * hands it to citty, which renders its own help and consults none of the above. Exported so the rule
42
+ * lives in exactly one place — a second copy of it is how the help output came to disagree with every
43
+ * other surface in the first place.
44
+ */
45
+ export function colorEnabled(): boolean {
46
+ return enabled;
47
+ }
48
+
49
+ /** The brand mark in terminal form. Truecolor → 256-color 178 → no color. */
50
+ export function saffron(text: string): string {
51
+ if (!enabled) return text;
52
+ return (supportsTruecolor() ? SAFFRON_TRUECOLOR : SAFFRON_256) + text + RESET;
53
+ }
54
+
55
+ // The terminal-themed tiers, re-exported so all color still imports from the one
56
+ // seam. Built from our own `enabled` flag (not picocolors' detection) so they
57
+ // honor the same TTY-gated rule as `saffron`. This is the documented exception
58
+ // to the no-re-export rule; further tiers join as commands need them.
59
+ export const { red, yellow, cyan, dim, magenta, bold } = pc.createColors(enabled);
60
+
61
+ /**
62
+ * A group heading on the root help screen: bold + basic-16 magenta (docs/CLI.md §3.4).
63
+ *
64
+ * One symbol rather than a nesting each call site repeats, because "bold + magenta" is a tier decision
65
+ * and a tier decision lives here. A second call site that reached for half the pair would be a heading
66
+ * that looks styled and is not, which is the failure a reader cannot see.
67
+ *
68
+ * Magenta rather than saffron deliberately: §3.4 lists the places saffron appears and a heading is not
69
+ * one of them. A heading carries structure; saffron carries meaning.
70
+ */
71
+ export function heading(text: string): string {
72
+ return bold(magenta(text));
73
+ }
74
+
75
+ // A stable, TTY-gated palette for per-worker labels in `pithy dev`. Built from the one seam so no
76
+ // raw ANSI leaks. Cyan/red/yellow/dim are reserved for meaning elsewhere, so the palette leans on the
77
+ // remaining hues and their bright variants; it cycles when a project has more workers than colors.
78
+ // Magenta is in both this palette and `heading` on purpose: `pithy dev` and the help screen never share
79
+ // an output stream, and dropping an entry would silently reassign every worker's color from index 1 on.
80
+ const palette = pc.createColors(enabled);
81
+ const WORKER_PALETTE: readonly ((text: string) => string)[] = [
82
+ palette.green,
83
+ palette.magenta,
84
+ palette.blue,
85
+ palette.cyan,
86
+ palette.greenBright,
87
+ palette.magentaBright,
88
+ palette.blueBright,
89
+ palette.cyanBright,
90
+ ];
91
+
92
+ /**
93
+ * A stable color for one worker's log label, chosen by its discovery index and cycling the palette.
94
+ * `pithy dev` colorizes each worker's `[name]` prefix so interleaved output stays readable.
95
+ */
96
+ export function workerColor(index: number): (text: string) => string {
97
+ return WORKER_PALETTE[((index % WORKER_PALETTE.length) + WORKER_PALETTE.length) % WORKER_PALETTE.length] as (
98
+ text: string,
99
+ ) => string;
100
+ }
101
+
102
+ /**
103
+ * Wrap `text` in an OSC-8 terminal hyperlink pointing at `url` — the id-as-link
104
+ * rendering `pithy env` uses so a resource id opens its Cloudflare dashboard page.
105
+ * Gated on the same latched `enabled` flag as every other symbol here: off when the
106
+ * output is piped/redirected or `NO_COLOR` is set (where escape sequences would be
107
+ * noise), so the caller falls back to printing the plain URL. `FORCE_COLOR` forces
108
+ * it on. When off, the plain `text` is returned unchanged.
109
+ */
110
+ export function link(url: string, text: string): string {
111
+ if (!enabled) return safe(text);
112
+ return `\x1b]8;;${safe(url)}\x1b\\${safe(text)}\x1b]8;;\x1b\\`;
113
+ }
114
+
115
+ /**
116
+ * Strip C0/C1 control characters. Both halves of a hyperlink are values read out of a project's
117
+ * `wrangler.jsonc` — a resource id and a URL built from it — so an embedded `ESC` could close this
118
+ * sequence early and open its own, making the rendered link point somewhere other than what it displays.
119
+ * Stripping them also stops a malformed id from corrupting the surrounding output.
120
+ */
121
+ function safe(value: string): string {
122
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping control characters is the point.
123
+ return value.replace(/[\u0000-\u001f\u007f-\u009f]/g, "");
124
+ }
125
+
126
+ /**
127
+ * Whether OSC-8 hyperlinks render — the same TTY-gated `enabled` flag `link` uses.
128
+ * Callers branch on it to choose the clickable id or the plain-URL fallback.
129
+ */
130
+ export function supportsHyperlinks(): boolean {
131
+ return colorEnabled();
132
+ }
@@ -0,0 +1,190 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { mkdtemp, rm } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { afterEach, beforeEach, vi } from "vitest";
8
+ import type { ReconcilePlan } from "../capabilities/reconcile";
9
+ import type { DoctorReportOptions } from "../commands/doctor";
10
+ import type { BuildPlan, ProjectHealth, WorkerHealth } from "../doctor/health";
11
+ import type { FetchLike } from "../notifier/check";
12
+ import type { ShellInfo } from "../platform/shell";
13
+ import type { ProjectConfig } from "../project/config";
14
+ import type { ResolvedWorker } from "../project/workerScope";
15
+
16
+ /**
17
+ * Shared scaffolding for building a `DoctorReport` — every environment and network seam stubbed, so a
18
+ * suite states only the scenario it is about.
19
+ *
20
+ * Extracted so there is exactly one way to construct a report. `commands/doctor.test.ts` asserts what the
21
+ * renderer prints; `commands/doctorDocs.test.ts` asserts that `docs/CLI.md` prints the same thing. Two
22
+ * builders would let the doc pin drift from the suite it is meant to hold the doc against — the pin would
23
+ * still pass while describing a report the CLI no longer produces.
24
+ */
25
+
26
+ /**
27
+ * The nth **checked** Worker in a health report — #371's `WorkerHealth` union narrowed in one place.
28
+ *
29
+ * It throws rather than answering `undefined`, so a Worker that silently stopped being checked fails the
30
+ * assertion that was about something else, loudly, instead of passing as an optional-chained skip. The
31
+ * suites that are about the unchecked state read `health.workers` directly.
32
+ */
33
+ export function checkedWorker(
34
+ health: ProjectHealth | undefined,
35
+ index = 0,
36
+ ): Extract<WorkerHealth, { state: "checked" }> {
37
+ const worker = health?.workers[index];
38
+ if (worker?.state !== "checked") throw new Error(`worker ${index} was not checked: ${worker?.state ?? "absent"}`);
39
+ return worker;
40
+ }
41
+
42
+ /** A `fetch` mapping each package's registry URL to a canned latest version. */
43
+ export function registryFetch(versions: Record<string, string>): FetchLike {
44
+ return vi.fn(async (url: string) => {
45
+ const match = url.match(/@pithy-sh%2F([^/]+)\/latest/);
46
+ const name = match?.[1] ?? "";
47
+ const version = versions[name];
48
+ if (!version) return { ok: false, status: 404, json: async () => ({}) };
49
+ return { ok: true, status: 200, json: async () => ({ version }) };
50
+ });
51
+ }
52
+
53
+ /**
54
+ * A clean plan for one Worker.
55
+ *
56
+ * `deployedAs` is derived rather than echoed, so every fixture built here has a directory and a deployed
57
+ * name that differ. A harness that set both to `worker` would let code reading the wrong one pass.
58
+ */
59
+ export const cleanPlanFor = (worker: string): ReconcilePlan => ({
60
+ worker,
61
+ deployedAs: `acme-${worker}`,
62
+ env: "dev",
63
+ perCapability: [],
64
+ ejectedSkipped: [],
65
+ ledger: { state: "read", pending: 0, undeclared: [] },
66
+ entitlements: { state: "read", gates: [] },
67
+ missingPrerequisites: [],
68
+ declinedBindings: { state: "read", declines: [] },
69
+ missingVersionMetadata: false,
70
+ });
71
+
72
+ /** A plan builder that stamps the requested Worker's name onto a fixed plan. */
73
+ export const planStub = (plan: ReconcilePlan): BuildPlan =>
74
+ vi.fn(async (options) => ({ ...plan, worker: options.worker ?? plan.worker }));
75
+
76
+ /** A plan builder keyed by Worker — for a project whose Workers differ. */
77
+ export const planStubPer = (plans: Record<string, ReconcilePlan>): BuildPlan =>
78
+ vi.fn(async (options) => plans[options.worker ?? ""] ?? cleanPlanFor(options.worker ?? ""));
79
+
80
+ /** The Worker set doctor's resolver seam returns; `ResolvedWorker` is satisfied structurally. */
81
+ export const workerSet = (...names: string[]) =>
82
+ names.map((name) => ({ name, dir: `/p/apps/${name}`, capabilities: [] }) as unknown as ResolvedWorker);
83
+
84
+ /** The shell every fixture reports — installed alias included, so the alias line is a constant. */
85
+ export const zsh: ShellInfo = { kind: "zsh", rcPath: "/home/u/.zshrc", aliasSyntax: "alias p.='pithy'" };
86
+
87
+ /** The loaded root config every fixture reports. */
88
+ export const config: ProjectConfig = { name: "pithy-app" };
89
+
90
+ /** A throwaway directory per test, plus the two option builders every doctor suite starts from. */
91
+ export interface DoctorHarness {
92
+ /** The temp project directory. A getter: `beforeEach` makes a new one for every test. */
93
+ readonly dir: string;
94
+ /** The notifier state file inside it. */
95
+ readonly stateFile: string;
96
+ /** A project present, fresh registry, deterministic env/os/runtime — the verbose starting point. */
97
+ baseOptions(overrides?: Partial<DoctorReportOptions>): DoctorReportOptions;
98
+ /** Nothing to report: current CLI, current capabilities, alias installed — the terse starting point. */
99
+ healthyOptions(overrides?: Partial<DoctorReportOptions>): DoctorReportOptions;
100
+ }
101
+
102
+ /**
103
+ * Registers the per-test temp directory and returns the option builders. Call it once at module or
104
+ * `describe` scope; `dir` is a getter because each test gets a fresh directory, so a plain value captured
105
+ * at import would go stale after the first test.
106
+ */
107
+ export function doctorHarness(): DoctorHarness {
108
+ let dir = "";
109
+ let stateFile = "";
110
+ beforeEach(async () => {
111
+ dir = await mkdtemp(join(tmpdir(), "pithy-doctor-"));
112
+ stateFile = join(dir, "state.json");
113
+ });
114
+ afterEach(async () => {
115
+ await rm(dir, { recursive: true, force: true });
116
+ });
117
+
118
+ function baseOptions(overrides: Partial<DoctorReportOptions> = {}): DoctorReportOptions {
119
+ return {
120
+ projectDir: dir,
121
+ installedVersion: "1.2.0",
122
+ stateFile,
123
+ argv1: "/home/u/.bun/bin/pithy",
124
+ env: {},
125
+ homedir: "/home/u",
126
+ os: { name: "macOS", version: "14.5" },
127
+ runtime: { name: "Node", version: "22.10.0", nodeCompat: null },
128
+ node: "22.10.0",
129
+ // Injected so the unit suite never reaches Cloudflare; the live probe is exercised by its own module.
130
+ checkCloudflare: async () => ({
131
+ state: "ok" as const,
132
+ missing: [],
133
+ tokenStatus: "active",
134
+ credentialSplit: null,
135
+ }),
136
+ // Same reason: unstubbed, every test here would list the real account's D1 and R2.
137
+ checkProjectName: async () => ({ state: "ok" as const, project: "pithy-app", misnamed: [] }),
138
+ // And the same again for the machine's port registry: unstubbed, `present` and every row of the
139
+ // listing would depend on which checkouts the machine running the suite happens to hold — a report
140
+ // that differs by machine, and a transcript nobody could pin. One own block, which is the state a
141
+ // developer on their default branch is in; the suites about the other rows override this.
142
+ checkPortsRegistry: async () => ({
143
+ path: "/home/u/.config/pithy/dev-ports.json",
144
+ present: true,
145
+ stray: null,
146
+ root: "/home/u/code/acme",
147
+ unreadable: null,
148
+ entries: [
149
+ { root: "/home/u/code/acme", branch: "main", block: 0, base: 8787, size: 20, own: true, onDisk: true },
150
+ ],
151
+ }),
152
+ now: () => 1_000,
153
+ fetch: registryFetch({ cli: "1.3.0" }),
154
+ detectShell: async () => zsh,
155
+ readRc: async () => "# >>> pithy alias >>>\nalias p.='pithy'\n# <<< pithy alias <<<\n",
156
+ loadProject: async () => config,
157
+ resolveWorkers: async () => workerSet("api"),
158
+ installedCapabilities: async () => [
159
+ { name: "@pithy-sh/core", version: "1.2.0" },
160
+ { name: "@pithy-sh/auth", version: "1.1.8" },
161
+ { name: "@pithy-sh/leaderboard", version: "1.2.0" },
162
+ ],
163
+ buildPlan: planStub(cleanPlanFor("api")),
164
+ ...overrides,
165
+ };
166
+ }
167
+
168
+ return {
169
+ get dir(): string {
170
+ return dir;
171
+ },
172
+ get stateFile(): string {
173
+ return stateFile;
174
+ },
175
+ baseOptions,
176
+ healthyOptions(overrides: Partial<DoctorReportOptions> = {}): DoctorReportOptions {
177
+ return baseOptions({
178
+ installedVersion: "1.3.0",
179
+ argv1: "/opt/homebrew/bin/pithy",
180
+ fetch: registryFetch({ cli: "1.3.0", core: "1.2.0", auth: "1.2.0", leaderboard: "1.2.0" }),
181
+ installedCapabilities: async () => [
182
+ { name: "@pithy-sh/core", version: "1.2.0" },
183
+ { name: "@pithy-sh/auth", version: "1.2.0" },
184
+ { name: "@pithy-sh/leaderboard", version: "1.2.0" },
185
+ ],
186
+ ...overrides,
187
+ });
188
+ },
189
+ };
190
+ }