@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,2059 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
6
|
+
import { platform as osPlatform, release as osRelease } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { PithyError, ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
9
|
+
import { defineCommand } from "citty";
|
|
10
|
+
import {
|
|
11
|
+
type BindingDeclines,
|
|
12
|
+
type BuildReconcilePlanOptions,
|
|
13
|
+
buildReconcilePlan,
|
|
14
|
+
type ReconcilePlan,
|
|
15
|
+
undeclinableReason,
|
|
16
|
+
} from "../capabilities/reconcile";
|
|
17
|
+
import { pithyOffline } from "../cloudflare/config";
|
|
18
|
+
import { type CloudflareAccess, checkCloudflareAccess, describeCloudflareAccess } from "../doctor/cloudflare";
|
|
19
|
+
import { checkDevPreferences, type DevPreferencesCheck, describeDevPreferences } from "../doctor/devPreferences";
|
|
20
|
+
import {
|
|
21
|
+
checkDevSecrets,
|
|
22
|
+
checkDevSecretsLocation,
|
|
23
|
+
type DevSecretsCheck,
|
|
24
|
+
type DevSecretsLocationCheck,
|
|
25
|
+
describeDevSecrets,
|
|
26
|
+
describeDevSecretsLocation,
|
|
27
|
+
devSecretsHealthy,
|
|
28
|
+
} from "../doctor/devSecrets";
|
|
29
|
+
import { checkDevVars, type DevVarsCheck, describeDevVars, devVarsHealthy } from "../doctor/devVars";
|
|
30
|
+
import { checkDevVarsLocal, type DevVarsLocalCheck, describeDevVarsLocal } from "../doctor/devVarsLocal";
|
|
31
|
+
import { checkEnvironments, describeEnvironmentDrift, type EnvironmentsCheck } from "../doctor/environments";
|
|
32
|
+
import { buildProjectHealth, type MigrationHealth, type ProjectHealth, type WorkerChecks } from "../doctor/health";
|
|
33
|
+
import { checkLocalDelivery, describeLocalDelivery, type LocalDeliveryCheck } from "../doctor/localDelivery";
|
|
34
|
+
import {
|
|
35
|
+
checkPortsRegistry,
|
|
36
|
+
describePortsRegistry,
|
|
37
|
+
type PortsRegistryCheck,
|
|
38
|
+
type PortsRegistryEntry,
|
|
39
|
+
} from "../doctor/portsRegistry";
|
|
40
|
+
import { checkProjectName, describeProjectName, type ProjectNameCheck } from "../doctor/projectName";
|
|
41
|
+
import { checkSecretBindings, describeSecretBindings, type SecretBindingsCheck } from "../doctor/secretBindings";
|
|
42
|
+
import {
|
|
43
|
+
describeSettingsAccount,
|
|
44
|
+
describeSettingsFinding,
|
|
45
|
+
type SettingsCheck,
|
|
46
|
+
type SettingsFindingEntry,
|
|
47
|
+
} from "../doctor/settings";
|
|
48
|
+
import { doctorSettingsCheck } from "../doctor/settingsSources";
|
|
49
|
+
import { checkWorkerNames, describeWorkerName, type WorkerNameCheck } from "../doctor/workerName";
|
|
50
|
+
import { describeUndeclared, undeclaredRemedy } from "../migrations/ledger";
|
|
51
|
+
import { type FetchLike, fetchLatestVersion } from "../notifier/check";
|
|
52
|
+
import { detectInstaller, type Installer, upgradeCommandFor } from "../notifier/installer";
|
|
53
|
+
import { readState, setNotifierFlag, stateDir, stateFilePath, writeState } from "../notifier/state";
|
|
54
|
+
import { classifyBump } from "../notifier/version";
|
|
55
|
+
import { readRcFile } from "../platform/rc";
|
|
56
|
+
import { detectShell, type ShellInfo } from "../platform/shell";
|
|
57
|
+
import { loadProject, type ProjectConfig, projectCloudflareAccount } from "../project/config";
|
|
58
|
+
import { checkOrigins, describeOriginDrift, type OriginsCheck } from "../project/domains";
|
|
59
|
+
import { checkExtensions, describeExtension, type ExtensionsCheck } from "../project/extensions";
|
|
60
|
+
import { type ResolvedWorker, resolveWorkers } from "../project/workerScope";
|
|
61
|
+
import { checkWorkflows, describeWorkflowDrift, type WorkflowsCheck } from "../project/workflows";
|
|
62
|
+
import { formatJsonLine, withErrorReporting } from "../terminal/output";
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* `pithy doctor` (docs/CLI.md §5.6): the user-initiated health check. It bypasses the 24-hour notifier
|
|
66
|
+
* cache for a fresh registry query, reports the full toolchain state (CLI version, shell/alias, config,
|
|
67
|
+
* project capability versions) plus a `Project health` block — `pithy upgrade`'s reconcile in read-only
|
|
68
|
+
* mode — and exits non-zero when a health check fails so CI can gate on drift. Toolchain state alone never
|
|
69
|
+
* fails the exit. Outside a Pithy project the `Project:` line says there is no config here and every other
|
|
70
|
+
* `Project*` line is omitted — including `Project name:`, because with no project there is no name question.
|
|
71
|
+
*
|
|
72
|
+
* The health block is **per Worker**: each Worker under `apps/` carries its own `pithy.config.ts` and
|
|
73
|
+
* `wrangler.jsonc`, so each drifts on its own. Any unhealthy Worker fails the exit. `--worker <name>`
|
|
74
|
+
* narrows the block to one — the same flag `migrate`, `seed`, `upgrade`, and `env` take (docs/CLI.md §1.1),
|
|
75
|
+
* so CI can gate on a single Worker's health.
|
|
76
|
+
*/
|
|
77
|
+
|
|
78
|
+
/** The alias-block marker `pithy alias` writes — reused here to detect an installed `p.` shortcut. */
|
|
79
|
+
const ALIAS_MARKER = "# >>> pithy alias >>>";
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Whether the `p.` alias is installed — **three states, because a boolean cannot say "I could not tell"**
|
|
83
|
+
* (#210).
|
|
84
|
+
*
|
|
85
|
+
* The rc file is the one thing in this report `doctor` reads out of the adopter's own shell config, and
|
|
86
|
+
* an unreadable one — wrong mode, a dangling symlink, an `EIO` — used to throw out of
|
|
87
|
+
* {@link buildDoctorReport} and take the entire report with it: Cloudflare reachability, the secrets
|
|
88
|
+
* paths, project health, dev secrets. The least important line in the report cost every other line.
|
|
89
|
+
*
|
|
90
|
+
* Catching it to `false` is not the fix, and is why #203 stopped at making the failure legible.
|
|
91
|
+
* `Alias: not installed` about a file nothing could read is a lie, and the adopter's next move on reading
|
|
92
|
+
* it is `pithy alias` — which fails on the same file. So the third state is said out loud, and it names
|
|
93
|
+
* the file, which is the only thing anybody can act on.
|
|
94
|
+
*/
|
|
95
|
+
export type AliasState = "installed" | "not-installed" | "unknown";
|
|
96
|
+
|
|
97
|
+
/** The alias line's whole answer: which state, about which file, and why when nobody could tell. */
|
|
98
|
+
export interface AliasStatus {
|
|
99
|
+
state: AliasState;
|
|
100
|
+
/** The rc file the answer is about, or `null` when no shell was detected and none was resolved. */
|
|
101
|
+
rcPath: string | null;
|
|
102
|
+
/**
|
|
103
|
+
* Why the file would not read. Set exactly when {@link AliasStatus.state} is `unknown`.
|
|
104
|
+
*
|
|
105
|
+
* The refusal's own sentence — `readRcFile` owns those words (#203) — and never a byte of the file's
|
|
106
|
+
* contents: an rc file is where a developer keeps `export GITHUB_TOKEN=…`.
|
|
107
|
+
*/
|
|
108
|
+
reason: string | null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* One actionable sentence from a thrown failure — `message` and `action`, never `detail`.
|
|
113
|
+
*
|
|
114
|
+
* `detail` is throw-site context and the HTTP codec strips it for a reason; these lines reach a terminal
|
|
115
|
+
* and `--json`.
|
|
116
|
+
*/
|
|
117
|
+
function messageOf(error: unknown): string {
|
|
118
|
+
if (error instanceof PithyError) return `${error.payload.message} ${error.payload.action ?? ""}`.trim();
|
|
119
|
+
return error instanceof Error ? error.message : String(error);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Read the rc file and answer all three ways it can go.
|
|
124
|
+
*
|
|
125
|
+
* No shell is `not-installed` rather than `unknown`: nothing was refused, there is simply no rc file this
|
|
126
|
+
* platform's detection would write to, and `pithy alias` prints manual instructions for exactly that case.
|
|
127
|
+
*/
|
|
128
|
+
async function aliasStatus(shell: ShellInfo | null, readRc: (path: string) => Promise<string>): Promise<AliasStatus> {
|
|
129
|
+
if (!shell) return { state: "not-installed", rcPath: null, reason: null };
|
|
130
|
+
try {
|
|
131
|
+
const contents = await readRc(shell.rcPath);
|
|
132
|
+
return {
|
|
133
|
+
state: contents.includes(ALIAS_MARKER) ? "installed" : "not-installed",
|
|
134
|
+
rcPath: shell.rcPath,
|
|
135
|
+
reason: null,
|
|
136
|
+
};
|
|
137
|
+
} catch (error) {
|
|
138
|
+
return { state: "unknown", rcPath: shell.rcPath, reason: messageOf(error) };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const VERSION = (
|
|
143
|
+
JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")) as { version: string }
|
|
144
|
+
).version;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Whether a version could be compared against the registry at all.
|
|
148
|
+
*
|
|
149
|
+
* **`unknown` exists because a boolean cannot say "I did not find out".** The registry lookup fails for
|
|
150
|
+
* ordinary reasons — offline, an outage, a package not published yet — and collapsing that into
|
|
151
|
+
* `upToDate: true` made `doctor` report currency it had never established. A diagnostic that answers
|
|
152
|
+
* confidently when it could not check is worse than one that says nothing.
|
|
153
|
+
*/
|
|
154
|
+
export type VersionState = "current" | "outdated" | "unknown";
|
|
155
|
+
|
|
156
|
+
/** The CLI-version block of the report. */
|
|
157
|
+
export interface CliStatus {
|
|
158
|
+
installed: string;
|
|
159
|
+
latest: string | null;
|
|
160
|
+
installer: Installer;
|
|
161
|
+
state: VersionState;
|
|
162
|
+
upgradeCommand: string;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** One installed project capability's version state. */
|
|
166
|
+
export interface CapabilityStatus {
|
|
167
|
+
name: string;
|
|
168
|
+
installed: string;
|
|
169
|
+
latest: string | null;
|
|
170
|
+
state: VersionState;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The project-scoped portion of the report; `null` outside a Pithy project. */
|
|
174
|
+
export interface ProjectStatus {
|
|
175
|
+
capabilities: CapabilityStatus[];
|
|
176
|
+
health: ProjectHealth;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The complete doctor report — the structured source both the text and `--json` renderers project from. */
|
|
180
|
+
export interface DoctorReport {
|
|
181
|
+
cli: CliStatus;
|
|
182
|
+
shell: ShellInfo | null;
|
|
183
|
+
/** Whether the `p.` alias is installed, or whether its rc file could not be read at all (#210). */
|
|
184
|
+
alias: AliasStatus;
|
|
185
|
+
configDir: string;
|
|
186
|
+
stateFile: string;
|
|
187
|
+
notifierEnabled: boolean;
|
|
188
|
+
notifierDisabledBy: "env" | "state" | null;
|
|
189
|
+
project: ProjectStatus | null;
|
|
190
|
+
/** Set when a `pithy.config.ts` is present but could not be loaded (e.g. dependencies not installed). */
|
|
191
|
+
projectLoadError: string | null;
|
|
192
|
+
/** Whether the configured Cloudflare credentials actually reach the account. */
|
|
193
|
+
cloudflare: CloudflareAccess;
|
|
194
|
+
/**
|
|
195
|
+
* Whether the configured project name still matches the names this project's resources were provisioned
|
|
196
|
+
* under — `null` when the root config could not be read, so no name question arises. Set from the same
|
|
197
|
+
* `loadProject` outcome as {@link DoctorReport.project} and {@link DoctorReport.projectLoadError}, which
|
|
198
|
+
* is what keeps the two blocks from disputing whether there is a project here.
|
|
199
|
+
*/
|
|
200
|
+
projectName: ProjectNameCheck | null;
|
|
201
|
+
/**
|
|
202
|
+
* Whether each Worker's three names still agree — its `apps/<dir>`, its deployed script name, and its
|
|
203
|
+
* `WORKER` var. `null` outside a readable project, on the same `loadProject` outcome as
|
|
204
|
+
* {@link DoctorReport.projectName}: with no project there are no Workers to name.
|
|
205
|
+
*/
|
|
206
|
+
workerNames: WorkerNameCheck | null;
|
|
207
|
+
/**
|
|
208
|
+
* Whether every Worker's `env.<name>` stanzas are the environments the root config declares (#241), and
|
|
209
|
+
* whether a declaration changed after resources were provisioned under the old names. `null` outside a
|
|
210
|
+
* readable project, on the same `loadProject` outcome as the two above: with no declaration there is
|
|
211
|
+
* nothing to compare a stanza to.
|
|
212
|
+
*/
|
|
213
|
+
environments: EnvironmentsCheck | null;
|
|
214
|
+
/**
|
|
215
|
+
* Whether every environment this project declares names every origin it answers on (#253). `null`
|
|
216
|
+
* outside a readable project, on the same `loadProject` outcome as the checks above: with no declared
|
|
217
|
+
* set there is nothing to ask about.
|
|
218
|
+
*
|
|
219
|
+
* The question `pithy deploy` refuses on, asked without being asked — so an environment with no origin,
|
|
220
|
+
* or one whose `workers.dev` subdomain nobody decided about, is findable before a deploy is attempted
|
|
221
|
+
* rather than at the end of one.
|
|
222
|
+
*/
|
|
223
|
+
origins: OriginsCheck | null;
|
|
224
|
+
/**
|
|
225
|
+
* Whether each declared environment's `wrangler.jsonc` stanza binds exactly the Workflows and cron its
|
|
226
|
+
* Worker's app capability declares (#267). `null` outside a readable project, or one that names no
|
|
227
|
+
* project — a Workflow name leads with the project, so without one there is nothing to compare.
|
|
228
|
+
*
|
|
229
|
+
* The one fault in this report that is otherwise **completely silent**. A declared job that was never
|
|
230
|
+
* synced ships with no `workflows` entry and no `triggers.crons`: the cron never fires, and no request
|
|
231
|
+
* fails, no log line appears and no probe goes red to say so.
|
|
232
|
+
*/
|
|
233
|
+
workflows: WorkflowsCheck | null;
|
|
234
|
+
/**
|
|
235
|
+
* What an adopter plugged into a capability — a Better Auth plugin composed through the auth
|
|
236
|
+
* capability's config is the first (#271). `null` outside a readable project, on the same
|
|
237
|
+
* `loadProject` outcome as the checks above: with no Workers there is nothing composed to read.
|
|
238
|
+
*
|
|
239
|
+
* It reports and **never fails the exit**, and it is the only block here that is not about a fault.
|
|
240
|
+
* An extension an adopter deliberately added is not drift. What it must not be is invisible: it has
|
|
241
|
+
* no `package.json` for `Project capabilities:` to name it from, and it adds routes to the Worker and
|
|
242
|
+
* tables to the database all the same.
|
|
243
|
+
*/
|
|
244
|
+
extensions: ExtensionsCheck | null;
|
|
245
|
+
/**
|
|
246
|
+
* This project's dev-login preference file: where it goes, whether it is there, and whether it says
|
|
247
|
+
* anything a seed can use. `null` outside a readable project, on the same footing as the two above.
|
|
248
|
+
*
|
|
249
|
+
* It sits in the report because nothing else could say it. `dev.json` is machine-local and named by
|
|
250
|
+
* nothing in the checkout, and until this line doctor reported one config directory while `pithy seed`
|
|
251
|
+
* read a different one — so a developer whose dev login was not working looked where doctor pointed and
|
|
252
|
+
* found nothing, correctly.
|
|
253
|
+
*/
|
|
254
|
+
devPreferences: DevPreferencesCheck | null;
|
|
255
|
+
/**
|
|
256
|
+
* Where this machine's dev-port registry is, and whether an older CLI left one in the checkout.
|
|
257
|
+
*
|
|
258
|
+
* It sits in the report for the reason `devPreferences` does, one step further out: the registry left
|
|
259
|
+
* the main repo root in #435, and being inside the checkout was the only thing that ever made it
|
|
260
|
+
* findable. It decides every port `pithy dev` binds and nothing in the project mentions it.
|
|
261
|
+
*
|
|
262
|
+
* `null` outside a project — the stray half of the check is a question about a checkout.
|
|
263
|
+
*/
|
|
264
|
+
portsRegistry: PortsRegistryCheck | null;
|
|
265
|
+
/**
|
|
266
|
+
* Whether any declared `d1`-backed secret is still sitting in `.dev.vars`, and whether the secrets file
|
|
267
|
+
* is readable by anyone but its owner. `null` outside a project that composes `secrets` — with no
|
|
268
|
+
* registry there is no declared name, and so no misplaced one.
|
|
269
|
+
*
|
|
270
|
+
* It reports and never fails the exit. Every project that predates the dev secrets file has misplaced
|
|
271
|
+
* secrets by definition, and an upgrade that turns a green `pithy doctor` red in CI over a file that
|
|
272
|
+
* still works is a surprise rather than a diagnosis. Migration is told, not enforced.
|
|
273
|
+
*/
|
|
274
|
+
devSecrets: Checked<DevSecretsCheck> | null;
|
|
275
|
+
/**
|
|
276
|
+
* Whether every declared environment binds the `cf-secrets-store` secrets its Worker reads (#238).
|
|
277
|
+
* `null` outside a project that composes `secrets` — with no registry there is nothing to bind.
|
|
278
|
+
*
|
|
279
|
+
* It reports and never fails the exit, on the same rule its neighbors follow: every project that
|
|
280
|
+
* composed `secrets` and has not yet run `pithy secrets provision` is in this state, and so is every
|
|
281
|
+
* project that predates the stanza existing at all. That is a step not yet taken rather than a
|
|
282
|
+
* contradiction. What it must not be is silent — until this line the only thing that reported a
|
|
283
|
+
* missing binding was the Worker's own 500 on its first request.
|
|
284
|
+
*/
|
|
285
|
+
secretBindings: SecretBindingsCheck | null;
|
|
286
|
+
/**
|
|
287
|
+
* Whether each composed capability's **settings work**, as opposed to being merely present (#411).
|
|
288
|
+
* `null` when no capability any Worker composes declares a check — the ordinary case, and silent.
|
|
289
|
+
*
|
|
290
|
+
* Every other project check in this report asks a presence question, and all of them pass while
|
|
291
|
+
* `fromAddress` names a domain nobody onboarded, the link-signing key was never created, and
|
|
292
|
+
* `BASE_URL` points at a host nothing serves. Only the capability knows what its own values must be,
|
|
293
|
+
* so the capability declares the check and this runs it — keyed on the composed instance, never on a
|
|
294
|
+
* `pithy.manifest.json`, because two published capability packages ship none.
|
|
295
|
+
*
|
|
296
|
+
* **It fails the exit on a finding, from either tier.** A local finding is established from the
|
|
297
|
+
* project's own files; an account finding is established from an account that was actually reached.
|
|
298
|
+
* An account that could *not* be reached is reported as skipped and gates nothing, which is why the
|
|
299
|
+
* account tier's state is carried rather than inferred from an empty finding list.
|
|
300
|
+
*/
|
|
301
|
+
settings: SettingsCheck | null;
|
|
302
|
+
/**
|
|
303
|
+
* Whether local email delivery is **live** — whether a magic link triggered from localhost leaves this
|
|
304
|
+
* machine or is written to disk (#410). `null` when nothing composed puts a message on the wire.
|
|
305
|
+
*
|
|
306
|
+
* Not a fault, and it never fails the exit: the simulator is a legitimate choice, and an offline
|
|
307
|
+
* machine has no other. It prints because silence would be read as "of course it sends", which is the
|
|
308
|
+
* assumption that had developers waiting on an inbox for mail no local process could ever have posted.
|
|
309
|
+
* The verdict is {@link deliveryPreflight}'s own, so this and `pithy dev` cannot disagree.
|
|
310
|
+
*/
|
|
311
|
+
localDelivery: LocalDeliveryCheck | null;
|
|
312
|
+
/**
|
|
313
|
+
* What is in a `.dev.vars.local` that nothing else in the project knows about — a key that exists only
|
|
314
|
+
* in dev, and a key that shadows a registry secret. `null` when there is nothing to say, which is every
|
|
315
|
+
* project with no `.dev.vars.local` anywhere.
|
|
316
|
+
*
|
|
317
|
+
* It reports and never fails the exit, for the reason {@link ./devVarsLocal} gives: both states are
|
|
318
|
+
* legitimate, and neither may be invisible.
|
|
319
|
+
*/
|
|
320
|
+
devVarsLocal: Checked<DevVarsLocalCheck> | null;
|
|
321
|
+
/**
|
|
322
|
+
* The two `.dev.vars` questions nothing else asks: whether each Worker's generated file actually
|
|
323
|
+
* carries anything, and whether the project root's hand-written one is still holding values nothing
|
|
324
|
+
* reads. `null` outside a readable project, on the same footing as the checks above.
|
|
325
|
+
*
|
|
326
|
+
* It reports and never fails the exit, for the same reason {@link DoctorReport.devSecrets} does not:
|
|
327
|
+
* every project that predates the generated file (#154) is in this state by definition, and an
|
|
328
|
+
* upgrade that turns a green `pithy doctor` red in CI is a surprise rather than a diagnosis. What it
|
|
329
|
+
* must not be is silent — until #178 the only thing that reported either was a 500 from a running
|
|
330
|
+
* Worker naming the bindings it did not have.
|
|
331
|
+
*/
|
|
332
|
+
devVars: Checked<DevVarsCheck> | null;
|
|
333
|
+
/**
|
|
334
|
+
* Where this project's dev secrets file is, and whether it is there. `null` outside a project with a
|
|
335
|
+
* resolvable name, on the same footing as {@link DoctorReport.devPreferences}.
|
|
336
|
+
*
|
|
337
|
+
* **Separate from {@link DoctorReport.devSecrets}, and reported even when that one is `null`.** That
|
|
338
|
+
* check needs a registry to compare against, so a project that has not composed `secrets` yet gets no
|
|
339
|
+
* answer from it — and a location is still the question an adopter has, because the file is outside
|
|
340
|
+
* the checkout (#156) and nothing in the project names it. It never fails the exit and it is not a
|
|
341
|
+
* term in the terse predicate: a path is not a fault. It prints in **both** forms of the report for
|
|
342
|
+
* that same reason (#166) — a healthy project is the one most likely to be asking where the file is.
|
|
343
|
+
*/
|
|
344
|
+
devSecretsFile: Checked<DevSecretsLocationCheck> | null;
|
|
345
|
+
os: { name: string; version: string };
|
|
346
|
+
/** The runtime actually executing, which under Bun is not what `process.versions.node` reports. */
|
|
347
|
+
runtime: RuntimeInfo;
|
|
348
|
+
node: string;
|
|
349
|
+
/**
|
|
350
|
+
* Whether this run refused ambient credentials and every network call — `PITHY_OFFLINE`, or `--offline`
|
|
351
|
+
* (#218).
|
|
352
|
+
*
|
|
353
|
+
* Carried in the report rather than read again at each renderer, because the two things it changes are
|
|
354
|
+
* both *wordings*: a version state of `unknown` means "the registry did not answer" on an ordinary run
|
|
355
|
+
* and "nobody asked it" on this one, and a diagnostic that reports the first when the second happened is
|
|
356
|
+
* describing a network fault that does not exist. `--json` carries it for the same reason one level up —
|
|
357
|
+
* a script reading `state: "unknown"` cannot otherwise tell a skipped check from a failed one.
|
|
358
|
+
*/
|
|
359
|
+
offline: boolean;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* A probe's own answer, or that the probe threw (#371).
|
|
364
|
+
*
|
|
365
|
+
* Eleven checks contribute to this report, and until #371 five of them could take the whole thing down
|
|
366
|
+
* while six were caught into `null` — which already meant *the question does not arise here*. Both halves
|
|
367
|
+
* were the same defect: a report is what an adopter reads to find out why something is wrong, and losing
|
|
368
|
+
* ten findings to an eleventh, or filing a failure under "not applicable", are two ways of not saying it.
|
|
369
|
+
*
|
|
370
|
+
* Every probe is guarded now, and every failure lands as a `state` **on the value**. Six of the eleven
|
|
371
|
+
* checks already carried a `could-not-check` member of their own and use it; `CloudflareAccess` and
|
|
372
|
+
* `DevPreferencesCheck` gained one; and the four whose payload is a bag of findings with no discriminant
|
|
373
|
+
* wear this wrapper, so the finding fields are unreachable without narrowing. One representation — a
|
|
374
|
+
* `state` on the value — in four vocabularies, which is how `#210` and `#350` differ too.
|
|
375
|
+
*
|
|
376
|
+
* **`null` still means the question does not arise**, and that is what keeps the two apart: a project that
|
|
377
|
+
* composes no `secrets` has no dev-secrets question, and a project whose registry would not load has one
|
|
378
|
+
* nobody answered.
|
|
379
|
+
*/
|
|
380
|
+
export type Checked<T> = ({ state: "checked" } & T) | { state: "could-not-check" };
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Run a probe, and turn a throw into `could-not-check` rather than into a lost report (#371).
|
|
384
|
+
*
|
|
385
|
+
* The `catch` takes no binding, on `#350`'s rule: these probes read an adopter's config files, their
|
|
386
|
+
* `.dev.vars`, and their Cloudflare credentials, so what they throw names paths, account ids and — in the
|
|
387
|
+
* worst case — a value. Nothing derived from it reaches a terminal or `--json`. What survives is which
|
|
388
|
+
* check could not run, which is the one thing anybody can act on and which the line already names.
|
|
389
|
+
*/
|
|
390
|
+
async function probed<T>(probe: () => Promise<T>, unavailable: T): Promise<T> {
|
|
391
|
+
try {
|
|
392
|
+
return await probe();
|
|
393
|
+
} catch {
|
|
394
|
+
return unavailable;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* The same guard for a check whose payload has no discriminant of its own — see {@link Checked}.
|
|
400
|
+
*
|
|
401
|
+
* `try`/`catch` rather than `.catch()` in both, and that is not style. Every one of these probes is an
|
|
402
|
+
* injectable seam, and a seam that throws *before* returning a promise is not a rejected promise —
|
|
403
|
+
* `.catch()` never sees it, and the report dies exactly as it did before the guard was written.
|
|
404
|
+
*/
|
|
405
|
+
async function checkedProbe<T>(probe: () => Promise<T | null>): Promise<Checked<T> | null> {
|
|
406
|
+
try {
|
|
407
|
+
const answer = await probe();
|
|
408
|
+
// A probe's own `null` is preserved, never wrapped. It is the third fact this report already had a
|
|
409
|
+
// word for — the question does not arise here — and folding it into `checked` would trade one
|
|
410
|
+
// conflation for another.
|
|
411
|
+
return answer === null ? null : { state: "checked", ...answer };
|
|
412
|
+
} catch {
|
|
413
|
+
return { state: "could-not-check" };
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* The `devSecrets` payload: every finding with its own sentence, or the state that says nobody looked.
|
|
419
|
+
*
|
|
420
|
+
* Lifted out of the JSON renderer because it is the one probe whose payload is long enough that a
|
|
421
|
+
* conditional expression around it stopped being readable — and because #371 gave it a second shape a
|
|
422
|
+
* consumer has to be able to tell apart from `null`. `null` is "this project composes no secrets";
|
|
423
|
+
* `{ state: "could-not-check" }` is "it does and nobody could read the registry".
|
|
424
|
+
*/
|
|
425
|
+
function jsonDevSecrets(value: Checked<DevSecretsCheck> | null): Record<string, unknown> | null {
|
|
426
|
+
if (value === null) return null;
|
|
427
|
+
const check = reported(value);
|
|
428
|
+
if (check === null) return { state: "could-not-check" };
|
|
429
|
+
return {
|
|
430
|
+
state: "checked",
|
|
431
|
+
path: check.path,
|
|
432
|
+
misplaced: check.misplaced,
|
|
433
|
+
missing: check.missing,
|
|
434
|
+
// The two the human block has printed since #323 and this payload did not carry (#325). A JSON
|
|
435
|
+
// consumer could not see the fault class that wave added — and `malformed` is the one that flips
|
|
436
|
+
// the exit, so a script read a value the next seed refuses as a healthy project.
|
|
437
|
+
bootstrapMissing: check.bootstrapMissing,
|
|
438
|
+
malformed: check.malformed,
|
|
439
|
+
undeclared: check.undeclared,
|
|
440
|
+
mode: check.mode === null ? null : check.mode.toString(8),
|
|
441
|
+
// The loader's sentence since #323, and a `string | null` ever since. A script gating on
|
|
442
|
+
// `unreadable === true` stopped firing the moment it stopped being a boolean, and stopped silently,
|
|
443
|
+
// because a non-empty string is not `false` — it is merely not `true`. The sentence stays, because
|
|
444
|
+
// it names the secret and the shape; `healthy` is what a gate reads.
|
|
445
|
+
unreadable: check.unreadable,
|
|
446
|
+
// The one field a script can gate on without enumerating fault names. Through the same function the
|
|
447
|
+
// exit code is computed from, so the payload and the exit cannot come to two answers — and so the
|
|
448
|
+
// next fault class added here needs no consumer to be updated (#325).
|
|
449
|
+
healthy: devSecretsHealthy(check),
|
|
450
|
+
// The Workers this project has that nobody could ask what they declare (#208). Carried here because
|
|
451
|
+
// a `devSecrets` object with no targets and a `null` one used to be the same answer, and an agent
|
|
452
|
+
// reading either had no way to tell "no secrets" from "nothing loaded".
|
|
453
|
+
unresolvable: check.unresolvable,
|
|
454
|
+
detail: describeDevSecrets(check),
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* The check when the probe answered, and `null` when it either did not arise or could not run.
|
|
460
|
+
*
|
|
461
|
+
* A convenience for the render sites that treat "no answer" alike — the ones that must say *which*
|
|
462
|
+
* kind of no-answer it is read `state` directly, and there are lines below that do.
|
|
463
|
+
*/
|
|
464
|
+
function reported<T>(value: Checked<T> | null): T | null {
|
|
465
|
+
return value?.state === "checked" ? value : null;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* The interpreter running the CLI.
|
|
470
|
+
*
|
|
471
|
+
* Reporting `process.versions.node` alone is actively wrong under Bun: Bun sets it to the Node version it
|
|
472
|
+
* emulates, so `doctor` would name a runtime that is not executing — the one thing a diagnostic must not do.
|
|
473
|
+
* Both are kept, because the emulated level is what the `engines.node >= 22` floor is judged against.
|
|
474
|
+
*/
|
|
475
|
+
export interface RuntimeInfo {
|
|
476
|
+
/** `Bun` or `Node`. */
|
|
477
|
+
name: string;
|
|
478
|
+
/** The runtime's own version. */
|
|
479
|
+
version: string;
|
|
480
|
+
/** The Node version being emulated, when the runtime is not Node itself. */
|
|
481
|
+
nodeCompat: string | null;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Detect the executing runtime. `process.versions.bun` is Bun-only and absent from Node's typings. */
|
|
485
|
+
export function detectRuntime(versions: NodeJS.ProcessVersions = process.versions): RuntimeInfo {
|
|
486
|
+
const bun = (versions as { bun?: string }).bun;
|
|
487
|
+
if (bun) return { name: "Bun", version: bun, nodeCompat: versions.node };
|
|
488
|
+
return { name: "Node", version: versions.node, nodeCompat: null };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** Classify an installed version against what the registry returned — `unknown` when it returned nothing. */
|
|
492
|
+
export function versionState(installed: string, latest: string | null): VersionState {
|
|
493
|
+
if (latest === null) return "unknown";
|
|
494
|
+
return classifyBump(installed, latest) === "none" ? "current" : "outdated";
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** Map a Node platform id to a human OS name. */
|
|
498
|
+
function osName(platform: NodeJS.Platform): string {
|
|
499
|
+
if (platform === "darwin") return "macOS";
|
|
500
|
+
if (platform === "win32") return "Windows";
|
|
501
|
+
if (platform === "linux") return "Linux";
|
|
502
|
+
return platform;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** Options for {@link buildDoctorReport} — every environment and network dependency is injectable for tests. */
|
|
506
|
+
export interface DoctorReportOptions {
|
|
507
|
+
projectDir: string;
|
|
508
|
+
/** Narrow the health block to one Worker, by name or `apps/<dir>` basename. Every Worker when omitted. */
|
|
509
|
+
worker?: string;
|
|
510
|
+
installedVersion?: string;
|
|
511
|
+
fetch?: FetchLike;
|
|
512
|
+
now?: () => number;
|
|
513
|
+
stateFile?: string;
|
|
514
|
+
argv1?: string;
|
|
515
|
+
env?: NodeJS.ProcessEnv;
|
|
516
|
+
homedir?: string;
|
|
517
|
+
os?: { name: string; version: string };
|
|
518
|
+
/** Runtime seam; defaults to {@link detectRuntime}. */
|
|
519
|
+
runtime?: RuntimeInfo;
|
|
520
|
+
node?: string;
|
|
521
|
+
/**
|
|
522
|
+
* Refuse ambient credentials and every network call. Defaults to {@link pithyOffline} over `env`.
|
|
523
|
+
*
|
|
524
|
+
* The flag's route in. Explicit `false` is a caller saying so and the variable does not override it —
|
|
525
|
+
* the same reading `CloudflareConfigOptions.offline` takes, because the two have to agree or a run is
|
|
526
|
+
* offline in one half of itself.
|
|
527
|
+
*/
|
|
528
|
+
offline?: boolean;
|
|
529
|
+
/** Shell detection seam; defaults to {@link detectShell}. */
|
|
530
|
+
detectShell?: () => Promise<ShellInfo | null>;
|
|
531
|
+
/** rc-file reader seam; defaults to {@link readRcFile}. */
|
|
532
|
+
readRc?: (path: string) => Promise<string>;
|
|
533
|
+
/** Installed-capability enumerator seam; defaults to scanning `node_modules/@pithy-sh/*`. */
|
|
534
|
+
installedCapabilities?: (projectDir: string) => Promise<{ name: string; version: string }[]>;
|
|
535
|
+
/** Project-config loader seam; defaults to {@link loadProject} (a `NotFoundError` marks "outside a project"). */
|
|
536
|
+
loadProject?: (projectDir: string) => Promise<ProjectConfig>;
|
|
537
|
+
/** Worker-set resolver seam; defaults to {@link resolveWorkers}. The health block reports one entry per Worker. */
|
|
538
|
+
resolveWorkers?: (options: { projectDir: string; worker?: string }) => Promise<ResolvedWorker[]>;
|
|
539
|
+
/** Health plan-builder seam, forwarded to {@link buildProjectHealth}. */
|
|
540
|
+
buildPlan?: (options: BuildReconcilePlanOptions) => Promise<ReconcilePlan>;
|
|
541
|
+
/** Migration-ledger seam for the health plan. */
|
|
542
|
+
readLedger?: BuildReconcilePlanOptions["readLedger"];
|
|
543
|
+
/**
|
|
544
|
+
* Cloudflare-credential probe seam; defaults to {@link checkCloudflareAccess}. Injected so unit tests
|
|
545
|
+
* never call out. It takes no project directory: the credentials are account-scoped (#182), so the
|
|
546
|
+
* answer is the same in every checkout on this machine.
|
|
547
|
+
*/
|
|
548
|
+
checkCloudflare?: () => Promise<CloudflareAccess>;
|
|
549
|
+
/** Project-name probe seam; defaults to {@link checkProjectName}. Injected so unit tests never call out. */
|
|
550
|
+
checkProjectName?: (projectDir: string) => Promise<ProjectNameCheck | null>;
|
|
551
|
+
/** Worker-name agreement seam; defaults to {@link checkWorkerNames}. Reads files only — no account call. */
|
|
552
|
+
checkWorkerNames?: (projectDir: string) => Promise<WorkerNameCheck>;
|
|
553
|
+
/** Environment-declaration seam; defaults to {@link checkEnvironments}. Reads files only — no account call. */
|
|
554
|
+
checkEnvironments?: (projectDir: string) => Promise<EnvironmentsCheck>;
|
|
555
|
+
/** Origin-declaration seam; defaults to {@link checkOrigins}. Reads files only — no account call. */
|
|
556
|
+
checkOrigins?: (projectDir: string) => Promise<OriginsCheck>;
|
|
557
|
+
/** App-Workflow binding seam; defaults to {@link checkWorkflows}. Reads files only — no account call. */
|
|
558
|
+
checkWorkflows?: (projectDir: string) => Promise<WorkflowsCheck>;
|
|
559
|
+
/**
|
|
560
|
+
* Dev-login preference seam; defaults to {@link checkDevPreferences} resolved against the same `homedir`
|
|
561
|
+
* and `env` the config directory is, so the line can never name a path this report did not resolve.
|
|
562
|
+
*/
|
|
563
|
+
checkDevPreferences?: (projectDir: string) => Promise<DevPreferencesCheck | null>;
|
|
564
|
+
/**
|
|
565
|
+
* Port-registry seam; defaults to {@link checkPortsRegistry} resolved against the same `homedir` and
|
|
566
|
+
* `env` the config directory is, so the line can never name a path this report did not resolve.
|
|
567
|
+
*/
|
|
568
|
+
checkPortsRegistry?: (projectDir: string) => Promise<PortsRegistryCheck>;
|
|
569
|
+
/** Seam: whether this project's secrets are in the file they belong in, without loading real configs. */
|
|
570
|
+
checkDevSecrets?: (projectDir: string) => Promise<DevSecretsCheck | null>;
|
|
571
|
+
/** Seam: whether every declared environment binds the Secrets Store entries its Worker reads. */
|
|
572
|
+
checkSecretBindings?: (projectDir: string) => Promise<SecretBindingsCheck | null>;
|
|
573
|
+
/** Seam: what this project's `.dev.vars.local` files carry that nothing else declares. */
|
|
574
|
+
checkDevVarsLocal?: (projectDir: string) => Promise<DevVarsLocalCheck | null>;
|
|
575
|
+
/**
|
|
576
|
+
* Seam: whether each Worker's generated `.dev.vars` carries anything, and what the root one is
|
|
577
|
+
* holding that nothing reads. Resolved against the same `homedir` and `env` every other per-project
|
|
578
|
+
* path in this report is, so the file it names can never be one this report did not resolve.
|
|
579
|
+
*/
|
|
580
|
+
checkDevVars?: (projectDir: string) => Promise<DevVarsCheck | null>;
|
|
581
|
+
/**
|
|
582
|
+
* Settings-check seam; defaults to {@link doctorSettingsCheck} bound to this run's account and offline
|
|
583
|
+
* mode. It takes the resolved Workers rather than a directory, because the checks hang off the composed
|
|
584
|
+
* `Capability` instances this report already holds — nothing here re-enumerates `apps/`, so
|
|
585
|
+
* `--worker <name>` narrows this block exactly as it narrows the health block.
|
|
586
|
+
*/
|
|
587
|
+
checkSettings?: (options: {
|
|
588
|
+
projectDir: string;
|
|
589
|
+
workers: readonly ResolvedWorker[];
|
|
590
|
+
}) => Promise<SettingsCheck | null>;
|
|
591
|
+
/**
|
|
592
|
+
* Local-delivery seam; defaults to {@link checkLocalDelivery} over the same resolved Workers. Like the
|
|
593
|
+
* settings probe it takes the composition rather than a directory, so nothing here re-enumerates
|
|
594
|
+
* `apps/` and `--worker <name>` narrows it the same way.
|
|
595
|
+
*/
|
|
596
|
+
checkLocalDelivery?: (options: {
|
|
597
|
+
projectDir: string;
|
|
598
|
+
workers: readonly ResolvedWorker[];
|
|
599
|
+
}) => Promise<LocalDeliveryCheck | null>;
|
|
600
|
+
/**
|
|
601
|
+
* Dev-secrets location seam; defaults to {@link checkDevSecretsLocation} resolved against the same
|
|
602
|
+
* `homedir` and `env` the config directory is, so the line can never name a path this report did not
|
|
603
|
+
* resolve — the defect #131 fixed for `dev.json`, in the file beside it.
|
|
604
|
+
*/
|
|
605
|
+
checkDevSecretsFile?: (projectDir: string) => Promise<DevSecretsLocationCheck | null>;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** Enumerate installed `@pithy-sh/*` packages (excluding the CLI itself) with their versions, name-sorted. */
|
|
609
|
+
export async function installedCapabilityVersions(projectDir: string): Promise<{ name: string; version: string }[]> {
|
|
610
|
+
const scopeDir = join(projectDir, "node_modules", "@pithy-sh");
|
|
611
|
+
let entries: string[];
|
|
612
|
+
try {
|
|
613
|
+
entries = await readdir(scopeDir);
|
|
614
|
+
} catch {
|
|
615
|
+
return [];
|
|
616
|
+
}
|
|
617
|
+
const found: { name: string; version: string }[] = [];
|
|
618
|
+
for (const entry of entries) {
|
|
619
|
+
if (entry === "cli") continue; // the CLI binary is the top block, not a project capability
|
|
620
|
+
try {
|
|
621
|
+
const raw = await readFile(join(scopeDir, entry, "package.json"), "utf8");
|
|
622
|
+
const version = (JSON.parse(raw) as { version?: string }).version;
|
|
623
|
+
if (typeof version === "string") found.push({ name: `@pithy-sh/${entry}`, version });
|
|
624
|
+
} catch {
|
|
625
|
+
// No package.json / unreadable — skip it.
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
return found.sort((a, b) => a.name.localeCompare(b.name));
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/** Build the full structured report — always querying the registry fresh (doctor bypasses the notifier cache). */
|
|
632
|
+
/**
|
|
633
|
+
* The settings verdict when the probe itself never ran — the root config states no name, a Worker's
|
|
634
|
+
* `pithy.config.ts` would not import, the seam threw.
|
|
635
|
+
*
|
|
636
|
+
* Not `null`, which is the documented shape for "no composed capability declares a check": an agent
|
|
637
|
+
* reading that after a config that would not load concludes this project has no settings questions.
|
|
638
|
+
* And the account tier is `not-declared` rather than `unreachable`, because nothing here reached for an
|
|
639
|
+
* account — a local failure rendered as one Cloudflare caused sends the reader to the wrong machine.
|
|
640
|
+
*/
|
|
641
|
+
function settingsNotRun(): SettingsCheck {
|
|
642
|
+
return {
|
|
643
|
+
state: "could-not-check",
|
|
644
|
+
account: { state: "skipped", reason: "not-declared" },
|
|
645
|
+
checked: [],
|
|
646
|
+
findings: [],
|
|
647
|
+
unchecked: [],
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
export async function buildDoctorReport(options: DoctorReportOptions): Promise<DoctorReport> {
|
|
652
|
+
const installed = options.installedVersion ?? VERSION;
|
|
653
|
+
const env = options.env ?? process.env;
|
|
654
|
+
const argv1 = options.argv1 ?? process.argv[1] ?? "";
|
|
655
|
+
const installer = detectInstaller(argv1);
|
|
656
|
+
const doFetch = options.fetch ?? (globalThis.fetch as unknown as FetchLike);
|
|
657
|
+
const now = options.now ?? Date.now;
|
|
658
|
+
const file = options.stateFile ?? stateFilePath();
|
|
659
|
+
const detect = options.detectShell ?? (() => detectShell());
|
|
660
|
+
const readRc = options.readRc ?? readRcFile;
|
|
661
|
+
const listCapabilities = options.installedCapabilities ?? installedCapabilityVersions;
|
|
662
|
+
// The account this project belongs to, read before the probe so `doctor` reports on the credentials
|
|
663
|
+
// this project's own commands resolve. Outside a project there is none, and the unnamed file is the
|
|
664
|
+
// right answer — a config that will not load is what the `Project:` line is for, not this one.
|
|
665
|
+
const account = await projectCloudflareAccount(options.projectDir).catch(() => null);
|
|
666
|
+
// Resolved once, here, and handed down. Every consumer of it below is asking the same question, and a
|
|
667
|
+
// report that computed it twice could answer differently in its own two halves.
|
|
668
|
+
const offline = options.offline ?? pithyOffline(env);
|
|
669
|
+
const probeCloudflare =
|
|
670
|
+
options.checkCloudflare ??
|
|
671
|
+
(() => checkCloudflareAccess({ ...(options.homedir ? { homedir: options.homedir } : {}), env, account, offline }));
|
|
672
|
+
const probePortsRegistry = (): Promise<PortsRegistryCheck> =>
|
|
673
|
+
(
|
|
674
|
+
options.checkPortsRegistry ??
|
|
675
|
+
((dir: string) => checkPortsRegistry(dir, { ...(options.homedir ? { homedir: options.homedir } : {}), env }))
|
|
676
|
+
)(options.projectDir);
|
|
677
|
+
const probeProjectName = options.checkProjectName ?? checkProjectName;
|
|
678
|
+
const probeWorkerNames = options.checkWorkerNames ?? checkWorkerNames;
|
|
679
|
+
const probeEnvironments = options.checkEnvironments ?? checkEnvironments;
|
|
680
|
+
const probeOrigins = options.checkOrigins ?? checkOrigins;
|
|
681
|
+
const probeWorkflows = options.checkWorkflows ?? checkWorkflows;
|
|
682
|
+
const probeDevPreferences =
|
|
683
|
+
options.checkDevPreferences ??
|
|
684
|
+
((dir: string) => checkDevPreferences(dir, { ...(options.homedir ? { homedir: options.homedir } : {}), env }));
|
|
685
|
+
const probeDevSecrets = options.checkDevSecrets ?? ((dir: string) => checkDevSecrets({ projectDir: dir }));
|
|
686
|
+
const probeSecretBindings =
|
|
687
|
+
options.checkSecretBindings ?? ((dir: string) => checkSecretBindings({ projectDir: dir }));
|
|
688
|
+
const probeDevVarsLocal = options.checkDevVarsLocal ?? ((dir: string) => checkDevVarsLocal({ projectDir: dir }));
|
|
689
|
+
const probeDevVars =
|
|
690
|
+
options.checkDevVars ??
|
|
691
|
+
((dir: string) =>
|
|
692
|
+
checkDevVars({
|
|
693
|
+
projectDir: dir,
|
|
694
|
+
paths: { ...(options.homedir ? { homedir: options.homedir } : {}), env },
|
|
695
|
+
}));
|
|
696
|
+
const probeSettings =
|
|
697
|
+
options.checkSettings ??
|
|
698
|
+
((scope: { projectDir: string; workers: readonly ResolvedWorker[] }) =>
|
|
699
|
+
doctorSettingsCheck({
|
|
700
|
+
...scope,
|
|
701
|
+
// The same account and the same offline decision the `Cloudflare:` block above reports on,
|
|
702
|
+
// resolved once at the top of this function. A settings check that asked a second account would
|
|
703
|
+
// be a second report inside this one.
|
|
704
|
+
account,
|
|
705
|
+
offline,
|
|
706
|
+
...(options.homedir ? { homedir: options.homedir } : {}),
|
|
707
|
+
env,
|
|
708
|
+
}));
|
|
709
|
+
const probeLocalDelivery =
|
|
710
|
+
options.checkLocalDelivery ??
|
|
711
|
+
((scope: { projectDir: string; workers: readonly ResolvedWorker[] }) => checkLocalDelivery({ ...scope, env }));
|
|
712
|
+
const probeDevSecretsFile =
|
|
713
|
+
options.checkDevSecretsFile ??
|
|
714
|
+
((dir: string) => checkDevSecretsLocation(dir, { ...(options.homedir ? { homedir: options.homedir } : {}), env }));
|
|
715
|
+
|
|
716
|
+
// Fresh CLI-version check, then persist it into the notifier state (installer detected once when unknown).
|
|
717
|
+
//
|
|
718
|
+
// **Not asked at all when offline.** This one is unauthenticated and reaches npm rather than Cloudflare,
|
|
719
|
+
// so it is not the credential leak #218 is about — but a mode that says no network and then blocks for a
|
|
720
|
+
// DNS timeout on a plane has told the adopter something untrue, and the state it would land in
|
|
721
|
+
// (`unknown`) is the same one a registry outage produces. Skipping it keeps `unknown` meaning one thing
|
|
722
|
+
// per run, which the two version lines then say in the run's own words.
|
|
723
|
+
const cliLatest = offline ? null : await fetchLatestVersion("cli", { fetch: doFetch });
|
|
724
|
+
const state = await readState(file);
|
|
725
|
+
// Discarded on failure, like every read in this command and for the same reason (#210). The notifier
|
|
726
|
+
// cache is bookkeeping for the *next* run — a config directory that will not take a write is exactly
|
|
727
|
+
// the machine somebody runs `doctor` on, and losing the whole report over a file nothing in it reports
|
|
728
|
+
// is the shape this command exists to not have.
|
|
729
|
+
await writeState(file, {
|
|
730
|
+
...state,
|
|
731
|
+
lastCheck: now(),
|
|
732
|
+
latestVersion: cliLatest?.version ?? state.latestVersion,
|
|
733
|
+
securityFlagged: cliLatest?.securityFlagged ?? state.securityFlagged,
|
|
734
|
+
installer: state.installer === "unknown" ? installer : state.installer,
|
|
735
|
+
}).catch(() => undefined);
|
|
736
|
+
|
|
737
|
+
const cli: CliStatus = {
|
|
738
|
+
installed,
|
|
739
|
+
latest: cliLatest?.version ?? null,
|
|
740
|
+
installer,
|
|
741
|
+
state: versionState(installed, cliLatest?.version ?? null),
|
|
742
|
+
upgradeCommand: upgradeCommandFor(installer),
|
|
743
|
+
};
|
|
744
|
+
|
|
745
|
+
// Shell / alias. The rc read is the only one in this report that reaches into the adopter's own shell
|
|
746
|
+
// config, and it is the least important line here — so it becomes a state rather than an exception.
|
|
747
|
+
const shell = await detect();
|
|
748
|
+
const alias = await aliasStatus(shell, readRc);
|
|
749
|
+
|
|
750
|
+
// Notifier state for display.
|
|
751
|
+
const disabledByEnv = Boolean(env.PITHY_NO_UPDATE_NOTIFIER);
|
|
752
|
+
const disabledByState = state.notifier === false;
|
|
753
|
+
const notifierEnabled = !disabledByEnv && !disabledByState;
|
|
754
|
+
const notifierDisabledBy = disabledByEnv ? "env" : disabledByState ? "state" : null;
|
|
755
|
+
|
|
756
|
+
// Project block — omitted outside a Pithy project.
|
|
757
|
+
let project: ProjectStatus | null = null;
|
|
758
|
+
let projectLoadError: string | null = null;
|
|
759
|
+
// Filled from the resolved Workers below, where the composed capabilities are already in hand — this
|
|
760
|
+
// check needs no file of its own and reaches nothing.
|
|
761
|
+
let extensions: ExtensionsCheck | null = null;
|
|
762
|
+
// Held out of the `try` so the guarded probe below still has the composition to run, even when a later
|
|
763
|
+
// step of the project block threw. The settings checks are adopter code reached through a live
|
|
764
|
+
// `import()`, so they are guarded like every other probe rather than run inside the block.
|
|
765
|
+
let resolvedWorkers: readonly ResolvedWorker[] = [];
|
|
766
|
+
// Whether that list is the project's composition or merely the value it was initialized to. An empty
|
|
767
|
+
// list is a legitimate answer and an unresolved one is not, and the settings probe is the one reader
|
|
768
|
+
// that cannot tell them apart on its own: over `[]` it answers `null`, which the JSON contract defines
|
|
769
|
+
// as "no composed capability declares a check". A project whose `pithy.config.ts` would not import
|
|
770
|
+
// would read as one with no settings questions.
|
|
771
|
+
let workersResolved = false;
|
|
772
|
+
const load = options.loadProject ?? loadProject;
|
|
773
|
+
const resolve = options.resolveWorkers ?? resolveWorkers;
|
|
774
|
+
// Set the moment the root config loads, so a `core/not_found` raised *later* (no workers under apps/)
|
|
775
|
+
// is reported as a broken project rather than mistaken for "outside a project".
|
|
776
|
+
let inProject = false;
|
|
777
|
+
try {
|
|
778
|
+
await load(options.projectDir);
|
|
779
|
+
inProject = true;
|
|
780
|
+
const installedCaps = await listCapabilities(options.projectDir);
|
|
781
|
+
const capabilities: CapabilityStatus[] = [];
|
|
782
|
+
for (const cap of installedCaps) {
|
|
783
|
+
const unscoped = cap.name.replace("@pithy-sh/", "");
|
|
784
|
+
const latest = offline ? null : await fetchLatestVersion(unscoped, { fetch: doFetch });
|
|
785
|
+
capabilities.push({
|
|
786
|
+
name: cap.name,
|
|
787
|
+
installed: cap.version,
|
|
788
|
+
latest: latest?.version ?? null,
|
|
789
|
+
state: versionState(cap.version, latest?.version ?? null),
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
const workers = await resolve({
|
|
793
|
+
projectDir: options.projectDir,
|
|
794
|
+
...(options.worker !== undefined ? { worker: options.worker } : {}),
|
|
795
|
+
});
|
|
796
|
+
resolvedWorkers = workers;
|
|
797
|
+
workersResolved = true;
|
|
798
|
+
extensions = checkExtensions(workers.map((worker) => ({ name: worker.name, capabilities: worker.capabilities })));
|
|
799
|
+
const health = await buildProjectHealth({
|
|
800
|
+
projectDir: options.projectDir,
|
|
801
|
+
env: "dev",
|
|
802
|
+
// The same account the `Cloudflare:` block above reports on, resolved once at the top of this
|
|
803
|
+
// function. A doctor that counted pending migrations against one account and named another in the
|
|
804
|
+
// line beside it would be two reports in one (#234).
|
|
805
|
+
account,
|
|
806
|
+
workers: workers.map((worker) => ({
|
|
807
|
+
name: worker.name,
|
|
808
|
+
dir: worker.dir,
|
|
809
|
+
capabilities: worker.capabilities,
|
|
810
|
+
// Optional-chained because `resolveWorkers` is a test seam: a double that supplies no config
|
|
811
|
+
// is a Worker that declines nothing, not a crash in the report it was called to produce.
|
|
812
|
+
config: worker.config,
|
|
813
|
+
})),
|
|
814
|
+
buildPlan: options.buildPlan,
|
|
815
|
+
readLedger: options.readLedger,
|
|
816
|
+
});
|
|
817
|
+
project = { capabilities, health };
|
|
818
|
+
} catch (error) {
|
|
819
|
+
if (error instanceof PithyError && !inProject && error.payload.code === "core/not_found") {
|
|
820
|
+
project = null; // outside a Pithy project — omit the Project* lines entirely
|
|
821
|
+
} else if (error instanceof PithyError) {
|
|
822
|
+
// A pithy.config.ts is present but the project could not be read — its config would not load (most often:
|
|
823
|
+
// dependencies not installed), or it holds no Worker to check. Degrade to a toolchain report with an
|
|
824
|
+
// actionable project line rather than aborting: a diagnostic command must still work in exactly the
|
|
825
|
+
// broken-environment case it exists to diagnose. Drives a non-zero exit below.
|
|
826
|
+
projectLoadError = `${error.payload.message} ${error.payload.action ?? ""}`.trim();
|
|
827
|
+
} else {
|
|
828
|
+
throw error; // a genuine CLI bug — keep its stack
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// Credentials are checked whether or not a project loaded: `.dev.vars` is read from the directory, and
|
|
833
|
+
// "are my credentials right" is a question worth answering before `pithy init` as much as after.
|
|
834
|
+
// Every probe below is guarded, and each failure lands on that check's own value (#371). None of them
|
|
835
|
+
// is load-bearing: they are eleven independent questions about one project, and there is no order in
|
|
836
|
+
// which one's answer is a precondition for another's.
|
|
837
|
+
const cloudflare = await probed<CloudflareAccess>(probeCloudflare, {
|
|
838
|
+
state: "probe_failed",
|
|
839
|
+
missing: [],
|
|
840
|
+
tokenStatus: null,
|
|
841
|
+
credentialSplit: null,
|
|
842
|
+
});
|
|
843
|
+
// The name is different, and it is asked only of a project whose root config actually loaded. It is not a
|
|
844
|
+
// question about the directory, it is a question about a config: "is the `name` in this file still the one
|
|
845
|
+
// every provisioned resource was named under". With no readable config there is no name and no question,
|
|
846
|
+
// and the `Project:` block below already reports which of the two happened. Gated on the same `inProject`
|
|
847
|
+
// that block is written from, so neither can contradict the other about whether a project is here.
|
|
848
|
+
const projectName = inProject
|
|
849
|
+
? await probed<ProjectNameCheck | null>(() => probeProjectName(options.projectDir), {
|
|
850
|
+
state: "could-not-check",
|
|
851
|
+
project: null,
|
|
852
|
+
misnamed: [],
|
|
853
|
+
})
|
|
854
|
+
: null;
|
|
855
|
+
// The same question one level down, and gated the same way. `checkProjectName` asks whether this
|
|
856
|
+
// project's name still names its resources; this asks whether each Worker's own three names still name
|
|
857
|
+
// one Worker. Files only, so it costs nothing and answers offline.
|
|
858
|
+
const workerNames = inProject
|
|
859
|
+
? await probed<WorkerNameCheck>(() => probeWorkerNames(options.projectDir), {
|
|
860
|
+
state: "could-not-check",
|
|
861
|
+
mismatches: [],
|
|
862
|
+
})
|
|
863
|
+
: null;
|
|
864
|
+
// And once more, one level out: the declaration is project-wide, so with no readable config there is
|
|
865
|
+
// nothing to compare each Worker's stanzas to. Files only, so it answers offline like the two above.
|
|
866
|
+
const environments = inProject
|
|
867
|
+
? await probed<EnvironmentsCheck>(() => probeEnvironments(options.projectDir), {
|
|
868
|
+
state: "could-not-check",
|
|
869
|
+
declared: [],
|
|
870
|
+
drift: [],
|
|
871
|
+
})
|
|
872
|
+
: null;
|
|
873
|
+
// One level further out again, and gated the same way: an environment is declared, and now what is
|
|
874
|
+
// true *about* it — where it answers — must be too. Files only, so it answers offline like the rest.
|
|
875
|
+
const origins = inProject
|
|
876
|
+
? await probed<OriginsCheck>(() => probeOrigins(options.projectDir), { state: "could-not-check", drift: [] })
|
|
877
|
+
: null;
|
|
878
|
+
// The same shape once more, on the other thing a `pithy.config.ts` declares and a `wrangler.jsonc` has
|
|
879
|
+
// to agree with: the app capability's Workflows and cron. Files only, so it answers offline like the rest.
|
|
880
|
+
const workflows = inProject
|
|
881
|
+
? await probed<WorkflowsCheck>(() => probeWorkflows(options.projectDir), { state: "could-not-check", drift: [] })
|
|
882
|
+
: null;
|
|
883
|
+
// Gated the same way once more: `dev.json` is keyed by the project's own name, so with no readable config
|
|
884
|
+
// there is no path to resolve and no file to look for. Files only, no account, no seed run.
|
|
885
|
+
const devPreferences = inProject
|
|
886
|
+
? await probed<DevPreferencesCheck | null>(() => probeDevPreferences(options.projectDir), {
|
|
887
|
+
// The config directory this report already resolved, never a `dev.json` path this run did not
|
|
888
|
+
// compute — resolving that path is what failed (#131's rule, applied to #371's state). Nothing
|
|
889
|
+
// prints it: the `Dev login:` line drops the path entirely in this state.
|
|
890
|
+
state: "could-not-check",
|
|
891
|
+
path: stateDir({ homedir: options.homedir, env }),
|
|
892
|
+
user: null,
|
|
893
|
+
})
|
|
894
|
+
: null;
|
|
895
|
+
// Gated the same way, and files only: the two `.dev.` files and each Worker's registry. No account, no
|
|
896
|
+
// database, no seed run — so it answers offline, in the project that is not working.
|
|
897
|
+
const devSecrets = inProject ? await checkedProbe(() => probeDevSecrets(options.projectDir)) : null;
|
|
898
|
+
// Gated the same way, and no account: the registry, two `stat` calls, and one `git rev-parse` — the
|
|
899
|
+
// spawn this check was written to avoid, and the only thing that says which of the machine's blocks are
|
|
900
|
+
// this checkout's, since a worktree's directory is not the registry key. Guarded inside the check. It
|
|
901
|
+
// reports a location, so its could-not-check state is simply `null` — see `probePortsRegistry`.
|
|
902
|
+
const portsRegistry = inProject ? await probed<PortsRegistryCheck | null>(() => probePortsRegistry(), null) : null;
|
|
903
|
+
// The same registry, asked about the deployed environments rather than the local file. Files only once
|
|
904
|
+
// more: whether a store *entry* exists is provisioning's question, and this one is whether the stanza
|
|
905
|
+
// that would bind it is there at all (#238).
|
|
906
|
+
const secretBindings = inProject
|
|
907
|
+
? await probed<SecretBindingsCheck | null>(() => probeSecretBindings(options.projectDir), {
|
|
908
|
+
state: "could-not-check",
|
|
909
|
+
missing: [],
|
|
910
|
+
})
|
|
911
|
+
: null;
|
|
912
|
+
// Gated the same way, and asked separately: this one needs no registry, so it answers for a project
|
|
913
|
+
// that has never composed `secrets` — which is the project most likely to be asking where the file is.
|
|
914
|
+
const devSecretsFile = inProject ? await checkedProbe(() => probeDevSecretsFile(options.projectDir)) : null;
|
|
915
|
+
const devVarsLocal = inProject ? await checkedProbe(() => probeDevVarsLocal(options.projectDir)) : null;
|
|
916
|
+
// Gated the same way, and files only once more: each Worker's generated `.dev.vars` and the root's.
|
|
917
|
+
// No account, no store, no seed run — which matters here more than anywhere, because the state it
|
|
918
|
+
// reports is a project whose Workers cannot start.
|
|
919
|
+
const devVars = inProject ? await checkedProbe(() => probeDevVars(options.projectDir)) : null;
|
|
920
|
+
// The one project check that runs the capabilities' own code. Gated on `inProject` like the rest, and
|
|
921
|
+
// guarded like the rest — a capability's check that throws must cost this report that capability's
|
|
922
|
+
// verdict and nothing else. `could-not-check` here is the whole-probe failure; a single capability's is
|
|
923
|
+
// carried inside the value, as `unchecked`.
|
|
924
|
+
const settings = !inProject
|
|
925
|
+
? null
|
|
926
|
+
: workersResolved
|
|
927
|
+
? await probed<SettingsCheck | null>(
|
|
928
|
+
() => probeSettings({ projectDir: options.projectDir, workers: resolvedWorkers }),
|
|
929
|
+
settingsNotRun(),
|
|
930
|
+
)
|
|
931
|
+
: settingsNotRun();
|
|
932
|
+
|
|
933
|
+
// The same composition the settings probe runs over, asked the one question `Settings:` does not: not
|
|
934
|
+
// whether the values work, but whether anything this machine sends would actually leave it. Guarded
|
|
935
|
+
// like every other probe, and `null` on a failure — an unanswered delivery question is exactly as
|
|
936
|
+
// silent as a project that composes nothing which sends.
|
|
937
|
+
const localDelivery =
|
|
938
|
+
inProject && workersResolved
|
|
939
|
+
? await probed<LocalDeliveryCheck | null>(
|
|
940
|
+
() => probeLocalDelivery({ projectDir: options.projectDir, workers: resolvedWorkers }),
|
|
941
|
+
null,
|
|
942
|
+
)
|
|
943
|
+
: null;
|
|
944
|
+
|
|
945
|
+
return {
|
|
946
|
+
cli,
|
|
947
|
+
shell,
|
|
948
|
+
alias,
|
|
949
|
+
configDir: stateDir({ homedir: options.homedir, env }),
|
|
950
|
+
stateFile: file,
|
|
951
|
+
notifierEnabled,
|
|
952
|
+
notifierDisabledBy,
|
|
953
|
+
project,
|
|
954
|
+
projectLoadError,
|
|
955
|
+
cloudflare,
|
|
956
|
+
projectName,
|
|
957
|
+
workerNames,
|
|
958
|
+
environments,
|
|
959
|
+
origins,
|
|
960
|
+
workflows,
|
|
961
|
+
extensions,
|
|
962
|
+
devPreferences,
|
|
963
|
+
portsRegistry,
|
|
964
|
+
devSecrets,
|
|
965
|
+
secretBindings,
|
|
966
|
+
settings,
|
|
967
|
+
localDelivery,
|
|
968
|
+
devSecretsFile,
|
|
969
|
+
devVarsLocal,
|
|
970
|
+
devVars,
|
|
971
|
+
os: options.os ?? { name: osName(osPlatform()), version: osRelease() },
|
|
972
|
+
runtime: options.runtime ?? detectRuntime(),
|
|
973
|
+
node: options.node ?? process.versions.node,
|
|
974
|
+
offline,
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/** Non-zero exit when a health check fails or the project could not load (the CI gate). Toolchain state never fails it. */
|
|
979
|
+
export function doctorExitCode(report: DoctorReport): number {
|
|
980
|
+
if (report.projectLoadError) return 1;
|
|
981
|
+
// Configured-but-broken credentials are drift worth gating CI on. Absent ones are not: a project that has
|
|
982
|
+
// not been provisioned yet is a legitimate state, and failing it would make `doctor` useless before setup.
|
|
983
|
+
// `not_checked` is the same standard once more and the clearest case of it — the check did not run, so it
|
|
984
|
+
// established nothing, and a caller who asked for no network did not ask for a red exit (#218).
|
|
985
|
+
const cloudflare = report.cloudflare.state;
|
|
986
|
+
// `probe_failed` joins them on the same standard (#371): the probe threw, so it established nothing —
|
|
987
|
+
// and a credentials file that will not parse is not a credential this project has been shown to lack.
|
|
988
|
+
if (
|
|
989
|
+
cloudflare !== "ok" &&
|
|
990
|
+
cloudflare !== "unconfigured" &&
|
|
991
|
+
cloudflare !== "not_checked" &&
|
|
992
|
+
cloudflare !== "probe_failed"
|
|
993
|
+
) {
|
|
994
|
+
return 1;
|
|
995
|
+
}
|
|
996
|
+
// Listed positively, never as "anything but ok": only a fault this project's own config or wiring
|
|
997
|
+
// positively establishes may gate CI. `unconfigured` (no name yet) and `could-not-check` (the wiring
|
|
998
|
+
// would not read) establish nothing, and failing on either would break every run in an unprovisioned
|
|
999
|
+
// project. `invalid` meets the same standard the other two do — the name is set, and no Cloudflare
|
|
1000
|
+
// namespace can carry it, which is why every other command already hard-fails on it.
|
|
1001
|
+
//
|
|
1002
|
+
// `drifted` and `orphaned` now meet it too, which they did not always: `drifted` used to fire on any one
|
|
1003
|
+
// declared name that merely had Pithy's shape, so an adopter's pre-existing `myapp-prod-db` — the ordinary
|
|
1004
|
+
// Cloudflare convention — turned a green CI red on the adoption path. Both are evidence-backed now.
|
|
1005
|
+
// `drifted` is a wholesale contradiction between this repo's own config and its own wiring, checkable
|
|
1006
|
+
// from files alone; `orphaned` is Pithy's own `pithy_migrations_owner` stamp naming another project. A
|
|
1007
|
+
// name that only *looks* like ours establishes nothing and no longer reaches either.
|
|
1008
|
+
//
|
|
1009
|
+
// A `null` check is the same standard once more: no readable config means nothing was established about
|
|
1010
|
+
// any name, and `doctor` outside a project — to read the CLI version, the shell, the alias — must exit 0.
|
|
1011
|
+
const state = report.projectName?.state;
|
|
1012
|
+
if (state === "invalid" || state === "drifted" || state === "orphaned") return 1;
|
|
1013
|
+
// Same standard once more, and it is met from local files alone: a Worker's directory and its own
|
|
1014
|
+
// wrangler.jsonc contradict each other about which Worker this is. Nothing is inferred about the
|
|
1015
|
+
// account, and `could-not-check` establishes nothing, so only `drifted` gates.
|
|
1016
|
+
if (report.workerNames?.state === "drifted") return 1;
|
|
1017
|
+
// Same standard again, and met the same way: the root config and a Worker's own wrangler.jsonc
|
|
1018
|
+
// contradict each other about which environments this project has. Nothing about the account is
|
|
1019
|
+
// inferred — an orphan is established by ids the checkout already commits — and `could-not-check`
|
|
1020
|
+
// establishes nothing, so only `drifted` gates.
|
|
1021
|
+
if (report.environments?.state === "drifted") return 1;
|
|
1022
|
+
// The same standard, and two of the three origin faults meet it. `workers-dev-open` is a Worker serving
|
|
1023
|
+
// a live origin its own config does not name, established from that config alone — and it is the
|
|
1024
|
+
// security half: on that origin the CSRF gate refuses the requests that establish who you are, and
|
|
1025
|
+
// nothing bound to the hostname applies. `unserved-origin` is the mirror image and the harder failure:
|
|
1026
|
+
// the config names an origin and nothing in it serves that host, so the Worker answers nowhere — which
|
|
1027
|
+
// is the state this command's own `workers_dev` remedy used to produce, then report as healthy (#264).
|
|
1028
|
+
// Both are established from the project's own files. `no-origin` is the state every freshly scaffolded
|
|
1029
|
+
// project is in before it has a domain, which is legitimate and universal; failing it would turn
|
|
1030
|
+
// `pithy doctor` red on day one for everyone and teach them to stop reading it. It is reported, loudly,
|
|
1031
|
+
// and `pithy deploy` is what refuses it — the moment it stops being hypothetical.
|
|
1032
|
+
if (report.origins?.drift.some((drift) => drift.fault !== "no-origin")) return 1;
|
|
1033
|
+
// The same standard, and **both** faults meet it — there is no day-one state here to spare. A project
|
|
1034
|
+
// that declares no Workflows has no drift to report; one that declares them and has not synced is a
|
|
1035
|
+
// contradiction between its own two files, checkable offline, and the only fault in this report whose
|
|
1036
|
+
// consequence is nothing happening at all: the cron never fires and nothing anywhere says so (#267).
|
|
1037
|
+
// An unwritable declaration is the same standard once more — the declaration itself is what no command
|
|
1038
|
+
// can act on. `could-not-check` establishes nothing and carries no drift, so it never reaches here.
|
|
1039
|
+
if (report.workflows && report.workflows.drift.length > 0) return 1;
|
|
1040
|
+
// And once more, on a file rather than a config. A `dev.json` that will not parse or names no user is a
|
|
1041
|
+
// fault this machine's own disk establishes: the file is there, and nothing will ever read anything out of
|
|
1042
|
+
// it. `absent` is the documented default — no file, no session, magic links only — so it never gates, and
|
|
1043
|
+
// CI (which has no `dev.json` at all) is therefore never touched by this check. The audience is the
|
|
1044
|
+
// developer whose dev login stopped working, which is the audience the whole check exists for.
|
|
1045
|
+
const preferences = report.devPreferences?.state;
|
|
1046
|
+
if (preferences === "unparseable" || preferences === "no-user") return 1;
|
|
1047
|
+
// The same standard as everything above it, and **both tiers meet it** (#411). A local finding is
|
|
1048
|
+
// established from the project's own config through the capability's own schema. An account finding is
|
|
1049
|
+
// established from an account that answered — which is exactly why the list is what gates and the
|
|
1050
|
+
// tier's state is not: an account nobody reached contributes no findings, so a run that skipped it
|
|
1051
|
+
// cannot fail here. `could-not-check` and `unchecked` establish nothing and never reach this line.
|
|
1052
|
+
if (report.settings && report.settings.findings.length > 0) return 1;
|
|
1053
|
+
return report.project && !report.project.health.ok ? 1 : 0;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/** Abbreviate a home-relative path to `~/…` for display. */
|
|
1057
|
+
function tildify(path: string, home: string): string {
|
|
1058
|
+
return path === home ? "~" : path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
/**
|
|
1062
|
+
* The width of the paths block's label field — `Config dir: `, `Secrets: `, `Ports: `, all twelve.
|
|
1063
|
+
*
|
|
1064
|
+
* Named because the port listing indents under it, and a continuation that is off by a space reads as a
|
|
1065
|
+
* different block. Derived from the longest label rather than typed as twelve, so a longer one moves both.
|
|
1066
|
+
*/
|
|
1067
|
+
const PATHS_INDENT = " ".repeat("Config dir: ".length);
|
|
1068
|
+
|
|
1069
|
+
/**
|
|
1070
|
+
* The `Ports:` line's listing — one row per allocated block, this checkout's first (#436).
|
|
1071
|
+
*
|
|
1072
|
+
* **Ranges, not block indices.** The index is the registry's own key and is the one form of this fact
|
|
1073
|
+
* nobody can act on: a registry written before `BLOCK_SIZE` went to 20 holds entries of both widths, so
|
|
1074
|
+
* block 2 and block 4 do not mean what their numbers imply, and the port a developer is actually looking
|
|
1075
|
+
* at — the one `pithy dev` refused to bind — appears in neither. Printing the range is what makes a
|
|
1076
|
+
* mixed-width registry legible, and it is the form the question arrives in.
|
|
1077
|
+
*
|
|
1078
|
+
* Own rows are unqualified because there is nothing to disambiguate; every other row names the checkout
|
|
1079
|
+
* that holds it, which is the whole answer to "8847, and I don't know why". `← not on disk` trails the
|
|
1080
|
+
* one row a developer can do something about — see {@link PortsRegistryEntry.onDisk}.
|
|
1081
|
+
*/
|
|
1082
|
+
function portsRegistryRows(check: PortsRegistryCheck, home: string): string[] {
|
|
1083
|
+
const range = (entry: PortsRegistryEntry): string => `${entry.base}–${entry.base + entry.size - 1}`;
|
|
1084
|
+
const width = Math.max(0, ...check.entries.map((entry) => range(entry).length));
|
|
1085
|
+
return check.entries.map((entry) => {
|
|
1086
|
+
const held = entry.own ? entry.branch : `${tildify(entry.root, home)} — ${entry.branch}`;
|
|
1087
|
+
return `${range(entry).padEnd(width)} ${held}${entry.onDisk ? "" : " ← not on disk"}`;
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/**
|
|
1092
|
+
* The `Alias:` line, in all three states.
|
|
1093
|
+
*
|
|
1094
|
+
* The third one **names the file and nothing else about it** (#210). The reason `readRcFile` raised is
|
|
1095
|
+
* carried in `--json`, where a script can read it; here the file is the whole actionable fact, and it is
|
|
1096
|
+
* tilde-abbreviated like every other path in this report. What the line must never do is offer
|
|
1097
|
+
* `pithy alias` — that command reads the same file and fails on it — so it names the order the two things
|
|
1098
|
+
* have to happen in.
|
|
1099
|
+
*/
|
|
1100
|
+
function aliasLine(alias: AliasStatus, terse: boolean, home: string): string {
|
|
1101
|
+
switch (alias.state) {
|
|
1102
|
+
case "installed":
|
|
1103
|
+
return terse ? "Alias: installed" : "Alias: installed (`p.` → `pithy`)";
|
|
1104
|
+
case "not-installed":
|
|
1105
|
+
return terse ? "Alias: not installed" : "Alias: not installed (run `pithy alias`)";
|
|
1106
|
+
case "unknown":
|
|
1107
|
+
return `Alias: unknown — can't read ${tildify(alias.rcPath ?? "your shell config", home)}. Fix that first; \`pithy alias\` reads the same file.`;
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
/**
|
|
1112
|
+
* Health lines nest under their Worker's name: a four-space indent plus a 13-wide label column, so a check's
|
|
1113
|
+
* content aligns at column 17 and its continuation lines sit flush beneath (docs/CLI.md §5.6).
|
|
1114
|
+
*/
|
|
1115
|
+
const HEALTH_LABEL = 13;
|
|
1116
|
+
const HEALTH_INDENT = " ".repeat(4);
|
|
1117
|
+
const HEALTH_CONT = " ".repeat(4 + HEALTH_LABEL);
|
|
1118
|
+
function healthLine(label: string, content: string): string {
|
|
1119
|
+
return `${HEALTH_INDENT}${label.padEnd(HEALTH_LABEL)}${content}`;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
/**
|
|
1123
|
+
* The `migrations` check's lines, in every state its ledger can be in (#371).
|
|
1124
|
+
*
|
|
1125
|
+
* **A database that could not be read gets its own sentence, and it is not "0 pending".** That was the
|
|
1126
|
+
* fault: an unreachable D1 contributed nothing to the sum, so the line said the schema was level with the
|
|
1127
|
+
* project when nothing had compared them. The unread databases are named — the only actionable fact — and
|
|
1128
|
+
* nothing derived from what the read threw appears, because a D1 failure's own words name an id or a query.
|
|
1129
|
+
*/
|
|
1130
|
+
function migrationLines(health: MigrationHealth): string[] {
|
|
1131
|
+
const lines: string[] = [];
|
|
1132
|
+
const ledger = health.ledger;
|
|
1133
|
+
if (ledger.state === "unavailable") {
|
|
1134
|
+
lines.push(healthLine("migrations", "couldn't be checked — no database in scope answered"));
|
|
1135
|
+
lines.push(`${HEALTH_CONT}The schema may be behind or ahead; this run established neither.`);
|
|
1136
|
+
return lines;
|
|
1137
|
+
}
|
|
1138
|
+
const counted = ledger.state === "read" ? ledger : ledger.counted;
|
|
1139
|
+
if (counted.pending > 0) {
|
|
1140
|
+
lines.push(healthLine("migrations", `${counted.pending} pending — run: pithy migrate --env ${health.env}`));
|
|
1141
|
+
}
|
|
1142
|
+
// The other direction, and the one nothing reported until #282. It is not "N pending" with a
|
|
1143
|
+
// different number: nothing is pending, migrate refuses outright, and the remedy is neither `pithy
|
|
1144
|
+
// migrate` nor `pithy upgrade`. So it gets its own sentence, written once in `migrations/ledger.ts`
|
|
1145
|
+
// and printed here exactly as `pithy migrate` refuses with it — two commands, one wording.
|
|
1146
|
+
if (counted.undeclared.length > 0) {
|
|
1147
|
+
lines.push(healthLine(lines.length === 0 ? "migrations" : "", describeUndeclared(counted.undeclared)));
|
|
1148
|
+
lines.push(`${HEALTH_CONT}${undeclaredRemedy(health.env)}`);
|
|
1149
|
+
}
|
|
1150
|
+
if (ledger.state === "partial") {
|
|
1151
|
+
const named = ledger.unreadable.map((entry) => `${entry.binding} (${entry.database})`).join(", ");
|
|
1152
|
+
lines.push(healthLine(lines.length === 0 ? "migrations" : "", `couldn't read ${named}`));
|
|
1153
|
+
lines.push(`${HEALTH_CONT}Every number above counts the databases that answered, and not those.`);
|
|
1154
|
+
}
|
|
1155
|
+
return lines;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/** One Worker's five check lines. Every check is shown, so a passing one still reads as checked. */
|
|
1159
|
+
/**
|
|
1160
|
+
* The `bindings` lines for a Worker's declined optional bindings.
|
|
1161
|
+
*
|
|
1162
|
+
* Four states, four sentences, in the house shape: state, then the cause in a comma clause, then the
|
|
1163
|
+
* consequence. Each honored decline gets a continuation line rather than a longer first line, because
|
|
1164
|
+
* the reason is the adopter's own text and a fixed-width report cannot budget for it.
|
|
1165
|
+
*
|
|
1166
|
+
* The two refusals name what to do; the stale one names both ways out, because either is correct and
|
|
1167
|
+
* only the adopter knows which they meant.
|
|
1168
|
+
*/
|
|
1169
|
+
/** Whether any Worker in the project has something to say about a declined binding. */
|
|
1170
|
+
function projectHasDeclines(health: ProjectHealth): boolean {
|
|
1171
|
+
return health.workers.some((worker) => worker.state !== "unavailable" && hasDeclines(worker));
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
/** Whether a Worker's checks carry anything to say about a declined binding. */
|
|
1175
|
+
function hasDeclines(worker: WorkerChecks): boolean {
|
|
1176
|
+
const declines = worker.bindings.declinedBindings;
|
|
1177
|
+
return declines.state === "invalid" || declines.declines.length > 0;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
function declineLines(declines: BindingDeclines): string[] {
|
|
1181
|
+
if (declines.state === "invalid") {
|
|
1182
|
+
return ["`declinedBindings` in pithy.config.ts cannot be read", `${HEALTH_CONT}${declines.problem}`];
|
|
1183
|
+
}
|
|
1184
|
+
return declines.declines.flatMap((decline) => {
|
|
1185
|
+
switch (decline.state) {
|
|
1186
|
+
case "honored":
|
|
1187
|
+
return [
|
|
1188
|
+
`${decline.name} (${decline.type}) declined in pithy.config.ts — ${decline.reason}`,
|
|
1189
|
+
`${HEALTH_CONT}${decline.capability} takes its optional path.${
|
|
1190
|
+
decline.stillPresentIn.length === 0
|
|
1191
|
+
? ""
|
|
1192
|
+
: ` Still in wrangler.jsonc for ${decline.stillPresentIn.join(", ")}.`
|
|
1193
|
+
}`,
|
|
1194
|
+
];
|
|
1195
|
+
case "required":
|
|
1196
|
+
return [
|
|
1197
|
+
`${decline.name} (${decline.type}) declined in pithy.config.ts, and ${decline.capability} requires it`,
|
|
1198
|
+
`${HEALTH_CONT}A required binding is never left out. Remove the line.`,
|
|
1199
|
+
];
|
|
1200
|
+
case "undeclinable":
|
|
1201
|
+
// The second line has to fit the kind. It said "run provision" for a Durable Object, which is
|
|
1202
|
+
// wrong on both halves: no capability exposes a DO provision command, and the reason a Durable
|
|
1203
|
+
// Object is refused is the write-once class migration tag, not provisioning.
|
|
1204
|
+
return [
|
|
1205
|
+
`${decline.name} (${decline.type}) declined in pithy.config.ts, and this kind cannot be declined`,
|
|
1206
|
+
`${HEALTH_CONT}${undeclinableReason(decline.type)} ${
|
|
1207
|
+
decline.type === "durable_object"
|
|
1208
|
+
? "Remove the line."
|
|
1209
|
+
: `Run \`pithy ${decline.capability} provision\`, or remove the line.`
|
|
1210
|
+
}`,
|
|
1211
|
+
];
|
|
1212
|
+
case "unrecognized":
|
|
1213
|
+
return [
|
|
1214
|
+
`${decline.name} declined in pithy.config.ts, and nothing here declares it`,
|
|
1215
|
+
`${HEALTH_CONT}Nothing is being left out for it. Delete the line, or fix the name.`,
|
|
1216
|
+
];
|
|
1217
|
+
}
|
|
1218
|
+
// Unreachable: the switch covers `BindingDecline` exhaustively. `satisfies never` is what keeps it
|
|
1219
|
+
// that way — a fifth decline state stops compiling here rather than silently printing nothing about
|
|
1220
|
+
// itself, which for a report whose whole purpose is not being silent would be the worst failure it
|
|
1221
|
+
// could have.
|
|
1222
|
+
decline satisfies never;
|
|
1223
|
+
return [];
|
|
1224
|
+
});
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
function workerHealthLines(health: WorkerChecks): string[] {
|
|
1228
|
+
const lines: string[] = [];
|
|
1229
|
+
|
|
1230
|
+
// First, and above the rest, because it is the only one that means the Worker does not start. Binding
|
|
1231
|
+
// drift and a pending migration are things a running Worker has; a missing prerequisite is a
|
|
1232
|
+
// `createBackend` refusal at assembly, so every other line below it describes a Worker that is down.
|
|
1233
|
+
if (health.prerequisites.ok) {
|
|
1234
|
+
lines.push(healthLine("prereqs", "every composed capability has its peers ✓"));
|
|
1235
|
+
} else {
|
|
1236
|
+
health.prerequisites.missing.forEach((entry, index) => {
|
|
1237
|
+
lines.push(
|
|
1238
|
+
healthLine(
|
|
1239
|
+
index === 0 ? "prereqs" : "",
|
|
1240
|
+
`${entry.capability} requires ${entry.requires} — run: pithy add ${entry.requires}`,
|
|
1241
|
+
),
|
|
1242
|
+
);
|
|
1243
|
+
});
|
|
1244
|
+
lines.push(`${HEALTH_CONT}This worker will not boot until they are composed.`);
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
if (health.config.ok) {
|
|
1248
|
+
lines.push(healthLine("config", "parses against every capability schema ✓"));
|
|
1249
|
+
} else {
|
|
1250
|
+
const [first, ...rest] = health.config.drift;
|
|
1251
|
+
lines.push(healthLine("config", `options missing from pithy.config.ts — run \`pithy upgrade\``));
|
|
1252
|
+
if (first) lines.push(`${HEALTH_CONT}${first.capability}: ${first.keys.join(", ")}`);
|
|
1253
|
+
for (const cap of rest) lines.push(`${HEALTH_CONT}${cap.capability}: ${cap.keys.join(", ")}`);
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
// **One accumulator, labeled by whether anything has been written yet.** Three lists share the
|
|
1257
|
+
// `bindings` label now, and the `index === 0 && previousList.length === 0` arithmetic that carried
|
|
1258
|
+
// two of them does not extend to a third — each new list would have to know the length of every list
|
|
1259
|
+
// before it. `bindingLine` asks the only question the label actually turns on.
|
|
1260
|
+
const bindingLines: string[] = [];
|
|
1261
|
+
const bindingLine = (text: string) =>
|
|
1262
|
+
bindingLines.push(healthLine(bindingLines.length === 0 ? "bindings" : "", text));
|
|
1263
|
+
|
|
1264
|
+
if (health.bindings.ok) {
|
|
1265
|
+
bindingLine("all required bindings present ✓");
|
|
1266
|
+
} else {
|
|
1267
|
+
for (const binding of health.bindings.missing) {
|
|
1268
|
+
bindingLine(`${binding.name} (${binding.type}) missing from wrangler.jsonc`);
|
|
1269
|
+
bindingLines.push(`${HEALTH_CONT}env: ${binding.envs.join(", ")}`);
|
|
1270
|
+
}
|
|
1271
|
+
// The other half of a Durable Object binding, and the half that lives in the adopter's code.
|
|
1272
|
+
// wrangler resolves `class_name` against the module `main` names, so a class missing there is a
|
|
1273
|
+
// deploy this project cannot make — reported under `bindings` because it is one binding written in
|
|
1274
|
+
// two files (#428).
|
|
1275
|
+
for (const className of health.bindings.missingExports) {
|
|
1276
|
+
bindingLine(`${className} not exported from this worker's entry — run \`pithy upgrade\``);
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
// Declines print whether or not the checks above passed, and that is the whole point: a binding an
|
|
1280
|
+
// adopter deliberately left out is invisible on a green report otherwise, and the next person cannot
|
|
1281
|
+
// tell "chosen" from "never heard of it" (#440).
|
|
1282
|
+
for (const line of declineLines(health.bindings.declinedBindings)) {
|
|
1283
|
+
if (line.startsWith(HEALTH_CONT)) bindingLines.push(line);
|
|
1284
|
+
else bindingLine(line);
|
|
1285
|
+
}
|
|
1286
|
+
lines.push(...bindingLines);
|
|
1287
|
+
|
|
1288
|
+
if (health.migrations.ok) {
|
|
1289
|
+
lines.push(healthLine("migrations", "none pending, none undeclared ✓"));
|
|
1290
|
+
} else {
|
|
1291
|
+
lines.push(...migrationLines(health.migrations));
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
if (health.entitlements.ok) {
|
|
1295
|
+
lines.push(healthLine("entitlements", "no gated route without a provider ✓"));
|
|
1296
|
+
} else if (health.entitlements.gap.state === "unavailable") {
|
|
1297
|
+
// Not "no gated route" — nothing was read, so nothing is known. #371's rule, said out loud.
|
|
1298
|
+
lines.push(healthLine("entitlements", "couldn't be checked — this worker's source would not scan"));
|
|
1299
|
+
} else {
|
|
1300
|
+
// Report-only: `pithy upgrade` cannot pick a capability for the adopter, so the line names the fix.
|
|
1301
|
+
lines.push(healthLine("entitlements", "gated routes, no provider — run: pithy add payments"));
|
|
1302
|
+
for (const gate of health.entitlements.gap.gates) lines.push(`${HEALTH_CONT}${gate}`);
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
return lines;
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
/**
|
|
1309
|
+
* The `Project health` lines — shown only when some Worker is failing a check, grouped one block per Worker.
|
|
1310
|
+
* A healthy Worker collapses to a single line: it was checked, and there is nothing to say about it.
|
|
1311
|
+
*/
|
|
1312
|
+
function healthBlock(health: ProjectHealth): string {
|
|
1313
|
+
const lines = ["Project health:"];
|
|
1314
|
+
// First, and above the Workers, because it explains a hole in every one of their blocks: a capability
|
|
1315
|
+
// whose manifest could not be read contributes no drift to any check below it, so a project full of
|
|
1316
|
+
// them read as healthy and said nothing at all (#184). No Worker owns this — manifests resolve once,
|
|
1317
|
+
// from the project root — so it sits at the block's top rather than inside a Worker's section.
|
|
1318
|
+
if (!health.manifests.ok) {
|
|
1319
|
+
lines.push(" manifests:");
|
|
1320
|
+
for (const fault of health.manifests.faults) {
|
|
1321
|
+
// Not `healthLine`: a package name is longer than the 13-column label the per-Worker checks use, so
|
|
1322
|
+
// it takes the line and its reason indents beneath it.
|
|
1323
|
+
lines.push(
|
|
1324
|
+
`${HEALTH_INDENT}${fault.package}: malformed pithy.manifest.json — reinstall it, or tell its maintainer`,
|
|
1325
|
+
);
|
|
1326
|
+
for (const line of fault.reason.split("\n")) lines.push(`${HEALTH_INDENT} ${line}`);
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
for (const worker of health.workers) {
|
|
1330
|
+
// Not "healthy", and not five empty checks either. Nothing was read about this Worker, so the block
|
|
1331
|
+
// says exactly that and names the two files a plan is built from (#371).
|
|
1332
|
+
if (worker.state === "unavailable") {
|
|
1333
|
+
lines.push(` ${worker.worker}: couldn't be checked`);
|
|
1334
|
+
lines.push(`${HEALTH_INDENT}Its pithy.config.ts or wrangler.jsonc would not read. Nothing below is about it.`);
|
|
1335
|
+
continue;
|
|
1336
|
+
}
|
|
1337
|
+
// A Worker with a decline is never collapsed to one line, even when every check passes. The
|
|
1338
|
+
// decline is the fact this Worker's operator most needs to see and the one nothing else records —
|
|
1339
|
+
// a `healthy ✓` here is how a deliberate absence becomes indistinguishable from a forgotten one,
|
|
1340
|
+
// which is the collapse #440 exists to remove.
|
|
1341
|
+
if (worker.ok && !hasDeclines(worker)) {
|
|
1342
|
+
lines.push(` ${worker.worker}: healthy ✓`);
|
|
1343
|
+
continue;
|
|
1344
|
+
}
|
|
1345
|
+
lines.push(` ${worker.worker}:`);
|
|
1346
|
+
lines.push(...workerHealthLines(worker));
|
|
1347
|
+
}
|
|
1348
|
+
return lines.join("\n");
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
/**
|
|
1352
|
+
* The `Worker names` lines — shown only when a stamp contradicts its directory, grouped one block per
|
|
1353
|
+
* Worker, on the health block's shape. A Worker whose names agree says nothing at all: there is no
|
|
1354
|
+
* "names fine ✓" line, because unlike a health check this has no per-Worker section to sit in.
|
|
1355
|
+
*/
|
|
1356
|
+
/**
|
|
1357
|
+
* The `Environments` lines — shown only when a Worker's stanzas and the declaration disagree, grouped one
|
|
1358
|
+
* block per Worker, on the health block's shape. A project whose Workers all agree says nothing at all.
|
|
1359
|
+
*
|
|
1360
|
+
* The declared set leads, because the whole finding is a comparison and a report that names only one side
|
|
1361
|
+
* of it makes the reader open the config to learn what it compared against.
|
|
1362
|
+
*/
|
|
1363
|
+
function environmentsBlock(check: EnvironmentsCheck): string {
|
|
1364
|
+
const lines = [`Environments: ${check.declared.join(", ")}`];
|
|
1365
|
+
for (const worker of [...new Set(check.drift.map((drift) => drift.worker))]) {
|
|
1366
|
+
lines.push(` ${worker}:`);
|
|
1367
|
+
for (const drift of check.drift.filter((entry) => entry.worker === worker)) {
|
|
1368
|
+
lines.push(healthLine(`env.${drift.env}`, describeEnvironmentDrift(drift, check.declared)));
|
|
1369
|
+
if (drift.resources.length > 0) lines.push(`${HEALTH_CONT}${drift.resources.join(", ")}`);
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
// No command is offered, and that is the point: `pithy` cannot rename a Cloudflare resource, so applying
|
|
1373
|
+
// a changed declaration is exactly the thing that must never happen quietly. The two edits are named.
|
|
1374
|
+
lines.push(`${HEALTH_INDENT}Make environments in pithy.config.ts and each Worker's env.<name> stanzas agree.`);
|
|
1375
|
+
return lines.join("\n");
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
/**
|
|
1379
|
+
* The `Origins` lines — shown only when an environment serves an origin its config does not name, grouped
|
|
1380
|
+
* one block per Worker, on the health block's shape (#253).
|
|
1381
|
+
*
|
|
1382
|
+
* The block offers no command of its own, on the same rule the environments block above states: which
|
|
1383
|
+
* hostname an environment answers on is the adopter's to declare, and whether the `workers.dev` subdomain
|
|
1384
|
+
* should stay open is a decision rather than a default. Each drift's own sentence names the edit and the
|
|
1385
|
+
* file it goes in — and exactly one of the three names a command, because exactly one has one: a route
|
|
1386
|
+
* missing from a `domains` declaration is derived, so `pithy worker sync` writes it (#264).
|
|
1387
|
+
*/
|
|
1388
|
+
function originsBlock(check: OriginsCheck): string {
|
|
1389
|
+
const lines = ["Origins:"];
|
|
1390
|
+
for (const worker of [...new Set(check.drift.map((drift) => drift.worker))]) {
|
|
1391
|
+
lines.push(` ${worker}:`);
|
|
1392
|
+
for (const drift of check.drift.filter((entry) => entry.worker === worker)) {
|
|
1393
|
+
lines.push(healthLine(`env.${drift.env}`, describeOriginDrift(drift)));
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
return lines.join("\n");
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
/**
|
|
1400
|
+
* The `Workflows:` lines — shown only when an environment's stanza does not bind what its Worker's app
|
|
1401
|
+
* capability declares (#267), grouped one block per Worker, on the same shape as the block above.
|
|
1402
|
+
*
|
|
1403
|
+
* One command answers every `unsynced-stanza` line in it, and each drift's own sentence names it — the
|
|
1404
|
+
* block adds no trailing remedy of its own, because the other fault has a different one: a declaration
|
|
1405
|
+
* that cannot be reduced to a stanza is fixed in `pithy.config.ts`, and no command can write it.
|
|
1406
|
+
*/
|
|
1407
|
+
function workflowsBlock(check: WorkflowsCheck): string {
|
|
1408
|
+
const lines = ["Workflows:"];
|
|
1409
|
+
for (const worker of [...new Set(check.drift.map((drift) => drift.worker))]) {
|
|
1410
|
+
lines.push(` ${worker}:`);
|
|
1411
|
+
for (const drift of check.drift.filter((entry) => entry.worker === worker)) {
|
|
1412
|
+
lines.push(healthLine(`env.${drift.env}`, describeWorkflowDrift(drift)));
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
return lines.join("\n");
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
/**
|
|
1419
|
+
* The `Capability extensions:` lines — what an adopter plugged into a capability, grouped per Worker.
|
|
1420
|
+
*
|
|
1421
|
+
* The only block in this report that is not a finding, and shown whenever there is anything to show
|
|
1422
|
+
* rather than only when something is wrong. That is deliberate: an extension is a deliberate act, so
|
|
1423
|
+
* there is no fault to report — and it is also the only place a composed Better Auth plugin has a name
|
|
1424
|
+
* outside the source of `pithy.config.ts`, which is what makes its absence from a report a problem and
|
|
1425
|
+
* its presence not one.
|
|
1426
|
+
*/
|
|
1427
|
+
function extensionsBlock(check: ExtensionsCheck): string {
|
|
1428
|
+
const lines = ["Capability extensions:"];
|
|
1429
|
+
for (const worker of [...new Set(check.extensions.map((entry) => entry.worker))]) {
|
|
1430
|
+
lines.push(` ${worker}:`);
|
|
1431
|
+
for (const entry of check.extensions.filter((candidate) => candidate.worker === worker)) {
|
|
1432
|
+
lines.push(` ${describeExtension(entry)}`);
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
return lines.join("\n");
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
/**
|
|
1439
|
+
* The `Secret bindings:` lines — shown only when a declared environment does not bind a
|
|
1440
|
+
* `cf-secrets-store` secret its Worker reads (#238).
|
|
1441
|
+
*
|
|
1442
|
+
* Its own block rather than a line inside `Dev secrets:`, because it is about the opposite half of the
|
|
1443
|
+
* project: that block is the machine-local file, this is the stanza a *deployed* Worker boots against.
|
|
1444
|
+
* A single command answers every line in it, which is why the lines group per Worker-and-environment.
|
|
1445
|
+
*/
|
|
1446
|
+
/**
|
|
1447
|
+
* The `Settings:` lines — whether each composed capability's settings work, grouped per Worker on the
|
|
1448
|
+
* health block's shape (#411).
|
|
1449
|
+
*
|
|
1450
|
+
* **It prints on a healthy project too, and in the terse form.** Every other finding block here is the
|
|
1451
|
+
* finding, so silence means nothing is wrong; this one has a third answer that silence cannot carry. An
|
|
1452
|
+
* account tier nobody could reach established nothing, and a report that said nothing about it would be
|
|
1453
|
+
* read as a pass — which is the one thing this check must never be. So a clean run collapses to one line
|
|
1454
|
+
* that says the checks ran, and any run that skipped the account says so out loud.
|
|
1455
|
+
*
|
|
1456
|
+
* A capability whose own check threw is named with the tier that failed, never folded into the findings:
|
|
1457
|
+
* "could not be run" is not "nothing to fix", and the exit gate reads only the findings.
|
|
1458
|
+
*/
|
|
1459
|
+
function settingsBlock(check: SettingsCheck): string {
|
|
1460
|
+
// "The account was never asked" is a pass only when nothing wanted to ask it.
|
|
1461
|
+
const accountSettled = check.account.state === "checked" || check.account.reason === "not-declared";
|
|
1462
|
+
if (check.state === "ok" && accountSettled) return "Settings: every composed capability's settings work ✓";
|
|
1463
|
+
|
|
1464
|
+
const lines = ["Settings:"];
|
|
1465
|
+
const workers = [...new Set([...check.findings, ...check.unchecked].map((entry) => entry.worker))];
|
|
1466
|
+
// Nothing was established about anything, because the probe itself did not run. Said as exactly that:
|
|
1467
|
+
// the account line below would blame Cloudflare for a project that never got as far as asking it.
|
|
1468
|
+
if (workers.length === 0 && check.state === "could-not-check") {
|
|
1469
|
+
lines.push(`${HEALTH_INDENT}the checks could not be run, so nothing here was established`);
|
|
1470
|
+
return lines.join("\n");
|
|
1471
|
+
}
|
|
1472
|
+
for (const worker of workers) {
|
|
1473
|
+
lines.push(` ${worker}:`);
|
|
1474
|
+
for (const finding of check.findings.filter((entry) => entry.worker === worker)) {
|
|
1475
|
+
lines.push(healthLine(finding.capability, settingsProblem(finding)));
|
|
1476
|
+
// The action on its own line beneath the problem, which is the shape every PithyError renders in.
|
|
1477
|
+
lines.push(`${HEALTH_CONT}${finding.action}`);
|
|
1478
|
+
}
|
|
1479
|
+
for (const entry of check.unchecked.filter((candidate) => candidate.worker === worker)) {
|
|
1480
|
+
lines.push(healthLine(entry.capability, `${entry.tier} checks couldn't be run`));
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
if (!accountSettled) lines.push(`${HEALTH_INDENT}${describeSettingsAccount(check.account)}.`);
|
|
1484
|
+
return lines.join("\n");
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
/**
|
|
1488
|
+
* The `Local delivery:` lines — whether a message sent from this machine leaves it (#410).
|
|
1489
|
+
*
|
|
1490
|
+
* Every line is {@link deliveryPreflight}'s own, verbatim, because `pithy dev` decides with the same
|
|
1491
|
+
* call and the two must not word one verdict twice. Doctor adds the heading and nothing else.
|
|
1492
|
+
*
|
|
1493
|
+
* It prints in the terse report as well, and never fails the exit. The simulator is a legitimate
|
|
1494
|
+
* choice rather than a fault — but a report that said nothing would be read as "of course it sends",
|
|
1495
|
+
* which is the assumption that had people waiting on an inbox no local process could have posted to.
|
|
1496
|
+
*/
|
|
1497
|
+
function localDeliveryBlock(check: LocalDeliveryCheck): string {
|
|
1498
|
+
return ["Local delivery:", ...check.lines.map((line) => ` ${line}`)].join("\n");
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
/** One finding's problem line: what is wrong, where, and why — the action follows it. */
|
|
1502
|
+
function settingsProblem(finding: SettingsFindingEntry): string {
|
|
1503
|
+
const where = finding.environment === null ? "" : ` (${finding.environment})`;
|
|
1504
|
+
return `${finding.setting}${where} — ${finding.problem}`;
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
function secretBindingsBlock(check: SecretBindingsCheck): string {
|
|
1508
|
+
return ["Secret bindings:", ...describeSecretBindings(check).map((line) => ` ${line}`)].join("\n");
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
function workerNamesBlock(check: WorkerNameCheck): string {
|
|
1512
|
+
const lines = ["Worker names:"];
|
|
1513
|
+
const workers = [...new Set(check.mismatches.map((mismatch) => mismatch.worker))];
|
|
1514
|
+
for (const worker of workers) {
|
|
1515
|
+
lines.push(` ${worker}:`);
|
|
1516
|
+
for (const mismatch of check.mismatches.filter((entry) => entry.worker === worker)) {
|
|
1517
|
+
lines.push(healthLine(mismatch.stamp, describeWorkerName(mismatch)));
|
|
1518
|
+
if (mismatch.envs.length > 0) lines.push(`${HEALTH_CONT}env: ${mismatch.envs.join(", ")}`);
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
// No command is offered to fix this one, because none of them can: the directory has already moved, and
|
|
1522
|
+
// `pithy worker rename` refuses a destination that exists. The fix is the two edits named above. The
|
|
1523
|
+
// command is named anyway, for the next rename — it moves all three at once and this block stays empty.
|
|
1524
|
+
lines.push(`${HEALTH_INDENT}Make wrangler.jsonc agree with the directory. Next time: pithy worker rename.`);
|
|
1525
|
+
return lines.join("\n");
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
/**
|
|
1529
|
+
* The `Project capabilities` lines — collapsed to one line when everything is current.
|
|
1530
|
+
*
|
|
1531
|
+
* The unchecked line says *why* it is unchecked. `unknown` means the registry did not answer on an
|
|
1532
|
+
* ordinary run and that nobody asked it on an offline one, and blaming a network that was never touched is
|
|
1533
|
+
* the sort of wrong a diagnostic is judged on.
|
|
1534
|
+
*/
|
|
1535
|
+
function capabilitiesBlock(capabilities: CapabilityStatus[], offline: boolean): string {
|
|
1536
|
+
if (capabilities.length === 0 || capabilities.every((cap) => cap.state === "current")) {
|
|
1537
|
+
return "Project capabilities: all up to date";
|
|
1538
|
+
}
|
|
1539
|
+
// Nothing is behind, but nothing was confirmed either — say which, rather than implying currency.
|
|
1540
|
+
if (capabilities.every((cap) => cap.state !== "outdated")) {
|
|
1541
|
+
return offline
|
|
1542
|
+
? "Project capabilities: version check skipped (offline)"
|
|
1543
|
+
: "Project capabilities: version check unavailable (registry unreachable)";
|
|
1544
|
+
}
|
|
1545
|
+
const width = Math.max(...capabilities.map((cap) => cap.name.length));
|
|
1546
|
+
const rows = capabilities.map((cap) => {
|
|
1547
|
+
const suffix =
|
|
1548
|
+
cap.state === "current"
|
|
1549
|
+
? " ✓"
|
|
1550
|
+
: cap.state === "unknown"
|
|
1551
|
+
? " (not checked)"
|
|
1552
|
+
: ` (${cap.latest} available — run \`pithy upgrade\`)`;
|
|
1553
|
+
return ` ${cap.name.padEnd(width)} ${cap.installed}${suffix}`;
|
|
1554
|
+
});
|
|
1555
|
+
return ["Project capabilities:", ...rows].join("\n");
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
/** Render the report as the aligned, blocked text of docs/CLI.md §5.6. Verbose vs. terse driven by overall health. */
|
|
1559
|
+
export function renderDoctorText(report: DoctorReport, home = process.env.HOME ?? ""): string {
|
|
1560
|
+
const capsUpToDate = !report.project || report.project.capabilities.every((cap) => cap.state === "current");
|
|
1561
|
+
const healthOk = !report.project || report.project.health.ok;
|
|
1562
|
+
const cloudflareOk = report.cloudflare.state === "ok";
|
|
1563
|
+
// A name that was never asked about is not a name that failed. Someone running `doctor` outside a project
|
|
1564
|
+
// is asking about their toolchain, and dragging the whole report verbose to explain a project they do not
|
|
1565
|
+
// have would answer a question they did not put. `unconfigured` still forces verbose — there the file is
|
|
1566
|
+
// real and a key is missing from it, which is worth the ink.
|
|
1567
|
+
const projectNameOk = report.projectName === null || report.projectName.state === "ok";
|
|
1568
|
+
// `could-not-check` keeps its silence here rather than forcing verbose: unlike an unreadable name, an
|
|
1569
|
+
// unreadable `wrangler.jsonc` is already the health block's line to say, and it says it louder.
|
|
1570
|
+
const workerNamesOk = !report.workerNames || report.workerNames.mismatches.length === 0;
|
|
1571
|
+
// Same silence for `could-not-check` and the same reason: an unreadable config is the `Project:` block's
|
|
1572
|
+
// line, and a second block repeating it is how a report starts contradicting itself.
|
|
1573
|
+
const environmentsOk = !report.environments || report.environments.drift.length === 0;
|
|
1574
|
+
// Both origin faults keep the report verbose, even though only one of them fails the exit. An
|
|
1575
|
+
// environment with no origin is the thing `pithy deploy` will refuse, and a report that stayed silent
|
|
1576
|
+
// about it would send the adopter to that refusal with no warning — which is exactly what #253 asked
|
|
1577
|
+
// doctor to stop doing. Worth the ink, not worth a red CI.
|
|
1578
|
+
const originsOk = !report.origins || report.origins.drift.length === 0;
|
|
1579
|
+
// Worth the ink and worth a red CI both, unlike the origins block above: this is the fault whose whole
|
|
1580
|
+
// symptom is that nothing happens, so a terse report over it would be the toolchain agreeing that
|
|
1581
|
+
// nothing is wrong.
|
|
1582
|
+
const workflowsOk = !report.workflows || report.workflows.drift.length === 0;
|
|
1583
|
+
// A dev login is optional, so having none is a pass — the terse report is for the developer who has
|
|
1584
|
+
// nothing to fix, and "you could have a dev login" is not something to fix. Only the two faults speak up.
|
|
1585
|
+
const devPreferencesState = report.devPreferences?.state;
|
|
1586
|
+
const devPreferencesOk = devPreferencesState !== "unparseable" && devPreferencesState !== "no-user";
|
|
1587
|
+
// A misplaced secret keeps the report verbose without failing the exit: it is worth the ink, and it is
|
|
1588
|
+
// the state every project that predates the dev secrets file starts in. A *missing* one does not — the
|
|
1589
|
+
// four OAuth pairs auth declares are unset in almost every project, and treating that as a fault would
|
|
1590
|
+
// drag every report in the world verbose. `devSecretsHealthy` draws that line; the block still prints.
|
|
1591
|
+
// A probe that could not run keeps the report verbose, on the same rule `Alias: unknown` follows:
|
|
1592
|
+
// "I could not check" is information, and the terse form is the report saying there is nothing to
|
|
1593
|
+
// look at (#371). It still never fails the exit.
|
|
1594
|
+
const devSecretsCheck = reported(report.devSecrets);
|
|
1595
|
+
const devSecretsOk = report.devSecrets === null || (devSecretsCheck !== null && devSecretsHealthy(devSecretsCheck));
|
|
1596
|
+
// A deployed Worker that will answer every request with a missing-binding error is worth the ink for
|
|
1597
|
+
// the same reason, and it does not fail the exit for the same reason either — see
|
|
1598
|
+
// {@link DoctorReport.secretBindings}.
|
|
1599
|
+
const secretBindingsOk = !report.secretBindings || report.secretBindings.missing.length === 0;
|
|
1600
|
+
// A setting that does not work keeps the report verbose, and a check nobody could run does too — the
|
|
1601
|
+
// rule `Alias: unknown` follows. `state` carries both, and it carries the third case the two lists
|
|
1602
|
+
// cannot: a whole probe that threw establishes nothing while listing nothing. A *skipped account tier* deliberately does not: that line prints in
|
|
1603
|
+
// both forms, like `Secrets:` and `Cloudflare:`, so the terse report can stay terse without ever
|
|
1604
|
+
// implying the account was checked.
|
|
1605
|
+
const settingsOk = !report.settings || report.settings.state === "ok";
|
|
1606
|
+
// A Worker that would start with no bindings at all is worth the ink for exactly the same reason a
|
|
1607
|
+
// misplaced secret is, and more so. It does not fail the exit — see {@link DoctorReport.devVars} —
|
|
1608
|
+
// but a report that called this project healthy is what #178 was reported about.
|
|
1609
|
+
const devVarsCheck = reported(report.devVars);
|
|
1610
|
+
const devVarsOk = report.devVars === null || (devVarsCheck !== null && devVarsHealthy(devVarsCheck));
|
|
1611
|
+
// An alias nobody could read keeps the report verbose, on the same rule the `unknown` version state
|
|
1612
|
+
// follows: "I could not check" is information, and the terse form is the report saying there is nothing
|
|
1613
|
+
// to look at. It still never fails the exit — toolchain state does not (#210).
|
|
1614
|
+
const aliasOk = report.alias.state !== "unknown";
|
|
1615
|
+
// An unknown keeps the report verbose on purpose: "I could not check" is information worth surfacing.
|
|
1616
|
+
const terse =
|
|
1617
|
+
report.cli.state === "current" &&
|
|
1618
|
+
capsUpToDate &&
|
|
1619
|
+
healthOk &&
|
|
1620
|
+
cloudflareOk &&
|
|
1621
|
+
projectNameOk &&
|
|
1622
|
+
workerNamesOk &&
|
|
1623
|
+
environmentsOk &&
|
|
1624
|
+
originsOk &&
|
|
1625
|
+
workflowsOk &&
|
|
1626
|
+
devPreferencesOk &&
|
|
1627
|
+
devSecretsOk &&
|
|
1628
|
+
secretBindingsOk &&
|
|
1629
|
+
settingsOk &&
|
|
1630
|
+
devVarsOk &&
|
|
1631
|
+
aliasOk &&
|
|
1632
|
+
!report.projectLoadError;
|
|
1633
|
+
|
|
1634
|
+
const blocks: string[] = [];
|
|
1635
|
+
|
|
1636
|
+
/**
|
|
1637
|
+
* The `Secrets:` line's content — the path, plus whatever {@link describeDevSecretsLocation} has to add.
|
|
1638
|
+
*
|
|
1639
|
+
* Built once and rendered twice, because it is the one line in the report that belongs in **both**
|
|
1640
|
+
* forms. Everything else here reports a fault, and the terse report exists to say nothing when nothing
|
|
1641
|
+
* is wrong; this reports a *location*, and "where is the file" is not a complaint. The file is outside
|
|
1642
|
+
* every checkout since #156 — nothing in the project names it and `ls` will not find it — so a terse
|
|
1643
|
+
* report that omitted it left the adopter with no way to find it at all (#166). `null` outside a
|
|
1644
|
+
* project with a resolvable name, where there is no path to name.
|
|
1645
|
+
*
|
|
1646
|
+
* **It names the command as well as the path**, on the same rule as `Alias: not installed (run `pithy
|
|
1647
|
+
* alias`)`. Knowing where a file is is not the same as having a way to open it: this one is outside the
|
|
1648
|
+
* checkout, so no editor's file tree reaches it and no `ls` in the project finds it. The line was the
|
|
1649
|
+
* one place an adopter learned the path, and the path was all it gave — leaving "resolve it yourself
|
|
1650
|
+
* and open it" as the workflow. `pithy secrets edit` (#157) is that step, and this is the only line in
|
|
1651
|
+
* the toolchain positioned to mention it.
|
|
1652
|
+
*/
|
|
1653
|
+
const secretsFile = reported(report.devSecretsFile);
|
|
1654
|
+
const secretsLocation =
|
|
1655
|
+
report.devSecretsFile === null
|
|
1656
|
+
? null
|
|
1657
|
+
: secretsFile === null
|
|
1658
|
+
? // Not a path, because none was resolved. Saying nothing would be the report implying there is
|
|
1659
|
+
// no file to find, which is the one thing this line exists to prevent (#371).
|
|
1660
|
+
"couldn't be checked"
|
|
1661
|
+
: (() => {
|
|
1662
|
+
const detail = describeDevSecretsLocation(secretsFile);
|
|
1663
|
+
const path = tildify(secretsFile.path, home);
|
|
1664
|
+
return `${path} (run \`pithy secrets edit\`)${detail ? ` — ${detail}` : ""}`;
|
|
1665
|
+
})();
|
|
1666
|
+
|
|
1667
|
+
/**
|
|
1668
|
+
* The `Cloudflare:` line, built once and rendered in **both** forms — the same rule `Secrets:` follows,
|
|
1669
|
+
* and for the same reason.
|
|
1670
|
+
*
|
|
1671
|
+
* It names the credentials file this run resolved, and "which account am I about to deploy to" is not a
|
|
1672
|
+
* complaint: it is a location, so it does not belong behind the not-healthy predicate. A machine holding
|
|
1673
|
+
* `cloudflare.leed.json` beside `cloudflare.other-co.json` cannot answer it any other way, and the run
|
|
1674
|
+
* that most needs the answer — everything green, about to deploy — was the one run that omitted it (#206).
|
|
1675
|
+
*/
|
|
1676
|
+
const cloudflareLine = `Cloudflare: ${describeCloudflareAccess(report.cloudflare, home)}`;
|
|
1677
|
+
|
|
1678
|
+
// CLI version.
|
|
1679
|
+
const cliLines = [`pithy ${report.cli.installed} (installed via ${report.cli.installer})`];
|
|
1680
|
+
if (report.cli.state === "outdated" && report.cli.latest) {
|
|
1681
|
+
cliLines.push(`Update available: ${report.cli.latest}`);
|
|
1682
|
+
cliLines.push(`Run: ${report.cli.upgradeCommand}`);
|
|
1683
|
+
} else if (report.cli.state === "unknown") {
|
|
1684
|
+
// Same distinction the capabilities line draws, and it is drawn here first because this is the line
|
|
1685
|
+
// everybody reads: skipped is a decision somebody made, unreachable is a network that failed.
|
|
1686
|
+
cliLines.push(
|
|
1687
|
+
report.offline ? "Version check skipped (offline)." : "Version check unavailable (registry unreachable).",
|
|
1688
|
+
);
|
|
1689
|
+
} else {
|
|
1690
|
+
cliLines.push("Up to date.");
|
|
1691
|
+
}
|
|
1692
|
+
blocks.push(cliLines.join("\n"));
|
|
1693
|
+
|
|
1694
|
+
// Shell / alias.
|
|
1695
|
+
const shellName = report.shell ? report.shell.kind : "unknown";
|
|
1696
|
+
const shellLine =
|
|
1697
|
+
terse || !report.shell ? `Shell: ${shellName}` : `Shell: ${shellName} (${tildify(report.shell.rcPath, home)})`;
|
|
1698
|
+
blocks.push([shellLine, aliasLine(report.alias, terse, home)].join("\n"));
|
|
1699
|
+
|
|
1700
|
+
// Config / State / Notifier — verbose only.
|
|
1701
|
+
if (!terse) {
|
|
1702
|
+
let notifier: string;
|
|
1703
|
+
if (report.notifierEnabled) {
|
|
1704
|
+
notifier = "enabled (PITHY_NO_UPDATE_NOTIFIER to disable)";
|
|
1705
|
+
} else if (report.notifierDisabledBy === "env") {
|
|
1706
|
+
notifier = "disabled (PITHY_NO_UPDATE_NOTIFIER set)";
|
|
1707
|
+
} else {
|
|
1708
|
+
notifier = "disabled (pithy doctor --enable-notifier to re-enable)";
|
|
1709
|
+
}
|
|
1710
|
+
// The dev-login line belongs in this block and nowhere else. It is the same question the two lines above
|
|
1711
|
+
// it answer — where does the CLI keep this, and what is in it — and it is here because it used not to be
|
|
1712
|
+
// resolvable from them: `dev.json` lived under a second, unrelated config root, so this block named a
|
|
1713
|
+
// directory that did not contain it. One root, one block.
|
|
1714
|
+
const paths = [`Config dir: ${tildify(report.configDir, home)}`, `State file: ${tildify(report.stateFile, home)}`];
|
|
1715
|
+
if (report.devPreferences?.state === "could-not-check") {
|
|
1716
|
+
// No path, because none was resolved — resolving it is what failed. Naming the config directory
|
|
1717
|
+
// here would be a claim about a file this run never located (#371, #131's rule).
|
|
1718
|
+
paths.push(`Dev login: ${describeDevPreferences(report.devPreferences)}`);
|
|
1719
|
+
} else if (report.devPreferences) {
|
|
1720
|
+
const path = tildify(report.devPreferences.path, home);
|
|
1721
|
+
paths.push(`Dev login: ${path} — ${describeDevPreferences(report.devPreferences)}`);
|
|
1722
|
+
}
|
|
1723
|
+
// Beside the other two paths rather than in the findings block below, because it answers the same
|
|
1724
|
+
// question they do. The terse report has no paths block to sit in, so it gets the line on its own,
|
|
1725
|
+
// in this same position — see `secretsLocation` above.
|
|
1726
|
+
if (secretsLocation !== null) paths.push(`Secrets: ${secretsLocation}`);
|
|
1727
|
+
// The same block once more, and machine-wide like `State file:` rather than per project like
|
|
1728
|
+
// `Dev login:`. A registry that is simply there gets its path and no verdict — the path is the line.
|
|
1729
|
+
// The listing under it is the rest of the answer (#436): the path said *where*, and left *why is this
|
|
1730
|
+
// project on 8847* to `cat`. Both halves are in the report people already run rather than behind a
|
|
1731
|
+
// second command, because the defect being fixed is that this file is invisible, and a command nobody
|
|
1732
|
+
// knows exists is not more visible than a line here.
|
|
1733
|
+
if (report.portsRegistry) {
|
|
1734
|
+
const detail = describePortsRegistry(report.portsRegistry);
|
|
1735
|
+
const path = tildify(report.portsRegistry.path, home);
|
|
1736
|
+
paths.push(`Ports: ${path}${detail ? ` — ${detail}` : ""}`);
|
|
1737
|
+
for (const row of portsRegistryRows(report.portsRegistry, home)) paths.push(`${PATHS_INDENT}${row}`);
|
|
1738
|
+
}
|
|
1739
|
+
blocks.push([...paths, `Notifier: ${notifier}`].join("\n"));
|
|
1740
|
+
} else if (secretsLocation !== null) {
|
|
1741
|
+
// Unpadded, because there is nothing here to align it against: the terse report carries this line
|
|
1742
|
+
// and no other path. Same position in the report either way, so the two forms read as one document.
|
|
1743
|
+
blocks.push(`Secrets: ${secretsLocation}`);
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
// Project. Three states across two fields, and all three are said out loud: the config loaded, it is
|
|
1747
|
+
// present but would not load, or there is none here. The third used to print nothing while the
|
|
1748
|
+
// `Project name:` line spoke for it — and got it wrong, advising a key be added to a file that did not
|
|
1749
|
+
// exist. Stated here, once, by the block whose subject it is.
|
|
1750
|
+
if (report.projectLoadError) {
|
|
1751
|
+
blocks.push(["Project: pithy.config.ts found", ` could not load — ${report.projectLoadError}`].join("\n"));
|
|
1752
|
+
} else if (report.project) {
|
|
1753
|
+
blocks.push(
|
|
1754
|
+
["Project: pithy.config.ts found", capabilitiesBlock(report.project.capabilities, report.offline)].join("\n"),
|
|
1755
|
+
);
|
|
1756
|
+
// Or when a Worker declines something. The block is the only place a decline is reported, and a
|
|
1757
|
+
// project whose every check passes is exactly the project a decline is working on — gating the
|
|
1758
|
+
// block on `ok` alone would print the feature's output on every report except the ones it is for.
|
|
1759
|
+
if (!report.project.health.ok || projectHasDeclines(report.project.health)) {
|
|
1760
|
+
blocks.push(healthBlock(report.project.health));
|
|
1761
|
+
}
|
|
1762
|
+
} else {
|
|
1763
|
+
blocks.push("Project: no pithy.config.ts here — run `pithy init`, or change to a project directory");
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
// Cloudflare credentials, on every run. See `cloudflareLine` above for why this one is not gated on
|
|
1767
|
+
// the report having something to complain about.
|
|
1768
|
+
blocks.push(cloudflareLine);
|
|
1769
|
+
|
|
1770
|
+
// The project name, reconciled against what is provisioned. Its own block rather than a second
|
|
1771
|
+
// Cloudflare line: the credentials answer "can I reach the account", this answers "is what I would
|
|
1772
|
+
// find there still mine". Absent when the config could not be read — the `Project:` line above has
|
|
1773
|
+
// already said so, and this line has no name to reconcile.
|
|
1774
|
+
if (!terse && report.projectName) blocks.push(`Project name: ${describeProjectName(report.projectName)}`);
|
|
1775
|
+
|
|
1776
|
+
// The Workers' own names, and only when they disagree. A Worker whose three stamps agree has nothing to
|
|
1777
|
+
// report — the block is the finding, the way `Project health` is.
|
|
1778
|
+
if (report.workerNames && report.workerNames.mismatches.length > 0) {
|
|
1779
|
+
blocks.push(workerNamesBlock(report.workerNames));
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
// The environment declaration, and only when a Worker disagrees with it. The block is the finding.
|
|
1783
|
+
if (report.environments && report.environments.drift.length > 0) {
|
|
1784
|
+
blocks.push(environmentsBlock(report.environments));
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
// Where each declared environment answers, and only when one of them serves an origin nothing named.
|
|
1788
|
+
// Beside the environments block because it is the same argument one level down: an environment is
|
|
1789
|
+
// declared, and now what is true about it must be too.
|
|
1790
|
+
if (report.origins && report.origins.drift.length > 0) blocks.push(originsBlock(report.origins));
|
|
1791
|
+
|
|
1792
|
+
// And what each declared environment runs, when its stanza does not bind what the app declares. Beside
|
|
1793
|
+
// the origins block for the same reason that one sits beside the environments block: the same argument
|
|
1794
|
+
// about the same environment, one declaration further in.
|
|
1795
|
+
if (report.workflows && report.workflows.drift.length > 0) blocks.push(workflowsBlock(report.workflows));
|
|
1796
|
+
|
|
1797
|
+
// And what an adopter plugged into a capability. Beside the blocks above because it is the same
|
|
1798
|
+
// subject — what this project actually composes — and unlike them it prints when there is nothing
|
|
1799
|
+
// wrong, because there is nothing here that can be wrong. Terse runs skip it: it is not a fault, and
|
|
1800
|
+
// `--terse` is the form that reports only faults.
|
|
1801
|
+
if (!terse && report.extensions && report.extensions.extensions.length > 0) {
|
|
1802
|
+
blocks.push(extensionsBlock(report.extensions));
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
// Dev secrets, and only when something is wrong with them. The block is the finding: a project whose
|
|
1806
|
+
// secrets are in the file they belong in needs no line saying so, and every project that predates
|
|
1807
|
+
// the dev secrets file needs one every run until it moves them. Nothing here fails the exit — see
|
|
1808
|
+
// {@link DoctorReport.devSecrets}.
|
|
1809
|
+
if (report.devSecrets || report.devVars || report.devVarsLocal) {
|
|
1810
|
+
// A probe that could not run says so rather than contributing nothing (#371). Silence here is the
|
|
1811
|
+
// same sentence a clean file produces, and this block is read by somebody whose dev environment is
|
|
1812
|
+
// already not working.
|
|
1813
|
+
const unchecked = (label: string, value: Checked<unknown> | null): string[] =>
|
|
1814
|
+
value?.state === "could-not-check" ? [`${label} couldn't be checked.`] : [];
|
|
1815
|
+
const devVars = reported(report.devVars);
|
|
1816
|
+
const devSecrets = reported(report.devSecrets);
|
|
1817
|
+
const devVarsLocal = reported(report.devVarsLocal);
|
|
1818
|
+
const lines = [
|
|
1819
|
+
// First in the block, because it is the loudest thing there is to say about a dev environment:
|
|
1820
|
+
// that Worker answers every request with a missing-binding error, and the lines below it are
|
|
1821
|
+
// usually why. Everything else here is a value in the wrong file; this one is a Worker with none.
|
|
1822
|
+
...(devVars ? describeDevVars(devVars) : unchecked(".dev.vars:", report.devVars)),
|
|
1823
|
+
...(devSecrets ? describeDevSecrets(devSecrets) : unchecked("secrets.jsonc:", report.devSecrets)),
|
|
1824
|
+
// In the same block, because it is the same question asked of the file beside it: what is in a
|
|
1825
|
+
// git-ignored file that nothing else in the project knows about.
|
|
1826
|
+
...(devVarsLocal ? describeDevVarsLocal(devVarsLocal) : unchecked(".dev.vars.local:", report.devVarsLocal)),
|
|
1827
|
+
];
|
|
1828
|
+
if (lines.length > 0) blocks.push(["Dev secrets:", ...lines.map((line) => ` ${line}`)].join("\n"));
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
// The same registry asked about the deployed environments. After `Dev secrets:` because a developer
|
|
1832
|
+
// reads this report about the project in front of them first, and a deploy second.
|
|
1833
|
+
if (report.secretBindings && report.secretBindings.missing.length > 0) {
|
|
1834
|
+
blocks.push(secretBindingsBlock(report.secretBindings));
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
// And whether the values in all of that actually work. Last of the project blocks because it is the
|
|
1838
|
+
// only one that reaches past the checkout — it asks the account the block above it only asks a file
|
|
1839
|
+
// about — and it prints in both forms of the report, for the reason {@link settingsBlock} states.
|
|
1840
|
+
if (report.settings) blocks.push(settingsBlock(report.settings));
|
|
1841
|
+
|
|
1842
|
+
// And whether anything this project sends would leave this machine at all — the question `Settings:`
|
|
1843
|
+
// does not ask, because it is about the machine rather than about a value.
|
|
1844
|
+
if (report.localDelivery) blocks.push(localDeliveryBlock(report.localDelivery));
|
|
1845
|
+
|
|
1846
|
+
// OS / runtime. Named explicitly, because under Bun `report.node` is an emulated compatibility level
|
|
1847
|
+
// rather than the interpreter — reporting it alone would name a runtime that is not running.
|
|
1848
|
+
const runtime =
|
|
1849
|
+
report.runtime.nodeCompat === null
|
|
1850
|
+
? `${report.runtime.name} ${report.runtime.version}`
|
|
1851
|
+
: `${report.runtime.name} ${report.runtime.version} (Node ${report.runtime.nodeCompat} compat)`;
|
|
1852
|
+
blocks.push([`OS: ${report.os.name} ${report.os.version}`, `Runtime: ${runtime}`].join("\n"));
|
|
1853
|
+
|
|
1854
|
+
return `\n${blocks.join("\n\n")}`;
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
/** The `--json` mirror of every block (agents can't read aligned columns). Health failures still drive the exit. */
|
|
1858
|
+
export function renderDoctorJson(report: DoctorReport): Record<string, unknown> {
|
|
1859
|
+
return {
|
|
1860
|
+
cli: report.cli,
|
|
1861
|
+
shell: report.shell?.kind ?? null,
|
|
1862
|
+
// An object rather than the string it used to be, because the answer is tri-state now and a third
|
|
1863
|
+
// string would leave a script no way to reach the file the third state is about (#210). `state` is
|
|
1864
|
+
// `installed`, `not-installed`, or `unknown`; `rcPath` is the file, absolute here like every other
|
|
1865
|
+
// path in this payload; `reason` is the refusal's own sentence, and `null` on the two known states.
|
|
1866
|
+
alias: report.alias,
|
|
1867
|
+
configDir: report.configDir,
|
|
1868
|
+
stateFile: report.stateFile,
|
|
1869
|
+
notifier: report.notifierEnabled ? "enabled" : "disabled",
|
|
1870
|
+
// Whether this run refused ambient credentials and the network (#218). Unconditional like every key
|
|
1871
|
+
// here, and a boolean rather than an absence, because the question a script asks of this payload is
|
|
1872
|
+
// "was that check actually run" — and `false` is as much of an answer as `true`.
|
|
1873
|
+
offline: report.offline,
|
|
1874
|
+
project: report.projectLoadError
|
|
1875
|
+
? { present: true, loadError: report.projectLoadError }
|
|
1876
|
+
: report.project
|
|
1877
|
+
? {
|
|
1878
|
+
present: true,
|
|
1879
|
+
capabilities: report.project.capabilities,
|
|
1880
|
+
health: report.project.health,
|
|
1881
|
+
}
|
|
1882
|
+
: null,
|
|
1883
|
+
cloudflare: {
|
|
1884
|
+
state: report.cloudflare.state,
|
|
1885
|
+
missing: report.cloudflare.missing,
|
|
1886
|
+
tokenStatus: report.cloudflare.tokenStatus,
|
|
1887
|
+
credentialSplit: report.cloudflare.credentialSplit,
|
|
1888
|
+
// The resolved file, absolute here rather than tilde-abbreviated, on the same rule as every other
|
|
1889
|
+
// path in this payload: `--json` is read by agents and scripts, which need a path they can open.
|
|
1890
|
+
configPath: report.cloudflare.configPath ?? null,
|
|
1891
|
+
accountName: report.cloudflare.accountName ?? null,
|
|
1892
|
+
accountMismatch: report.cloudflare.accountMismatch ?? null,
|
|
1893
|
+
// Beside `configPath` because the two are only useful together: the path is the file this run
|
|
1894
|
+
// resolved, and this is whether the credentials actually came out of it (#218).
|
|
1895
|
+
credentialSource: report.cloudflare.credentialSource ?? null,
|
|
1896
|
+
detail: describeCloudflareAccess(report.cloudflare),
|
|
1897
|
+
},
|
|
1898
|
+
// `null` alongside a `null` project: one fact, one shape, both keys agreeing that there is no project
|
|
1899
|
+
// here to name. An agent reading this never sees a name verdict for a directory that has no config.
|
|
1900
|
+
projectName: report.projectName
|
|
1901
|
+
? {
|
|
1902
|
+
state: report.projectName.state,
|
|
1903
|
+
project: report.projectName.project,
|
|
1904
|
+
misnamed: report.projectName.misnamed,
|
|
1905
|
+
detail: describeProjectName(report.projectName),
|
|
1906
|
+
}
|
|
1907
|
+
: null,
|
|
1908
|
+
// Same `null` discipline: no project, no Workers, no verdict. Each mismatch carries its own sentence
|
|
1909
|
+
// so an agent fixing it never has to reproduce the wording from the fields.
|
|
1910
|
+
workerNames: report.workerNames
|
|
1911
|
+
? {
|
|
1912
|
+
state: report.workerNames.state,
|
|
1913
|
+
mismatches: report.workerNames.mismatches.map((mismatch) => ({
|
|
1914
|
+
...mismatch,
|
|
1915
|
+
detail: describeWorkerName(mismatch),
|
|
1916
|
+
})),
|
|
1917
|
+
}
|
|
1918
|
+
: null,
|
|
1919
|
+
// Same `null` discipline once more, and each drift carries its own sentence — the remedy for an
|
|
1920
|
+
// orphan is not the remedy for a disagreement, and a consumer must not have to work out which.
|
|
1921
|
+
environments: report.environments
|
|
1922
|
+
? {
|
|
1923
|
+
state: report.environments.state,
|
|
1924
|
+
declared: report.environments.declared,
|
|
1925
|
+
drift: report.environments.drift.map((drift) => ({
|
|
1926
|
+
...drift,
|
|
1927
|
+
detail: describeEnvironmentDrift(drift, report.environments?.declared ?? []),
|
|
1928
|
+
})),
|
|
1929
|
+
}
|
|
1930
|
+
: null,
|
|
1931
|
+
// Same `null` discipline once more, and each drift carries its own sentence: the remedy for "no
|
|
1932
|
+
// origin at all" is not the remedy for "workers.dev is open beside your domain", and a consumer must
|
|
1933
|
+
// not have to work out which from a fault name.
|
|
1934
|
+
origins: report.origins
|
|
1935
|
+
? {
|
|
1936
|
+
state: report.origins.state,
|
|
1937
|
+
drift: report.origins.drift.map((drift) => ({ ...drift, detail: describeOriginDrift(drift) })),
|
|
1938
|
+
}
|
|
1939
|
+
: null,
|
|
1940
|
+
// Same `null` discipline, and each drift carries both sides of the comparison as well as its own
|
|
1941
|
+
// sentence: a consumer fixing this needs to know what the stanza binds *and* what the declaration
|
|
1942
|
+
// says, and reconstructing either from a fault name is not something a payload should ask for.
|
|
1943
|
+
workflows: report.workflows
|
|
1944
|
+
? {
|
|
1945
|
+
state: report.workflows.state,
|
|
1946
|
+
drift: report.workflows.drift.map((drift) => ({ ...drift, detail: describeWorkflowDrift(drift) })),
|
|
1947
|
+
}
|
|
1948
|
+
: null,
|
|
1949
|
+
// Same `null` discipline, and each entry carries its own sentence so an agent never has to
|
|
1950
|
+
// reproduce the wording from the fields. Never a fault, so nothing here is a `state`.
|
|
1951
|
+
extensions: report.extensions
|
|
1952
|
+
? {
|
|
1953
|
+
extensions: report.extensions.extensions.map((entry) => ({ ...entry, detail: describeExtension(entry) })),
|
|
1954
|
+
}
|
|
1955
|
+
: null,
|
|
1956
|
+
// The path is absolute here, not tilde-abbreviated: `--json` is read by agents and scripts, which need
|
|
1957
|
+
// a path they can open, not one a human recognizes. The same `null` discipline as the two above.
|
|
1958
|
+
devPreferences: report.devPreferences
|
|
1959
|
+
? { ...report.devPreferences, detail: describeDevPreferences(report.devPreferences) }
|
|
1960
|
+
: null,
|
|
1961
|
+
// Same `null` discipline. `detail` is nullable here rather than always a sentence: a registry that is
|
|
1962
|
+
// present with nothing stray beside it has no verdict to give, and inventing one ("ok") would put a
|
|
1963
|
+
// string in front of agents that means nothing they can act on.
|
|
1964
|
+
portsRegistry: report.portsRegistry
|
|
1965
|
+
? { ...report.portsRegistry, detail: describePortsRegistry(report.portsRegistry) }
|
|
1966
|
+
: null,
|
|
1967
|
+
// Same `null` discipline, and each finding carries its own sentence so an agent fixing it never has to
|
|
1968
|
+
// reproduce the wording from the fields. `mode` is octal-formatted here for the same reason: `420` is
|
|
1969
|
+
// not a permission anybody recognizes.
|
|
1970
|
+
devSecretsFile: report.devSecretsFile === null ? null : { ...report.devSecretsFile },
|
|
1971
|
+
devSecrets: jsonDevSecrets(report.devSecrets),
|
|
1972
|
+
// Same `null` discipline once more, and each finding carries its own sentence: one command writes
|
|
1973
|
+
// every one of them, and an agent must not have to reconstruct which from a binding name.
|
|
1974
|
+
secretBindings: report.secretBindings
|
|
1975
|
+
? {
|
|
1976
|
+
state: report.secretBindings.state,
|
|
1977
|
+
missing: report.secretBindings.missing,
|
|
1978
|
+
detail: describeSecretBindings(report.secretBindings),
|
|
1979
|
+
}
|
|
1980
|
+
: null,
|
|
1981
|
+
// Every finding carries its own sentence, and **every skip is a key rather than an absence**: a
|
|
1982
|
+
// script asking "do this project's settings work" has to be able to tell a clean pass from a check
|
|
1983
|
+
// that never ran, and only `account.state` and `unchecked` say which (#411).
|
|
1984
|
+
settings: report.settings
|
|
1985
|
+
? {
|
|
1986
|
+
state: report.settings.state,
|
|
1987
|
+
account: report.settings.account,
|
|
1988
|
+
checked: report.settings.checked,
|
|
1989
|
+
findings: report.settings.findings.map((finding) => ({
|
|
1990
|
+
...finding,
|
|
1991
|
+
detail: describeSettingsFinding(finding),
|
|
1992
|
+
})),
|
|
1993
|
+
unchecked: report.settings.unchecked,
|
|
1994
|
+
detail: describeSettingsAccount(report.settings.account),
|
|
1995
|
+
}
|
|
1996
|
+
: null,
|
|
1997
|
+
// Not a verdict a script gates on — the simulator is a choice — so `live` is the field and the
|
|
1998
|
+
// `detail` is the run's own sentence about why.
|
|
1999
|
+
localDelivery: report.localDelivery
|
|
2000
|
+
? {
|
|
2001
|
+
live: report.localDelivery.live,
|
|
2002
|
+
capability: report.localDelivery.capability,
|
|
2003
|
+
detail: describeLocalDelivery(report.localDelivery),
|
|
2004
|
+
}
|
|
2005
|
+
: null,
|
|
2006
|
+
devVarsLocal: reported(report.devVarsLocal)
|
|
2007
|
+
? { ...report.devVarsLocal, detail: describeDevVarsLocal(report.devVarsLocal as DevVarsLocalCheck) }
|
|
2008
|
+
: report.devVarsLocal,
|
|
2009
|
+
// Names only, never a value — the same discipline as its neighbor. The whole `root` classification
|
|
2010
|
+
// is carried rather than only the findings, because an agent asking "what is in that file and what
|
|
2011
|
+
// reads it" is asking the question the classification *is*, and a filtered list answers half of it.
|
|
2012
|
+
devVars: reported(report.devVars)
|
|
2013
|
+
? { ...report.devVars, detail: describeDevVars(report.devVars as DevVarsCheck) }
|
|
2014
|
+
: report.devVars,
|
|
2015
|
+
os: `${report.os.name} ${report.os.version}`,
|
|
2016
|
+
runtime: report.runtime,
|
|
2017
|
+
node: report.node,
|
|
2018
|
+
};
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
export default defineCommand({
|
|
2022
|
+
meta: { name: "doctor", description: "Check the toolchain, project, and for a new CLI version" },
|
|
2023
|
+
args: {
|
|
2024
|
+
worker: { type: "string", description: "Check only this worker (default: every worker under apps/)" },
|
|
2025
|
+
"disable-notifier": { type: "boolean", default: false, description: "Turn off the update notifier (persisted)" },
|
|
2026
|
+
"enable-notifier": { type: "boolean", default: false, description: "Turn the update notifier back on" },
|
|
2027
|
+
// No `default: false`. An unpassed flag has to stay absent so `PITHY_OFFLINE` can answer, and citty's
|
|
2028
|
+
// default would make every run an explicit "not offline" that overrode the variable.
|
|
2029
|
+
offline: { type: "boolean", description: "Use no ambient credentials and make no network call" },
|
|
2030
|
+
json: { type: "boolean", default: false, description: "Machine-readable output" },
|
|
2031
|
+
},
|
|
2032
|
+
run: ({ args }) =>
|
|
2033
|
+
withErrorReporting(args.json, async () => {
|
|
2034
|
+
if (args["disable-notifier"] && args["enable-notifier"]) {
|
|
2035
|
+
throw new ValidationError({
|
|
2036
|
+
message: "Pass either --disable-notifier or --enable-notifier, not both.",
|
|
2037
|
+
action: "Choose one.",
|
|
2038
|
+
});
|
|
2039
|
+
}
|
|
2040
|
+
const file = stateFilePath();
|
|
2041
|
+
if (args["disable-notifier"]) await setNotifierFlag(file, false);
|
|
2042
|
+
if (args["enable-notifier"]) await setNotifierFlag(file, true);
|
|
2043
|
+
|
|
2044
|
+
const report = await buildDoctorReport({
|
|
2045
|
+
projectDir: process.cwd(),
|
|
2046
|
+
stateFile: file,
|
|
2047
|
+
...(args.worker ? { worker: args.worker } : {}),
|
|
2048
|
+
...(args.offline === undefined ? {} : { offline: args.offline }),
|
|
2049
|
+
});
|
|
2050
|
+
const output = args.json ? formatJsonLine(renderDoctorJson(report)) : renderDoctorText(report);
|
|
2051
|
+
process.stdout.write(`${output}\n`);
|
|
2052
|
+
|
|
2053
|
+
const code = doctorExitCode(report);
|
|
2054
|
+
if (code !== 0) process.exit(code);
|
|
2055
|
+
}),
|
|
2056
|
+
});
|
|
2057
|
+
|
|
2058
|
+
// Re-export the shared plan-builder reference so a test can assert the engine is shared with upgrade.
|
|
2059
|
+
export { buildReconcilePlan };
|