@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.
- package/LICENSE +21 -0
- package/package.json +72 -0
- package/scripts/templateManifest.ts +49 -0
- package/scripts/tsconfig.json +26 -0
- package/scripts/vendorTemplate.ts +84 -0
- package/scripts/verifyPack.ts +88 -0
- package/src/audit/cliAudit.ts +406 -0
- package/src/bin.ts +111 -0
- package/src/capabilities/add.ts +288 -0
- package/src/capabilities/addBootstrap.ts +275 -0
- package/src/capabilities/catalog.ts +175 -0
- package/src/capabilities/compose.ts +39 -0
- package/src/capabilities/configConstants.ts +74 -0
- package/src/capabilities/configImports.ts +397 -0
- package/src/capabilities/eject.ts +331 -0
- package/src/capabilities/emailProvisioner.ts +346 -0
- package/src/capabilities/entitlementGap.ts +70 -0
- package/src/capabilities/entryExports.ts +162 -0
- package/src/capabilities/flow.ts +550 -0
- package/src/capabilities/hostRegistry.ts +368 -0
- package/src/capabilities/loadFailure.ts +208 -0
- package/src/capabilities/manifests.ts +238 -0
- package/src/capabilities/mediaProvisioner.ts +471 -0
- package/src/capabilities/mintSecrets.ts +306 -0
- package/src/capabilities/paymentsProvisioner.ts +207 -0
- package/src/capabilities/prerequisites.ts +168 -0
- package/src/capabilities/r2Bucket.ts +113 -0
- package/src/capabilities/reconcile.ts +1483 -0
- package/src/capabilities/remove.ts +597 -0
- package/src/capabilities/requiredOptions.ts +92 -0
- package/src/capabilities/rotateSecrets.ts +305 -0
- package/src/capabilities/secrets.ts +178 -0
- package/src/capabilities/secretsDispatcher.ts +29 -0
- package/src/capabilities/secretsProvisioner.ts +389 -0
- package/src/capabilities/storageProvisioner.ts +414 -0
- package/src/capabilities/supportProvisioner.ts +515 -0
- package/src/capabilities/testersLoader.ts +52 -0
- package/src/capabilities/testersProvisioner.ts +236 -0
- package/src/capabilities/turnstileProvisioner.ts +347 -0
- package/src/capabilities/vectorProvisioner.ts +260 -0
- package/src/ci/fileModes.ts +223 -0
- package/src/ci/sourceFiles.ts +200 -0
- package/src/ci/workflowDrivers.ts +524 -0
- package/src/cloudflare/accountAnswer.ts +110 -0
- package/src/cloudflare/config.ts +685 -0
- package/src/cloudflare/storeId.ts +129 -0
- package/src/commands/add.ts +372 -0
- package/src/commands/alias.ts +205 -0
- package/src/commands/dashboard.ts +651 -0
- package/src/commands/deploy.ts +150 -0
- package/src/commands/dev.ts +37 -0
- package/src/commands/doctor.ts +2059 -0
- package/src/commands/email.ts +425 -0
- package/src/commands/env.ts +155 -0
- package/src/commands/feature.ts +359 -0
- package/src/commands/init.ts +538 -0
- package/src/commands/media.ts +303 -0
- package/src/commands/migrate.ts +129 -0
- package/src/commands/payments.ts +336 -0
- package/src/commands/provision.ts +368 -0
- package/src/commands/remove.ts +151 -0
- package/src/commands/secrets.ts +652 -0
- package/src/commands/seed.ts +229 -0
- package/src/commands/storage.ts +309 -0
- package/src/commands/support.ts +331 -0
- package/src/commands/testers.ts +1020 -0
- package/src/commands/token.ts +364 -0
- package/src/commands/turnstile.ts +271 -0
- package/src/commands/ui.ts +222 -0
- package/src/commands/upgrade.ts +517 -0
- package/src/commands/vector.ts +390 -0
- package/src/commands/worker.ts +295 -0
- package/src/dashboard/api.ts +323 -0
- package/src/dashboard/connect.ts +758 -0
- package/src/dashboard/contract.ts +289 -0
- package/src/dashboard/grant.ts +124 -0
- package/src/dashboard/registry.ts +519 -0
- package/src/dashboard/resolveTarget.ts +119 -0
- package/src/dev/delivery.ts +174 -0
- package/src/dev/devLogin.ts +155 -0
- package/src/dev/devLoginTargets.ts +91 -0
- package/src/dev/env.ts +206 -0
- package/src/dev/hostWorkers.ts +290 -0
- package/src/dev/keys.ts +111 -0
- package/src/dev/logging.ts +87 -0
- package/src/dev/openUrl.ts +75 -0
- package/src/dev/orchestrator.ts +1014 -0
- package/src/dev/ports.ts +220 -0
- package/src/dev/readyWatch.ts +142 -0
- package/src/dev/state.ts +90 -0
- package/src/devSecrets/bootstrapVars.ts +265 -0
- package/src/devSecrets/devVars.ts +240 -0
- package/src/devSecrets/edit.ts +256 -0
- package/src/devSecrets/file.ts +277 -0
- package/src/devSecrets/generate.ts +428 -0
- package/src/devSecrets/location.ts +80 -0
- package/src/devSecrets/mode.ts +71 -0
- package/src/devSecrets/records.ts +30 -0
- package/src/devSecrets/report.ts +99 -0
- package/src/devSecrets/seed.ts +344 -0
- package/src/devSecrets/store.ts +262 -0
- package/src/devSecrets/targets.ts +204 -0
- package/src/dispatch.ts +147 -0
- package/src/docs/catalog.ts +246 -0
- package/src/docs/writeCatalog.ts +45 -0
- package/src/doctor/cloudflare.ts +287 -0
- package/src/doctor/devPreferences.ts +155 -0
- package/src/doctor/devSecrets.ts +464 -0
- package/src/doctor/devVars.ts +414 -0
- package/src/doctor/devVarsLocal.ts +138 -0
- package/src/doctor/environments.ts +155 -0
- package/src/doctor/health.ts +354 -0
- package/src/doctor/localDelivery.ts +91 -0
- package/src/doctor/portsRegistry.ts +252 -0
- package/src/doctor/projectName.ts +584 -0
- package/src/doctor/secretBindings.ts +166 -0
- package/src/doctor/settings.ts +274 -0
- package/src/doctor/settingsSources.ts +202 -0
- package/src/doctor/workerName.ts +174 -0
- package/src/doctor/wranglerVars.ts +33 -0
- package/src/feature/bindings.ts +93 -0
- package/src/feature/create.ts +179 -0
- package/src/feature/destroy.ts +160 -0
- package/src/feature/devConfig.ts +201 -0
- package/src/feature/identity.ts +100 -0
- package/src/feature/manifest.ts +132 -0
- package/src/feature/ports.ts +615 -0
- package/src/feature/provision.ts +362 -0
- package/src/feature/sync.ts +148 -0
- package/src/feature/worktree.ts +282 -0
- package/src/help/groups.ts +47 -0
- package/src/help/rootUsage.ts +135 -0
- package/src/main.ts +73 -0
- package/src/migrations/ledger.ts +129 -0
- package/src/migrations/registry.ts +47 -0
- package/src/migrations/run.ts +1066 -0
- package/src/notifier/check.ts +129 -0
- package/src/notifier/installer.ts +48 -0
- package/src/notifier/notify.ts +152 -0
- package/src/notifier/state.ts +248 -0
- package/src/notifier/version.ts +59 -0
- package/src/platform/editor.ts +333 -0
- package/src/platform/rc.ts +118 -0
- package/src/platform/shell.ts +83 -0
- package/src/project/appBindings.ts +184 -0
- package/src/project/appWorkflows.ts +266 -0
- package/src/project/applyDomains.ts +166 -0
- package/src/project/askDomains.ts +220 -0
- package/src/project/atomic.ts +466 -0
- package/src/project/bindingEntries.ts +425 -0
- package/src/project/config.ts +701 -0
- package/src/project/dashboard.ts +118 -0
- package/src/project/deploy.ts +364 -0
- package/src/project/devVars.ts +113 -0
- package/src/project/domainPrompt.ts +191 -0
- package/src/project/domains.ts +386 -0
- package/src/project/envInventory.ts +356 -0
- package/src/project/environment.ts +125 -0
- package/src/project/extensions.ts +69 -0
- package/src/project/jsonc.ts +289 -0
- package/src/project/packageManager.ts +238 -0
- package/src/project/readOptionalFile.ts +342 -0
- package/src/project/rollback.ts +145 -0
- package/src/project/scaffold.ts +1088 -0
- package/src/project/templateFiles.ts +53 -0
- package/src/project/verifyDeploy.ts +230 -0
- package/src/project/versionMetadata.ts +77 -0
- package/src/project/workerAddress.ts +176 -0
- package/src/project/workerCommand.ts +564 -0
- package/src/project/workerIdentity.ts +50 -0
- package/src/project/workerManifest.ts +135 -0
- package/src/project/workerScaffold.ts +289 -0
- package/src/project/workerScope.ts +394 -0
- package/src/project/workers.ts +86 -0
- package/src/project/workflows.ts +281 -0
- package/src/project/wrangler.ts +168 -0
- package/src/provision/confirm.ts +86 -0
- package/src/provision/environment.ts +407 -0
- package/src/provision/featureConfig.ts +98 -0
- package/src/provision/mode.ts +62 -0
- package/src/provision/pendingSecrets.ts +96 -0
- package/src/provision/resources.ts +126 -0
- package/src/provision/secretBindings.ts +149 -0
- package/src/provision/store.ts +33 -0
- package/src/provision/unprovisioned.ts +114 -0
- package/src/provision/wranglerEnv.ts +220 -0
- package/src/rootFlags.ts +48 -0
- package/src/seed/drivers.ts +423 -0
- package/src/seed/media.ts +187 -0
- package/src/seed/plan.ts +137 -0
- package/src/seed/prepare.ts +224 -0
- package/src/seed/registry.ts +25 -0
- package/src/seed/run.ts +793 -0
- package/src/seed/safety.ts +206 -0
- package/src/terminal/logger.ts +42 -0
- package/src/terminal/output.ts +64 -0
- package/src/terminal/style.ts +132 -0
- package/src/test-utils/doctorHarness.ts +190 -0
- package/src/test-utils/migrateHarness.ts +126 -0
- package/src/test-utils/seedHarness.ts +173 -0
- package/src/test-utils/tempRepo.ts +45 -0
- package/src/tokens/config.ts +16 -0
- package/src/tokens/engine.ts +345 -0
- package/src/tokens/mintedTokens.ts +233 -0
- package/src/tokens/sinks.ts +84 -0
- package/src/ui/flow.ts +451 -0
- package/src/ui/react.ts +112 -0
- package/src/ui/routeAllowlist.ts +208 -0
- package/src/ui/scaffold.ts +113 -0
- package/src/ui/screenStyles.ts +127 -0
- package/src/ui/stubs.ts +135 -0
- package/src/ui/templates.ts +52 -0
- package/src/ui/wire.ts +311 -0
- package/src/ui/workerUi.ts +172 -0
- package/templates/starter/.dev.secrets.example.jsonc +43 -0
- package/templates/starter/.dev.vars.example +30 -0
- package/templates/starter/apps/api/package.json +22 -0
- package/templates/starter/apps/api/pithy.config.ts +65 -0
- package/templates/starter/apps/api/pithy.worker.jsonc +11 -0
- package/templates/starter/apps/api/src/bindings.workers.test.ts +18 -0
- package/templates/starter/apps/api/src/cloudflare-test.d.ts +11 -0
- package/templates/starter/apps/api/src/index.ts +8 -0
- package/templates/starter/apps/api/tsconfig.json +26 -0
- package/templates/starter/apps/api/wrangler.jsonc +68 -0
- package/templates/starter/biome.template.jsonc +75 -0
- package/templates/starter/gitignore +37 -0
- package/templates/starter/package.json +28 -0
- package/templates/starter/pithy.config.ts +67 -0
- package/templates/starter/plugins/no-console.grit +25 -0
- package/templates/starter/plugins/no-process-io.grit +25 -0
- package/templates/starter/tsconfig.json +14 -0
- package/templates/starter/tsconfig.tools.json +30 -0
- package/templates/starter/vitest.config.ts +124 -0
- 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
|
+
}
|