@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,281 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { basename } from "node:path";
|
|
5
|
+
import type { Capability } from "@pithy-sh/core/src/capability/capability";
|
|
6
|
+
import { messageOf, ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
7
|
+
import { isSourceEnvironment } from "../provision/featureConfig";
|
|
8
|
+
import {
|
|
9
|
+
type AppWorkflowPlan,
|
|
10
|
+
appOwnedWorkflows,
|
|
11
|
+
planAppWorkflows,
|
|
12
|
+
type WorkflowConfig,
|
|
13
|
+
type WorkflowStanza,
|
|
14
|
+
} from "./appWorkflows";
|
|
15
|
+
import { loadProject, loadProjectEnvironments, loadWorkerConfig, requireProjectName } from "./config";
|
|
16
|
+
import { discoverWorkers } from "./workers";
|
|
17
|
+
import { readOptionalWranglerConfig } from "./wrangler";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* **What the app capability declares is what the environment's stanza binds.**
|
|
21
|
+
*
|
|
22
|
+
* That is the whole invariant, and until #267 nothing asked it. `reconcileAppWorkflows` derives the
|
|
23
|
+
* `workflows` table and `triggers.crons` from the `app` capability's `workflows` map and writes them into
|
|
24
|
+
* `wrangler.jsonc`; its only caller is `pithy worker sync`; and no check anywhere read the two halves back.
|
|
25
|
+
* The same structure as #264 one file over — a declaration in `pithy.config.ts`, a fact in
|
|
26
|
+
* `wrangler.jsonc`, one writer, no reader — and the same ending: an adopter who declares a job and never
|
|
27
|
+
* runs `sync` gets a green `pithy doctor`, a green `pithy deploy`, and a Worker that ships with no
|
|
28
|
+
* `workflows` entry and no `triggers.crons`.
|
|
29
|
+
*
|
|
30
|
+
* What that costs is worth naming, because two of the three failures are loud and the third is not:
|
|
31
|
+
*
|
|
32
|
+
* - **The cron never fires.** Nothing is wrong anywhere. No request fails, no log line appears, no probe
|
|
33
|
+
* goes red. The job simply does not run, and the first sign of it is whatever the job existed to
|
|
34
|
+
* prevent. This is the failure this reader exists for.
|
|
35
|
+
* - **`c.var.workflows.trigger("board/digest", …)` cannot reach a binding wrangler never wrote.** Loud,
|
|
36
|
+
* at least: `createBackend` derives a `workflow` binding spec from every registered job, so a
|
|
37
|
+
* non-`optional` one that is absent fails `validateBindings` on the Worker's first request.
|
|
38
|
+
* - **A binding that carries another environment's name** — the shape a copy-pasted stanza produces —
|
|
39
|
+
* starts instances of a Workflow deployed for somewhere else.
|
|
40
|
+
*
|
|
41
|
+
* ## The question is asked once, of the whole table
|
|
42
|
+
*
|
|
43
|
+
* Not "is every declared job bound?" plus "is every bound job declared?" plus "is every cron declared?".
|
|
44
|
+
* Those are three ways for one comparison to come out, and a gate written as a list of them is a gate
|
|
45
|
+
* that will one day be missing the fourth — which is exactly how #264 shipped, asking what `workers_dev`
|
|
46
|
+
* was set to instead of what serves the host. So the declaration is reduced to the table it implies
|
|
47
|
+
* (through {@link planAppWorkflows}, the same function the writer plans with, so the two cannot disagree
|
|
48
|
+
* about what "declared" means), the stanza's own app-owned table is read back beside it (through
|
|
49
|
+
* {@link appOwnedWorkflows}, the same rule the writer replaces by), and the two are compared whole. A
|
|
50
|
+
* missing binding, a stale binding, a stale cron and a wrong name are all one fault with one remedy,
|
|
51
|
+
* and so is anything else that can make the two differ.
|
|
52
|
+
*
|
|
53
|
+
* Order is deliberately not part of it. Cloudflare reads a table, the array's order carries no meaning,
|
|
54
|
+
* and a fault against a hand-ordered stanza that binds exactly the right things would be noise.
|
|
55
|
+
*
|
|
56
|
+
* ## What it does not claim
|
|
57
|
+
*
|
|
58
|
+
* A Worker whose `pithy.config.ts` will not import (dependencies not installed, most often) makes no
|
|
59
|
+
* claim at all — the #264 rule, for the same reason: "this declares no jobs" is a negative finding, and a
|
|
60
|
+
* config nobody could open is precisely the one that might have declared some. A Worker with no `app`
|
|
61
|
+
* capability makes none either: there is no declaration to compare against, and `pithy worker sync`
|
|
62
|
+
* writes nothing for it, so a fault there would name a command that cannot answer it.
|
|
63
|
+
*
|
|
64
|
+
* Files only, offline, no account call — the standard `originDrift` and `unprovisionedBindings` are held
|
|
65
|
+
* to, and the reason this can gate a deploy without costing it a round trip.
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
/** Which of the two ways a declaration and a stanza fail to be the same thing. Two remedies. */
|
|
69
|
+
export type WorkflowFault =
|
|
70
|
+
/** The stanza does not bind what the app declares — in either direction. `pithy worker sync` settles it. */
|
|
71
|
+
| "unsynced-stanza"
|
|
72
|
+
/** The declaration cannot be reduced to a stanza at all, so no command could write one. */
|
|
73
|
+
| "unwritable-declaration";
|
|
74
|
+
|
|
75
|
+
/** One Worker-and-environment whose declaration and stanza are not the same table. */
|
|
76
|
+
export interface WorkflowDrift {
|
|
77
|
+
/** The Worker's `apps/<name>` directory. */
|
|
78
|
+
worker: string;
|
|
79
|
+
/** The environment. */
|
|
80
|
+
env: string;
|
|
81
|
+
/** Which fault this is. */
|
|
82
|
+
fault: WorkflowFault;
|
|
83
|
+
/** What the app capability declares for this environment. Empty on `unwritable-declaration`. */
|
|
84
|
+
declared: AppWorkflowPlan;
|
|
85
|
+
/** What this environment's stanza binds for this Worker's own script. Evidence, never a secret. */
|
|
86
|
+
bound: AppWorkflowPlan;
|
|
87
|
+
/**
|
|
88
|
+
* On `unwritable-declaration` alone: why the declaration could not be reduced to a stanza.
|
|
89
|
+
*
|
|
90
|
+
* Carried rather than re-derived, because the refusal is core's and its sentence names the field —
|
|
91
|
+
* a job with no `className` has no class for wrangler to instantiate, and only `hostWorkflowsFor`
|
|
92
|
+
* knows that. Restating it here would be a second, staler copy of the same rule.
|
|
93
|
+
*/
|
|
94
|
+
reason?: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** What `doctor` learned. Listed positively, so an inconclusive read never gates CI. */
|
|
98
|
+
export type WorkflowsState =
|
|
99
|
+
/** Every declared environment binds exactly what its Worker's app capability declares. */
|
|
100
|
+
| "ok"
|
|
101
|
+
/** The root config would not load or names no project, so no Workflow name could be composed. */
|
|
102
|
+
| "could-not-check"
|
|
103
|
+
/** A declaration and a stanza disagree. Established from local files alone. */
|
|
104
|
+
| "drifted";
|
|
105
|
+
|
|
106
|
+
/** What `doctor` reports about this project's app-declared Workflows. */
|
|
107
|
+
export interface WorkflowsCheck {
|
|
108
|
+
state: WorkflowsState;
|
|
109
|
+
drift: WorkflowDrift[];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The empty table — what an environment declares when its job cannot be named, and what a bare stanza binds. */
|
|
113
|
+
const NOTHING: AppWorkflowPlan = { workflows: [], crons: [] };
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* One table, in a form two of them can be compared by.
|
|
117
|
+
*
|
|
118
|
+
* Every field of every entry, because presence is not the question: a binding called `DIGEST` pointing
|
|
119
|
+
* at `replay-staging-board-digest` in `env.prod` is bound, and bound to the wrong Workflow. Sorted,
|
|
120
|
+
* because the array's order is not part of the invariant.
|
|
121
|
+
*/
|
|
122
|
+
function canonical(plan: AppWorkflowPlan): string {
|
|
123
|
+
return JSON.stringify({
|
|
124
|
+
workflows: plan.workflows.map((entry) => [entry.binding, entry.name, entry.class_name]).sort(),
|
|
125
|
+
crons: [...plan.crons].sort(),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** What one environment's stanza binds for this Worker's own script — the right-hand side of the comparison. */
|
|
130
|
+
function boundBy(stanza: WorkflowStanza | undefined): AppWorkflowPlan {
|
|
131
|
+
return { workflows: appOwnedWorkflows(stanza), crons: [...(stanza?.triggers?.crons ?? [])] };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* One Worker's app capability, or `undefined` when there is no declaration to compare against.
|
|
136
|
+
*
|
|
137
|
+
* The two cases collapse deliberately. A config that will not import declares nothing *knowable*, and a
|
|
138
|
+
* Worker with no `app` block declares nothing *at all* — and in both, the honest output is silence
|
|
139
|
+
* rather than a fault naming a command that would report, correctly and uselessly, that it wrote nothing.
|
|
140
|
+
*/
|
|
141
|
+
async function workerApp(workerDir: string): Promise<Capability | undefined> {
|
|
142
|
+
try {
|
|
143
|
+
return (await loadWorkerConfig(workerDir)).app;
|
|
144
|
+
} catch {
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Every Worker-and-environment in `environments` whose declaration and stanza are not the same table, in
|
|
151
|
+
* worker then environment order.
|
|
152
|
+
*
|
|
153
|
+
* `project` is passed in and never guessed: a Workflow name is account-scoped and leads with the project,
|
|
154
|
+
* so a guessed one would compare the stanza against a name belonging to somebody else's project. It is
|
|
155
|
+
* `requireProjectName`'s answer, exactly as `reconcileAppWorkflows` takes it.
|
|
156
|
+
*/
|
|
157
|
+
export async function workflowDrift(
|
|
158
|
+
projectDir: string,
|
|
159
|
+
project: string,
|
|
160
|
+
environments: readonly string[],
|
|
161
|
+
): Promise<WorkflowDrift[]> {
|
|
162
|
+
const drift: WorkflowDrift[] = [];
|
|
163
|
+
for (const target of await discoverWorkers(projectDir)) {
|
|
164
|
+
if (target.hasWrangler === false) continue;
|
|
165
|
+
const config = (await readOptionalWranglerConfig(target.dir).catch(() => null)) as WorkflowConfig | null;
|
|
166
|
+
if (!config) continue;
|
|
167
|
+
const app = await workerApp(target.dir);
|
|
168
|
+
if (!app) continue;
|
|
169
|
+
const worker = basename(target.dir);
|
|
170
|
+
for (const env of environments) {
|
|
171
|
+
let declared: AppWorkflowPlan;
|
|
172
|
+
try {
|
|
173
|
+
declared = planAppWorkflows(app, { project, env });
|
|
174
|
+
} catch (error) {
|
|
175
|
+
// Reported, never rethrown. `planAppWorkflows` refuses a job it cannot name, and a check that
|
|
176
|
+
// let that escape would take doctor's whole block down to a `catch` and say nothing at all —
|
|
177
|
+
// which is the failure mode this file exists to remove, reintroduced one level up.
|
|
178
|
+
drift.push({
|
|
179
|
+
worker,
|
|
180
|
+
env,
|
|
181
|
+
fault: "unwritable-declaration",
|
|
182
|
+
declared: NOTHING,
|
|
183
|
+
bound: NOTHING,
|
|
184
|
+
reason: messageOf(error),
|
|
185
|
+
});
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
const bound = boundBy(config.env?.[env]);
|
|
189
|
+
if (canonical(declared) !== canonical(bound)) {
|
|
190
|
+
drift.push({ worker, env, fault: "unsynced-stanza", declared, bound });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return drift;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** One table as a sentence fragment: what is bound, or `nothing`. Both sides of every report read this way. */
|
|
198
|
+
function summarize(plan: AppWorkflowPlan): string {
|
|
199
|
+
const parts = [
|
|
200
|
+
...plan.workflows.map((entry) => `${entry.binding} → ${entry.name}`),
|
|
201
|
+
...plan.crons.map((cron) => `cron ${cron}`),
|
|
202
|
+
];
|
|
203
|
+
return parts.length === 0 ? "nothing" : parts.join(", ");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* One drift, as the sentence that fits it — and the sentence is the comparison, both sides of it.
|
|
208
|
+
*
|
|
209
|
+
* Naming only the half that is missing would leave the reader opening two files to learn what was
|
|
210
|
+
* compared against what, and it would have nothing to say at all about the direction where the stanza
|
|
211
|
+
* carries more than the declaration.
|
|
212
|
+
*/
|
|
213
|
+
export function describeWorkflowDrift(drift: WorkflowDrift): string {
|
|
214
|
+
if (drift.fault === "unwritable-declaration") {
|
|
215
|
+
return `${drift.worker} declares a job nothing can bind in ${drift.env}: ${drift.reason} Fix workflows in the Worker's pithy.config.ts — pithy worker sync would refuse it the same way.`;
|
|
216
|
+
}
|
|
217
|
+
return `${drift.worker} declares ${summarize(drift.declared)} for ${drift.env}, and env.${drift.env} binds ${summarize(drift.bound)}. Run pithy worker sync to write the declaration into wrangler.jsonc.`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** The throw-site context behind each fault — why this one is worth a refusal. */
|
|
221
|
+
function workflowDetail(fault: WorkflowFault): string {
|
|
222
|
+
return fault === "unwritable-declaration"
|
|
223
|
+
? "hostWorkflowsFor refuses a job it cannot name, so there is no stanza any command could write for this environment."
|
|
224
|
+
: "A declared Workflow and the binding that runs it are two halves of one fact in two files, and only pithy worker sync writes the second — so a cron nothing bound never fires, and nothing anywhere reports it.";
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Refuse to deploy into an environment that does not bind what its Workers declare.
|
|
229
|
+
*
|
|
230
|
+
* The same moment, and the same reasoning, as `assertOriginsDeclared` beside it: deploy already knows the
|
|
231
|
+
* environment and already has the config, and this is the last point at which the mistake is still
|
|
232
|
+
* hypothetical. It costs no account call.
|
|
233
|
+
*
|
|
234
|
+
* **A feature environment is exempt, and by the same rule.** Its stanza is a generated build artifact
|
|
235
|
+
* under `.wrangler/`, written by provisioning from the tracked file rather than by `pithy worker sync` —
|
|
236
|
+
* so a fault here would refuse every feature deploy and name a command that does not write that file.
|
|
237
|
+
* `isSourceEnvironment` is the predicate the config-path resolver uses, so the two cannot disagree about
|
|
238
|
+
* which environments keep their config in source.
|
|
239
|
+
*
|
|
240
|
+
* A project whose root config will not load or names nothing establishes nothing, and a deploy is not
|
|
241
|
+
* refused on nothing — the same evidence standard every gate here is held to.
|
|
242
|
+
*/
|
|
243
|
+
export async function assertWorkflowsBound(projectDir: string, env: string): Promise<void> {
|
|
244
|
+
if (!isSourceEnvironment(env)) return;
|
|
245
|
+
const project = await loadProject(projectDir)
|
|
246
|
+
.then(requireProjectName)
|
|
247
|
+
.catch(() => null);
|
|
248
|
+
if (project === null) return;
|
|
249
|
+
const drift = await workflowDrift(projectDir, project, [env]);
|
|
250
|
+
if (drift.length === 0) return;
|
|
251
|
+
const first = drift[0] as WorkflowDrift;
|
|
252
|
+
throw new ValidationError({
|
|
253
|
+
message: `${env} does not bind what it declares: ${drift.map((entry) => entry.worker).join(", ")}.`,
|
|
254
|
+
action: drift.map(describeWorkflowDrift).join(" "),
|
|
255
|
+
detail: workflowDetail(first.fault),
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The same question asked of every environment the project declares, for `pithy doctor`.
|
|
261
|
+
*
|
|
262
|
+
* `could-not-check` when the root config will not load or names no project: a Workflow name leads with
|
|
263
|
+
* the project, so without one there is nothing to compare a stanza against — and the `Project:` block
|
|
264
|
+
* already owns saying that a config would not read.
|
|
265
|
+
*
|
|
266
|
+
* `dev` is not among the environments walked, and needs no special case: it is never declared, and the
|
|
267
|
+
* declared set is what a deploy ever targets.
|
|
268
|
+
*/
|
|
269
|
+
export async function checkWorkflows(projectDir: string): Promise<WorkflowsCheck> {
|
|
270
|
+
let project: string;
|
|
271
|
+
let environments: readonly string[];
|
|
272
|
+
try {
|
|
273
|
+
const config = await loadProject(projectDir);
|
|
274
|
+
project = requireProjectName(config);
|
|
275
|
+
environments = loadProjectEnvironments(config);
|
|
276
|
+
} catch {
|
|
277
|
+
return { state: "could-not-check", drift: [] };
|
|
278
|
+
}
|
|
279
|
+
const drift = await workflowDrift(projectDir, project, environments);
|
|
280
|
+
return { state: drift.length > 0 ? "drifted" : "ok", drift };
|
|
281
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { ConflictError, InternalError, NotFoundError } from "@pithy-sh/core/src/error/pithyError";
|
|
7
|
+
import { parse } from "comment-json";
|
|
8
|
+
import { writeJsonc } from "./jsonc";
|
|
9
|
+
import { readOptionalFile } from "./readOptionalFile";
|
|
10
|
+
|
|
11
|
+
/** The slice of `wrangler.jsonc` the per-environment var helpers read and write. */
|
|
12
|
+
export interface WranglerEnvVars {
|
|
13
|
+
env?: Record<string, { vars?: Record<string, string> } | undefined>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The parsed `wrangler.jsonc`, comments preserved (comment-json), or `null` when there is **no file**.
|
|
18
|
+
*
|
|
19
|
+
* Nineteen modules read a Worker's config through this wrapper, and until #204 it read the bytes with a
|
|
20
|
+
* bare `readFile`. That put every one of those reads outside the ENOENT gate: the gate recognizes the leaf
|
|
21
|
+
* calls that hand back a file's contents, so a read behind a wrapper is one it cannot see, and
|
|
22
|
+
* `envInventory.ts` was left spelling out the errno branch for itself — correct, and invisible. The
|
|
23
|
+
* decision lives in {@link readOptionalFile} now, which is what puts this wrapper inside the rule.
|
|
24
|
+
*
|
|
25
|
+
* Absent is `ENOENT` and nothing else. A `wrangler.jsonc` that is there and will not open is a refusal
|
|
26
|
+
* naming it, never a Worker quietly reported as having no configuration.
|
|
27
|
+
*/
|
|
28
|
+
export async function readOptionalWranglerConfig(projectDir: string): Promise<unknown> {
|
|
29
|
+
const path = join(projectDir, "wrangler.jsonc");
|
|
30
|
+
const raw = await readOptionalFile(path, {
|
|
31
|
+
unreadable: ({ code, cause }) =>
|
|
32
|
+
new ConflictError(
|
|
33
|
+
{
|
|
34
|
+
message: `Can't read ${path}.`,
|
|
35
|
+
action: "Fix the file's permissions, or move it aside, and run the command again.",
|
|
36
|
+
detail: `${code ?? "unknown error"} while reading ${path}`,
|
|
37
|
+
},
|
|
38
|
+
{ cause },
|
|
39
|
+
),
|
|
40
|
+
});
|
|
41
|
+
return raw === null ? null : parse(raw);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The same read, for the callers that have already established the Worker has a config — most of them.
|
|
46
|
+
* A directory with no `wrangler.jsonc` is a `PithyError` naming the file rather than node's own `ENOENT`
|
|
47
|
+
* escaping into a command's output. Caller casts the shape.
|
|
48
|
+
*/
|
|
49
|
+
export async function readWranglerConfig(projectDir: string): Promise<unknown> {
|
|
50
|
+
const config = await readOptionalWranglerConfig(projectDir);
|
|
51
|
+
if (config === null) {
|
|
52
|
+
throw new NotFoundError({
|
|
53
|
+
message: `No wrangler.jsonc at ${join(projectDir, "wrangler.jsonc")}.`,
|
|
54
|
+
action: "Every worker lives in apps/<name> with its own wrangler.jsonc. Run pithy worker list to see them.",
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return config;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The module a Worker's `main` names, absolute — the entry every `class_name` in that config resolves
|
|
62
|
+
* against, and the file `pithy add` writes a Durable Object's export into (#428). `null` when the config
|
|
63
|
+
* names none.
|
|
64
|
+
*
|
|
65
|
+
* Read from the config rather than assumed to be `src/index.ts`: `main` is wrangler's own answer to
|
|
66
|
+
* "which module is this Worker", the adopter may move it, and a Worker carrying a front end has one
|
|
67
|
+
* written by the Vite plugin. Guessing would mean writing an export into a file nothing bundles.
|
|
68
|
+
*
|
|
69
|
+
* A missing `main` is answered as a value rather than a throw, because the two callers mean different
|
|
70
|
+
* things by it: `add` is about to wire a class into a Worker that cannot say which module it is, and
|
|
71
|
+
* refuses by name; `remove` is unwiring one and has nothing to take out, so it moves on rather than
|
|
72
|
+
* stranding a capability half-removed.
|
|
73
|
+
*/
|
|
74
|
+
export async function workerEntryPath(workerDir: string): Promise<string | null> {
|
|
75
|
+
const config = (await readWranglerConfig(workerDir)) as { main?: unknown };
|
|
76
|
+
if (typeof config.main !== "string" || config.main === "") return null;
|
|
77
|
+
return join(workerDir, config.main);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Write `wrangler.jsonc` back comment-preserving, printed the way the Biome `pithy init` scaffolds would
|
|
82
|
+
* print it and shaped like the bytes already there — see {@link writeJsonc}. This wrote `stringify`'s
|
|
83
|
+
* fully expanded output until #249, so every command that edits a Worker's config left a file the
|
|
84
|
+
* adopter's own commit hook rejected, and buried a two-line change in a whole-file reformat.
|
|
85
|
+
*/
|
|
86
|
+
export async function writeWranglerConfig(projectDir: string, config: unknown): Promise<void> {
|
|
87
|
+
await writeJsonc(join(projectDir, "wrangler.jsonc"), config);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface WranglerOptions {
|
|
91
|
+
/**
|
|
92
|
+
* Stream wrangler's output straight to the terminal. Off by default: the output is captured and
|
|
93
|
+
* surfaced **only on failure** — quiet on success, the error when there is one. That's pithy.
|
|
94
|
+
*/
|
|
95
|
+
passthrough?: boolean;
|
|
96
|
+
/** Working directory for the command. */
|
|
97
|
+
cwd?: string;
|
|
98
|
+
/**
|
|
99
|
+
* Override the executable to spawn (with `args` passed straight through). Tests set this to a
|
|
100
|
+
* stand-in. When omitted, wrangler runs via `bun x wrangler` so the workspace devDependency
|
|
101
|
+
* resolves — `pithy` does not assume a globally-installed wrangler.
|
|
102
|
+
*/
|
|
103
|
+
bin?: string;
|
|
104
|
+
/**
|
|
105
|
+
* Extra env vars merged onto the child process. Provisioning passes `CLOUDFLARE_API_TOKEN`
|
|
106
|
+
* (from the `.dev.vars` token) so wrangler authenticates without a separate `wrangler login` —
|
|
107
|
+
* `.dev.vars` stays the single source of credentials.
|
|
108
|
+
*/
|
|
109
|
+
env?: Record<string, string>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Run a wrangler command. We don't reimplement wrangler — `pithy` shells out to it (deploy, D1, …)
|
|
114
|
+
* and owns only the output discipline: by default it stays quiet, capturing stdout/stderr and
|
|
115
|
+
* raising them as the error `detail` if wrangler fails; with `passthrough`, wrangler's output streams
|
|
116
|
+
* through directly. A non-zero exit (or a missing binary) becomes a `PithyError`.
|
|
117
|
+
*
|
|
118
|
+
* On success it resolves with the captured `stdout`/`stderr` — empty strings in `passthrough` mode,
|
|
119
|
+
* where nothing is captured — so callers that need wrangler's output (e.g. `deploy` scraping the
|
|
120
|
+
* version id and url) can read it without giving up the quiet-on-success default.
|
|
121
|
+
*
|
|
122
|
+
* Wrangler is a workspace devDependency, not a global, so it runs through `bun x wrangler` — bun
|
|
123
|
+
* resolves the local install from the `cwd`. Tests override `bin` to spawn a stand-in directly.
|
|
124
|
+
*/
|
|
125
|
+
export async function runWrangler(
|
|
126
|
+
args: string[],
|
|
127
|
+
options: WranglerOptions = {},
|
|
128
|
+
): Promise<{ stdout: string; stderr: string }> {
|
|
129
|
+
const command = options.bin ?? "bun";
|
|
130
|
+
const commandArgs = options.bin ? args : ["x", "wrangler", ...args];
|
|
131
|
+
const label = options.bin ?? "wrangler";
|
|
132
|
+
return new Promise((resolve, reject) => {
|
|
133
|
+
const child = spawn(command, commandArgs, {
|
|
134
|
+
cwd: options.cwd,
|
|
135
|
+
stdio: options.passthrough ? "inherit" : ["ignore", "pipe", "pipe"],
|
|
136
|
+
env: options.env ? { ...process.env, ...options.env } : process.env,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
let stdout = "";
|
|
140
|
+
let stderr = "";
|
|
141
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
142
|
+
stdout += chunk.toString();
|
|
143
|
+
});
|
|
144
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
145
|
+
stderr += chunk.toString();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
child.on("error", (cause) => {
|
|
149
|
+
reject(
|
|
150
|
+
new InternalError({
|
|
151
|
+
message: `Could not run ${label}.`,
|
|
152
|
+
action: `Is ${label} installed and on PATH?`,
|
|
153
|
+
detail: cause.message,
|
|
154
|
+
}),
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
child.on("close", (code) => {
|
|
159
|
+
if (code === 0) {
|
|
160
|
+
resolve({ stdout, stderr });
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
// Surface the captured output (the errors) even in quiet mode; in passthrough it already streamed.
|
|
164
|
+
const captured = options.passthrough ? "" : `\n${(stderr || stdout).trim()}`;
|
|
165
|
+
reject(new InternalError({ message: `${label} ${args[0] ?? ""} failed.`, detail: `exit ${code}${captured}` }));
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
5
|
+
import { isProductionEnv } from "../seed/safety";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* **What stands between `pithy provision` and production.**
|
|
9
|
+
*
|
|
10
|
+
* Provisioning creates real account resources and runs migrations against them. Everything else in the
|
|
11
|
+
* kit that reaches production behind one flag is gated the same way, and for the same reason `--redo`
|
|
12
|
+
* is: `--yes` means "yes, this is not dev", and it is the flag every CI job already passes. It cannot
|
|
13
|
+
* also be what authorizes a first write against the environment paying customers are on.
|
|
14
|
+
*
|
|
15
|
+
* So production takes an exact phrase, **and the phrase names its environment** — a phrase typed for
|
|
16
|
+
* `staging` cannot be pasted into a command targeting `prod`, which one fixed sentence would allow. Who
|
|
17
|
+
* counts as production is `isProductionEnv`'s answer, the same one `pithy seed` uses, so a project whose
|
|
18
|
+
* production environment is called `live` declares it once in `seed.productionEnvironments` and is
|
|
19
|
+
* protected everywhere rather than in one command.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** The exact phrase that unlocks provisioning one environment. Compared case-insensitively after trimming. */
|
|
23
|
+
export function provisionConfirmPhrase(env: string): string {
|
|
24
|
+
return `yes, i really want to provision ${env.trim().toLowerCase()}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Inputs to the provisioning gate. */
|
|
28
|
+
export interface ConfirmProvisionOptions {
|
|
29
|
+
/** The environment being provisioned. */
|
|
30
|
+
env: string;
|
|
31
|
+
/** The `--yes` flag. Required for every environment; never sufficient for production. */
|
|
32
|
+
yes: boolean;
|
|
33
|
+
/** Non-interactive mode (`--json`, or any headless run). No prompt is shown; the phrase must arrive by flag. */
|
|
34
|
+
json: boolean;
|
|
35
|
+
/** The `--confirm` flag value. Authoritative wherever present, so CI never has to answer a prompt. */
|
|
36
|
+
confirmPhrase?: string;
|
|
37
|
+
/** Interactive confirm seam: ask the operator for the phrase. Never called under `--json`. */
|
|
38
|
+
prompt?: () => Promise<string>;
|
|
39
|
+
/** The names this project classifies as production (`seed.productionEnvironments`), plus the built-ins. */
|
|
40
|
+
productionEnvironments?: readonly string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Enforce the gate. Resolves when the run is authorized, throws a `ValidationError` otherwise.
|
|
45
|
+
*
|
|
46
|
+
* - Any environment → requires `--yes`. Provisioning is never the accidental result of a bare command.
|
|
47
|
+
* - Production → requires `--yes` **and** the exact {@link provisionConfirmPhrase}, from `--confirm` or,
|
|
48
|
+
* interactively, from the prompt. `--json` forbids the prompt, so a headless production provision
|
|
49
|
+
* happens only when a human wrote the phrase into the pipeline.
|
|
50
|
+
*/
|
|
51
|
+
export async function assertProvisionConfirmed(options: ConfirmProvisionOptions): Promise<void> {
|
|
52
|
+
if (!options.yes) {
|
|
53
|
+
throw new ValidationError({
|
|
54
|
+
message: `Provisioning ${options.env} creates real Cloudflare resources.`,
|
|
55
|
+
action: `Re-run with --yes to provision ${options.env}.`,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!isProductionEnv(options.env, options.productionEnvironments)) return;
|
|
60
|
+
|
|
61
|
+
const expected = provisionConfirmPhrase(options.env);
|
|
62
|
+
const matches = (input: string | undefined): boolean =>
|
|
63
|
+
input !== undefined && input.trim().toLowerCase() === expected;
|
|
64
|
+
|
|
65
|
+
// The flag is authoritative wherever present (CI or interactive).
|
|
66
|
+
if (options.confirmPhrase !== undefined) {
|
|
67
|
+
if (matches(options.confirmPhrase)) return;
|
|
68
|
+
throw new ValidationError({
|
|
69
|
+
message: `That is not the confirmation phrase for provisioning ${options.env}.`,
|
|
70
|
+
action: `Pass --confirm "${expected}".`,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!options.json && options.prompt) {
|
|
75
|
+
if (matches(await options.prompt())) return;
|
|
76
|
+
throw new ValidationError({
|
|
77
|
+
message: `Provisioning ${options.env} was not confirmed.`,
|
|
78
|
+
action: `Type the exact phrase, or pass --confirm "${expected}".`,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
throw new ValidationError({
|
|
83
|
+
message: `${options.env} is a production environment.`,
|
|
84
|
+
action: `Pass --confirm "${expected}".`,
|
|
85
|
+
});
|
|
86
|
+
}
|