@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,53 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The files git has committed under a directory. One question, asked by the packer and by `pithy init`.
|
|
6
|
+
*
|
|
7
|
+
* It lives in `src/` and nothing else does, because that is the only side of the boundary both programs
|
|
8
|
+
* can reach. `packages/cli/tsconfig.json` roots at `src`, so a module under `src` importing
|
|
9
|
+
* `scripts/templateManifest.ts` is `TS6059` — the file is outside the emit root. The other direction
|
|
10
|
+
* costs nothing: `scripts/tsconfig.json` roots one level higher and picks this file up through the
|
|
11
|
+
* import, and **this module imports nothing but `node:child_process` and `node:path`**, so it drags no
|
|
12
|
+
* graph behind it into a program that has no Workers types and must not acquire any.
|
|
13
|
+
*
|
|
14
|
+
* The alternative was two copies with a test asserting they agree, and a copy is what the last four
|
|
15
|
+
* fixes on this branch were each cleaning up.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { execFileSync } from "node:child_process";
|
|
19
|
+
import { sep } from "node:path";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The paths git has committed under `dir`, relative to it, sorted — or `null` when there is no index to
|
|
23
|
+
* read: `dir` is not in a checkout, or git is not installed, or nothing under it is tracked.
|
|
24
|
+
*
|
|
25
|
+
* **The allowlist, and it inverts the burden.** An exclusion filter has to predict the next artifact
|
|
26
|
+
* somebody drops in the directory, and nobody predicted `.dev.vars` — the file `pithy add` and
|
|
27
|
+
* `pithy token mint` write `CLOUDFLARE_API_TOKEN` and `SECRETS_ENCRYPTION_KEYS` into. It was published
|
|
28
|
+
* straight past `.gitignore` by `files`, and copied straight past it into an adopter's brand-new project
|
|
29
|
+
* by `pithy init`. Reading the index means a file ships because it was committed, reviewed and pushed;
|
|
30
|
+
* anything a working tree happens to hold is invisible.
|
|
31
|
+
*
|
|
32
|
+
* `--cached`, so an untracked or ignored file sitting beside a tracked one is not listed either.
|
|
33
|
+
*
|
|
34
|
+
* `null` rather than a throw, because "no index" is a normal answer to both callers and they answer it
|
|
35
|
+
* differently: a pack must refuse, and a scaffold from an installed package must carry on — the vendored
|
|
36
|
+
* template is a copy of this same allowlist, made when there was an index to read.
|
|
37
|
+
*
|
|
38
|
+
* git's own stderr is discarded. The failure is a value here, not a message: an installed CLI running
|
|
39
|
+
* `pithy init` outside a repository must not print `fatal: not a git repository` at the adopter.
|
|
40
|
+
*/
|
|
41
|
+
export function committedFiles(dir: string): string[] | null {
|
|
42
|
+
let listed: string;
|
|
43
|
+
try {
|
|
44
|
+
listed = execFileSync("git", ["-C", dir, "ls-files", "-z", "--cached"], {
|
|
45
|
+
encoding: "utf8",
|
|
46
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
47
|
+
});
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const files = listed.split("\0").filter(Boolean).sort();
|
|
52
|
+
return files.length === 0 ? null : files.map((path) => path.split("/").join(sep));
|
|
53
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Prove the Worker you just deployed is the one answering at the address this project claims.
|
|
6
|
+
*
|
|
7
|
+
* ## Why not compare the URL wrangler printed
|
|
8
|
+
*
|
|
9
|
+
* That is the obvious move and it is wrong twice over. Wrangler's last printed URL may be a
|
|
10
|
+
* **version-scoped preview URL** under versions and gradual deployments, so comparing it to the declared
|
|
11
|
+
* domain would fire falsely on every deploy. And it depends on an output format nobody controls.
|
|
12
|
+
*
|
|
13
|
+
* ## Why not a liveness probe
|
|
14
|
+
*
|
|
15
|
+
* `GET /health` answering `ok` at the declared domain proves *a* Worker is there — **not the one just
|
|
16
|
+
* deployed**. The old version answering happily would pass, and that is exactly the failure worth
|
|
17
|
+
* catching: a deploy that landed somewhere else (a different account, a different script name) while the
|
|
18
|
+
* declared domain kept serving what was already on it. That failure is silent, and it is the one that
|
|
19
|
+
* costs the most to discover late.
|
|
20
|
+
*
|
|
21
|
+
* ## So the check is a version correlation
|
|
22
|
+
*
|
|
23
|
+
* `parseDeployOutput` already captures the `versionId` wrangler reports. `GET /health` reports the
|
|
24
|
+
* running version from `CF_VERSION_METADATA`. This probes the **declared** domain and asserts the two
|
|
25
|
+
* match. That is an end-to-end assertion — *the Worker I deployed is answering at the address this
|
|
26
|
+
* project claims* — and it behaves identically from CI and a laptop, failing the pipeline rather than
|
|
27
|
+
* printing a line nobody reads.
|
|
28
|
+
*
|
|
29
|
+
* ## Two cases must not produce false failures
|
|
30
|
+
*
|
|
31
|
+
* **Propagation is not instant.** A custom domain can take seconds to route to a new version, so the
|
|
32
|
+
* probe retries with a short backoff before concluding anything.
|
|
33
|
+
*
|
|
34
|
+
* **A gradual deployment is not a failure.** Under one, the previous version is still legitimately
|
|
35
|
+
* serving a share of traffic, so hitting it is expected. The rule that distinguishes the two is
|
|
36
|
+
* *consistency*: if any probe sees the version just shipped, the deploy is verified. If every probe sees
|
|
37
|
+
* one single other version, that is a genuine mismatch. If probes see **more than one** version, the
|
|
38
|
+
* fleet is mixed — a rollout in progress — and the answer is `inconclusive`, said out loud, rather than a
|
|
39
|
+
* failure.
|
|
40
|
+
*
|
|
41
|
+
* ## And nothing answering is not "cannot tell"
|
|
42
|
+
*
|
|
43
|
+
* Those two were one branch until #264, and the conflation cost the whole check. A probe that received no
|
|
44
|
+
* response at all and a probe that received a 200 with no `version` field both landed in `inconclusive`,
|
|
45
|
+
* under a sentence blaming `CF_VERSION_METADATA` — so a Worker deployed with a declared domain and no
|
|
46
|
+
* route behind it, answering at no address, reported a deploy that "succeeded" and pointed the adopter at
|
|
47
|
+
* a binding that was already declared.
|
|
48
|
+
*
|
|
49
|
+
* They are different facts and they are established differently. *Something answered and could not say
|
|
50
|
+
* which version it is* is ordinary — an unadopted binding, a `/health` that is not mounted — and stays
|
|
51
|
+
* `inconclusive`. *Nothing answered* is transport-level: DNS, TLS, a timeout, no route. That is a failed
|
|
52
|
+
* deploy, and the address that did not answer is the fact worth printing.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
import { HEALTH_PATH } from "@pithy-sh/core/src/worker/health";
|
|
56
|
+
|
|
57
|
+
/** What a probe concluded. */
|
|
58
|
+
export type DeployVerification =
|
|
59
|
+
| "verified" // the version just shipped answered at the declared domain
|
|
60
|
+
| "mismatch" // something else is consistently answering there
|
|
61
|
+
| "inconclusive" // a gradual rollout, or the Worker answered without a version
|
|
62
|
+
| "unreachable"; // nothing answered at all
|
|
63
|
+
|
|
64
|
+
/** The outcome of verifying one Worker's deploy. */
|
|
65
|
+
export interface VerifyDeployResult {
|
|
66
|
+
/** The conclusion. */
|
|
67
|
+
status: DeployVerification;
|
|
68
|
+
/** Every distinct version observed, in the order first seen. Empty when nothing answered. */
|
|
69
|
+
observed: string[];
|
|
70
|
+
/** How many probes were made. */
|
|
71
|
+
attempts: number;
|
|
72
|
+
/** A one-line explanation, in brand voice, for the deploy summary. */
|
|
73
|
+
detail: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** What the probe needs. Every dependency injected, so the whole thing is testable with no network. */
|
|
77
|
+
export interface VerifyDeployOptions {
|
|
78
|
+
/** The declared base URL, e.g. `https://api.example.com`. `/health` is appended. */
|
|
79
|
+
url: string;
|
|
80
|
+
/** The version id wrangler reported for the deploy just made. */
|
|
81
|
+
expectedVersion: string;
|
|
82
|
+
/** How many times to probe before concluding. Defaults to 5. */
|
|
83
|
+
attempts?: number;
|
|
84
|
+
/** The backoff between probes, in ms. Defaults to 1000. */
|
|
85
|
+
delayMs?: number;
|
|
86
|
+
/**
|
|
87
|
+
* How long one probe may take before it is abandoned, in ms. Defaults to 5 seconds.
|
|
88
|
+
*
|
|
89
|
+
* Without a bound, a domain that accepts a connection and never answers stalls on undici's 300-second
|
|
90
|
+
* headers timeout — five attempts of that is twenty-five minutes of a `pithy deploy` that looks hung,
|
|
91
|
+
* in CI, after the deploy has already succeeded. A health probe that cannot answer in five seconds has
|
|
92
|
+
* answered: this attempt failed, try the next one.
|
|
93
|
+
*/
|
|
94
|
+
timeoutMs?: number;
|
|
95
|
+
/** Injected so a test drives the probe with no network and no clock. */
|
|
96
|
+
fetchImpl?: typeof fetch;
|
|
97
|
+
/** Injected so a test does not actually wait. */
|
|
98
|
+
sleep?: (ms: number) => Promise<void>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The `/health` body this reads. `version` is null on a Worker with no version-metadata binding. */
|
|
102
|
+
interface HealthBody {
|
|
103
|
+
status?: unknown;
|
|
104
|
+
version?: unknown;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const DEFAULT_ATTEMPTS = 5;
|
|
108
|
+
const DEFAULT_DELAY_MS = 1000;
|
|
109
|
+
|
|
110
|
+
/** Five seconds per probe. A `/health` route that cannot answer in that has answered. */
|
|
111
|
+
const DEFAULT_TIMEOUT_MS = 5000;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* What one probe learned, and the distinction the whole conclusion turns on.
|
|
115
|
+
*
|
|
116
|
+
* `reached` is whether an HTTP response came back at all — any status. A 404 or a 500 is a Worker (or a
|
|
117
|
+
* Cloudflare error page) at that address saying something, which is a different world from a DNS failure.
|
|
118
|
+
* `version` is what it reported, when it could.
|
|
119
|
+
*/
|
|
120
|
+
interface Probe {
|
|
121
|
+
reached: boolean;
|
|
122
|
+
version: string | null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** One probe. Reports whether anything answered, and the version it named. */
|
|
126
|
+
async function probe(url: string, fetchImpl: typeof fetch, timeoutMs: number): Promise<Probe> {
|
|
127
|
+
try {
|
|
128
|
+
// `HEALTH_PATH` rather than a literal: `createBackend` mounts the route, and a probe that writes
|
|
129
|
+
// its own copy of the path is a deploy check that goes inconclusive the day the route moves (#400).
|
|
130
|
+
const response = await fetchImpl(`${url.replace(/\/+$/, "")}${HEALTH_PATH}`, {
|
|
131
|
+
method: "GET",
|
|
132
|
+
headers: { accept: "application/json" },
|
|
133
|
+
// An abort lands in the same `catch` as a DNS or TLS failure, which is right: all three mean nothing
|
|
134
|
+
// answered, and the retry loop is what decides whether that is fatal.
|
|
135
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
136
|
+
});
|
|
137
|
+
// Answered, whatever it said. The body is only read on a 2xx — a 404's body is not JSON worth parsing.
|
|
138
|
+
if (!response.ok) return { reached: true, version: null };
|
|
139
|
+
const body = (await response.json().catch(() => ({}))) as HealthBody;
|
|
140
|
+
return {
|
|
141
|
+
reached: true,
|
|
142
|
+
version: typeof body.version === "string" && body.version.length > 0 ? body.version : null,
|
|
143
|
+
};
|
|
144
|
+
} catch {
|
|
145
|
+
// A DNS failure, a TLS failure, a timeout. Indistinguishable from "not routed yet" on the first
|
|
146
|
+
// attempt, which is exactly why this retries rather than concluding.
|
|
147
|
+
return { reached: false, version: null };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Probe the declared domain until the expected version answers, or until the attempts run out.
|
|
153
|
+
*
|
|
154
|
+
* Returns as soon as the expected version is seen — the common case costs one request. Only a deploy
|
|
155
|
+
* that has *not* propagated pays the full backoff.
|
|
156
|
+
*/
|
|
157
|
+
export async function verifyDeployedVersion(options: VerifyDeployOptions): Promise<VerifyDeployResult> {
|
|
158
|
+
const attempts = options.attempts ?? DEFAULT_ATTEMPTS;
|
|
159
|
+
const delayMs = options.delayMs ?? DEFAULT_DELAY_MS;
|
|
160
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
161
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
162
|
+
const sleep = options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
|
163
|
+
|
|
164
|
+
const observed: string[] = [];
|
|
165
|
+
let reached = 0;
|
|
166
|
+
|
|
167
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
168
|
+
const { reached: answered, version } = await probe(options.url, fetchImpl, timeoutMs);
|
|
169
|
+
if (answered) reached += 1;
|
|
170
|
+
if (version !== null) {
|
|
171
|
+
if (version === options.expectedVersion) {
|
|
172
|
+
return {
|
|
173
|
+
status: "verified",
|
|
174
|
+
observed: observed.includes(version) ? observed : [...observed, version],
|
|
175
|
+
attempts: attempt,
|
|
176
|
+
detail: `${options.url} is serving the version just deployed.`,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
if (!observed.includes(version)) observed.push(version);
|
|
180
|
+
}
|
|
181
|
+
if (attempt < attempts) await sleep(delayMs);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (reached === 0) {
|
|
185
|
+
// Nothing answered — not once, over every attempt. The deploy went somewhere, and it is not here.
|
|
186
|
+
// The address is the whole diagnostic: a declared domain with no route behind it is what produces
|
|
187
|
+
// this, and naming a binding instead sends the adopter to the wrong file (#264).
|
|
188
|
+
return {
|
|
189
|
+
status: "unreachable",
|
|
190
|
+
observed,
|
|
191
|
+
attempts,
|
|
192
|
+
detail: `Nothing answered at ${options.url} in ${attempts} attempts. Check that a route in this environment serves that host.`,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (observed.length === 0) {
|
|
197
|
+
// Something is there and cannot say which version it is. Ordinary: a project that has not adopted the
|
|
198
|
+
// `CF_VERSION_METADATA` binding genuinely cannot answer, and neither can a Worker with no `/health`.
|
|
199
|
+
return {
|
|
200
|
+
status: "inconclusive",
|
|
201
|
+
observed,
|
|
202
|
+
attempts,
|
|
203
|
+
detail: `${options.url} answered without a version. Check that it declares CF_VERSION_METADATA.`,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (observed.length > 1) {
|
|
208
|
+
return {
|
|
209
|
+
status: "inconclusive",
|
|
210
|
+
observed,
|
|
211
|
+
attempts,
|
|
212
|
+
detail: `${options.url} is serving ${observed.length} versions — a gradual deployment is in progress.`,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
status: "mismatch",
|
|
218
|
+
observed,
|
|
219
|
+
attempts,
|
|
220
|
+
detail: `${options.url} is serving ${observed[0]}, not the version just deployed.`,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Whether a verification should fail the command. A consistent mismatch, and nothing answering at all. */
|
|
225
|
+
export function isDeployFailure(status: DeployVerification): boolean {
|
|
226
|
+
// `inconclusive` is deliberately not a failure: a gradual rollout and an unadopted binding are both
|
|
227
|
+
// ordinary, and failing a deploy for either would train everyone to ignore the check. `unreachable` is
|
|
228
|
+
// the opposite of ordinary — the declared address answered nothing, over every attempt (#264).
|
|
229
|
+
return status === "mismatch" || status === "unreachable";
|
|
230
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { VERSION_METADATA_BINDING } from "@pithy-sh/core/src/worker/identity";
|
|
5
|
+
import { readWranglerConfig, writeWranglerConfig } from "./wrangler";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The `version_metadata` binding every Pithy Worker should declare, and the idempotent repair for one
|
|
9
|
+
* that does not.
|
|
10
|
+
*
|
|
11
|
+
* **This exists because the feature shipped without it and nobody noticed for a release.**
|
|
12
|
+
* `createBackend` has always read `env.CF_VERSION_METADATA` and stamped a `version` field onto every log
|
|
13
|
+
* record; `docs/LOGGING.md` documented it; a Workers-runtime test proved it worked. No template declared
|
|
14
|
+
* the binding, so the field was absent in every scaffolded project and nobody could correlate a log line
|
|
15
|
+
* to the deploy that produced it — the first question anyone asks when a deploy goes wrong. Correct code,
|
|
16
|
+
* never wired, nothing complaining.
|
|
17
|
+
*
|
|
18
|
+
* It is **not** a capability binding, so it does not travel through `pithy add`'s manifest path. No
|
|
19
|
+
* capability requires it, every Worker wants it, and it is populated by the platform rather than by
|
|
20
|
+
* anything Pithy provisions. That makes it a property of the scaffold — which is why the repair is its
|
|
21
|
+
* own step in `pithy upgrade` rather than a row in some capability's `requiredBindings`.
|
|
22
|
+
*
|
|
23
|
+
* **Top level, never per environment.** It is metadata about the build, and `env.<name>` stanzas replace
|
|
24
|
+
* rather than merge, so a per-environment copy would be three places for one fact to drift in. Cloudflare
|
|
25
|
+
* inherits the top-level declaration into every environment.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** The wrangler key that declares it. One word from the platform's own schema. */
|
|
29
|
+
export const VERSION_METADATA_KEY = "version_metadata";
|
|
30
|
+
|
|
31
|
+
/** The shape this module reads and writes. Only the key it owns — everything else is the adopter's. */
|
|
32
|
+
interface WranglerVersionMetadata {
|
|
33
|
+
version_metadata?: { binding?: string };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Whether this config already declares the binding under the name the runtime reads.
|
|
38
|
+
*
|
|
39
|
+
* Keys on the **binding name**, not merely on the key's presence. A `version_metadata` block naming
|
|
40
|
+
* something else binds a value nothing in the tree consumes and leaves the logger exactly as blind as no
|
|
41
|
+
* declaration at all — so that is drift to report, not a declaration to respect.
|
|
42
|
+
*/
|
|
43
|
+
export function hasVersionMetadata(config: unknown): boolean {
|
|
44
|
+
if (typeof config !== "object" || config === null) return false;
|
|
45
|
+
return (config as WranglerVersionMetadata).version_metadata?.binding === VERSION_METADATA_BINDING;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Declare the binding on a Worker that lacks it. Returns whether anything changed.
|
|
50
|
+
*
|
|
51
|
+
* Idempotent, and **never overwrites an adopter's own value.** A config already naming a different
|
|
52
|
+
* binding is left alone: renaming it would silently repoint a binding they may be reading themselves,
|
|
53
|
+
* and the honest move is to report the drift and let them decide. Writing goes through
|
|
54
|
+
* `writeWranglerConfig`, so comments survive.
|
|
55
|
+
*/
|
|
56
|
+
export async function applyVersionMetadata(workerDir: string): Promise<boolean> {
|
|
57
|
+
// A Worker with no `wrangler.jsonc` is an ordinary member of the project — a Vite frontend joins the
|
|
58
|
+
// dev set through `pithy.worker.jsonc` with a `dev.command` and never deploys. It reaches the
|
|
59
|
+
// reconcile plan like any other, so this must decline rather than throw: the rest of the engine
|
|
60
|
+
// already tolerates it (`readStanzas` returns no stanzas for exactly this case), and an unguarded
|
|
61
|
+
// read here would abort the whole `pithy upgrade --apply` run, taking every Worker after it in
|
|
62
|
+
// discovery order with it. The same applies to a wrangler.jsonc with a syntax error, which the plan
|
|
63
|
+
// side already swallows — reporting drift it cannot repair is the honest outcome.
|
|
64
|
+
let config: WranglerVersionMetadata;
|
|
65
|
+
try {
|
|
66
|
+
config = (await readWranglerConfig(workerDir)) as WranglerVersionMetadata;
|
|
67
|
+
} catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
if (config.version_metadata !== undefined) return false;
|
|
71
|
+
|
|
72
|
+
// Assign the whole block rather than mutating in place: the key is absent, so there is no existing
|
|
73
|
+
// object carrying comment-json's symbol-keyed comments to preserve.
|
|
74
|
+
config.version_metadata = { binding: VERSION_METADATA_BINDING };
|
|
75
|
+
await writeWranglerConfig(workerDir, config);
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { resolveOrigin, type WorkerDomains } from "@pithy-sh/core/src/naming/domains";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The one resolver for "where does this Worker answer".
|
|
8
|
+
*
|
|
9
|
+
* ## What it replaced
|
|
10
|
+
*
|
|
11
|
+
* Three derivations, none of which reconciled with the others:
|
|
12
|
+
*
|
|
13
|
+
* - `deriveBaseUrl` in `envInventory.ts` scraped the first `routes`/`route` pattern out of
|
|
14
|
+
* `wrangler.jsonc`, returning the literal `"local"` for `dev` and `null` when no route was declared.
|
|
15
|
+
* - `pithy email provision` and `pithy turnstile` each read a hand-set `vars.BASE_URL` — with two
|
|
16
|
+
* different parsers, two different error messages, and no validation in one of them.
|
|
17
|
+
* - `pithy dashboard connect` derived nothing at all and simply demanded `--worker-url`.
|
|
18
|
+
*
|
|
19
|
+
* `email provision` refused to run without `vars.BASE_URL` while `pithy env` printed a URL derived from
|
|
20
|
+
* the routes beside it, and nothing anywhere noticed when the two disagreed. That is a defect waiting to
|
|
21
|
+
* be hit; before the first customer it is a refactor, and after it is a migration for every project that
|
|
22
|
+
* guessed differently.
|
|
23
|
+
*
|
|
24
|
+
* ## The order, and why it is this order
|
|
25
|
+
*
|
|
26
|
+
* 1. **The `domains` declaration** for this Worker and environment — authoritative, because it is the
|
|
27
|
+
* thing the `routes` entry and `BASE_URL` are *generated from*. If it is present, everything else is
|
|
28
|
+
* downstream of it and cannot disagree without being stale.
|
|
29
|
+
* 2. **The first `routes`/`route` pattern**, for projects that predate the declaration or hand-edit
|
|
30
|
+
* wrangler. This is what keeps the change non-breaking: an adopter who wrote their own route keeps
|
|
31
|
+
* working and is never told to migrate.
|
|
32
|
+
* 3. **`vars.BASE_URL`**, for a project that set it by hand and declared no route. Last among the
|
|
33
|
+
* config sources because it is the one an adopter can most easily leave stale — it used to be the
|
|
34
|
+
* only input, so it is exactly where a contradiction lives.
|
|
35
|
+
*
|
|
36
|
+
* `workers.dev` is deliberately **not** in this list. It is resolved separately and only where an
|
|
37
|
+
* account is reachable and has the subdomain enabled, because it can be disabled per account and
|
|
38
|
+
* commonly is in production — a fallback that is weakest in the environment that counts is not a
|
|
39
|
+
* fallback worth silently depending on.
|
|
40
|
+
*
|
|
41
|
+
* ## Why the source is reported, not just the URL
|
|
42
|
+
*
|
|
43
|
+
* Every consumer either shows a human what it found (`pithy env`, `pithy dashboard connect` confirming
|
|
44
|
+
* an address before registering it) or needs to explain why it found nothing. "Where did this come
|
|
45
|
+
* from" is the first question in both cases, and reconstructing it after the fact is what produced
|
|
46
|
+
* three resolvers in the first place.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/** Where a resolved address came from. Ordered by authority, most authoritative first. */
|
|
50
|
+
export type WorkerAddressSource = "declaration" | "route" | "var" | "workers.dev";
|
|
51
|
+
|
|
52
|
+
/** A resolved address, and the evidence for it. */
|
|
53
|
+
export interface WorkerAddress {
|
|
54
|
+
/** The absolute base URL, e.g. `https://api.example.com`. Never a bare hostname. */
|
|
55
|
+
url: string;
|
|
56
|
+
/** Which input produced it. */
|
|
57
|
+
source: WorkerAddressSource;
|
|
58
|
+
/** The hostname alone — what Turnstile binds a widget to, and what a route pattern is. */
|
|
59
|
+
hostname: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The slice of a wrangler stanza an address can be read out of. */
|
|
63
|
+
export interface AddressStanza {
|
|
64
|
+
route?: string | { pattern?: string };
|
|
65
|
+
routes?: (string | { pattern?: string })[];
|
|
66
|
+
vars?: Record<string, unknown>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** What the resolver reads. Every field optional — a project may have none of them. */
|
|
70
|
+
export interface ResolveWorkerAddressInput {
|
|
71
|
+
/** The environment being resolved. `dev` never resolves to a public address. */
|
|
72
|
+
environment: string;
|
|
73
|
+
/** The Worker's declared `domains` block, when it has one. */
|
|
74
|
+
domains?: WorkerDomains | undefined;
|
|
75
|
+
/** The `wrangler.jsonc` stanza for this environment (the top-level doc is the `dev` stanza). */
|
|
76
|
+
stanza?: AddressStanza | undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** One route entry reduced to its pattern, in either form wrangler accepts. */
|
|
80
|
+
function routePattern(route: string | { pattern?: string } | undefined): string | null {
|
|
81
|
+
if (typeof route === "string") return route.length > 0 ? route : null;
|
|
82
|
+
const pattern = route?.pattern;
|
|
83
|
+
return typeof pattern === "string" && pattern.length > 0 ? pattern : null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Turn whatever an input held into an absolute URL and a hostname, or null.
|
|
88
|
+
*
|
|
89
|
+
* Accepts both shapes on purpose: a route pattern is a bare hostname (possibly with a path, which is
|
|
90
|
+
* dropped), while a hand-set `vars.BASE_URL` is usually a full URL and occasionally a bare hostname.
|
|
91
|
+
* Anything unparseable is null rather than a throw — the resolver's whole contract is that it reports
|
|
92
|
+
* what it found, and a malformed value found is the same as nothing found for the caller's purposes.
|
|
93
|
+
*/
|
|
94
|
+
function toAddress(value: string, source: WorkerAddressSource): WorkerAddress | null {
|
|
95
|
+
const candidate = /^https?:\/\//.test(value) ? value : `https://${value}`;
|
|
96
|
+
try {
|
|
97
|
+
const url = new URL(candidate);
|
|
98
|
+
if (!url.hostname) return null;
|
|
99
|
+
return { url: `https://${url.hostname}`, source, hostname: url.hostname };
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Resolve a Worker's public address for one environment from config alone — no network.
|
|
107
|
+
*
|
|
108
|
+
* Offline by construction, because `pithy env` is contractually read-only and always exits 0: a resolver
|
|
109
|
+
* that reached Cloudflare would turn it into a command that fails without credentials. The `workers.dev`
|
|
110
|
+
* tier is a separate, explicitly asynchronous step for the callers that can afford it.
|
|
111
|
+
*
|
|
112
|
+
* Returns null for `dev` always. There is no public address for a local run — the answer is
|
|
113
|
+
* `http://localhost:<port>` from the port the feature pinned, which lives in `.dev.config.json` and is
|
|
114
|
+
* not this function's business.
|
|
115
|
+
*/
|
|
116
|
+
export function resolveWorkerAddress(input: ResolveWorkerAddressInput): WorkerAddress | null {
|
|
117
|
+
if (input.environment === "dev") return null;
|
|
118
|
+
|
|
119
|
+
// Through the shared resolver, so the answer this reports and the answer an adopter's config composes
|
|
120
|
+
// are the same function (#256). `declared` is what keeps the fallback out of here: a `LOCAL_ORIGIN`
|
|
121
|
+
// returned as an address would stop the route and `BASE_URL` tiers below from ever being reached.
|
|
122
|
+
const declared = resolveOrigin(input.environment, input.domains);
|
|
123
|
+
if (declared.declared) {
|
|
124
|
+
return { url: declared.origin, source: "declaration", hostname: declared.hostname };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const stanza = input.stanza;
|
|
128
|
+
const pattern = routePattern(stanza?.routes?.[0]) ?? routePattern(stanza?.route);
|
|
129
|
+
if (pattern) {
|
|
130
|
+
const fromRoute = toAddress(pattern, "route");
|
|
131
|
+
if (fromRoute) return fromRoute;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const baseUrl = stanza?.vars?.BASE_URL;
|
|
135
|
+
if (typeof baseUrl === "string" && baseUrl.length > 0) {
|
|
136
|
+
const fromVar = toAddress(baseUrl, "var");
|
|
137
|
+
if (fromVar) return fromVar;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The `workers.dev` address for a script, when the account has a subdomain.
|
|
145
|
+
*
|
|
146
|
+
* Separate from {@link resolveWorkerAddress} and never folded into it, for two reasons. It needs the
|
|
147
|
+
* network, and the resolver must stay offline. And it is the weakest tier by a distance: `workers.dev`
|
|
148
|
+
* can be disabled per account and commonly is in production, where a live domain is the only intended
|
|
149
|
+
* entry point — so a caller has to opt into it deliberately rather than inherit it as a default that
|
|
150
|
+
* quietly stops working in exactly the environment that matters.
|
|
151
|
+
*
|
|
152
|
+
* `subdomain` is whatever `CloudflareWorkersManager.accountSubdomain()` returned, which is already null
|
|
153
|
+
* when the account has none.
|
|
154
|
+
*/
|
|
155
|
+
export function workersDevAddress(scriptName: string, subdomain: string | null): WorkerAddress | null {
|
|
156
|
+
if (!subdomain || !scriptName) return null;
|
|
157
|
+
return {
|
|
158
|
+
url: `https://${scriptName}.${subdomain}.workers.dev`,
|
|
159
|
+
source: "workers.dev",
|
|
160
|
+
hostname: `${scriptName}.${subdomain}.workers.dev`,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** How a resolved address reads in CLI output — the URL, and where it came from. */
|
|
165
|
+
export function describeAddressSource(source: WorkerAddressSource): string {
|
|
166
|
+
switch (source) {
|
|
167
|
+
case "declaration":
|
|
168
|
+
return "declared in pithy.config.ts";
|
|
169
|
+
case "route":
|
|
170
|
+
return "from the route in wrangler.jsonc";
|
|
171
|
+
case "var":
|
|
172
|
+
return "from vars.BASE_URL in wrangler.jsonc";
|
|
173
|
+
case "workers.dev":
|
|
174
|
+
return "your workers.dev subdomain";
|
|
175
|
+
}
|
|
176
|
+
}
|