@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,701 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { access, copyFile, rm } from "node:fs/promises";
|
|
6
|
+
import { basename, dirname, join } from "node:path";
|
|
7
|
+
import { pathToFileURL } from "node:url";
|
|
8
|
+
import type { ProfileOverride } from "@pithy-sh/cloudflare/src/tokens/profiles";
|
|
9
|
+
import type { Capability } from "@pithy-sh/core/src/capability/capability";
|
|
10
|
+
import {
|
|
11
|
+
causeMessage,
|
|
12
|
+
failurePosition,
|
|
13
|
+
isBuildFailureWrapper,
|
|
14
|
+
prop,
|
|
15
|
+
rootCause,
|
|
16
|
+
safeReason,
|
|
17
|
+
unresolvedSpecifier,
|
|
18
|
+
} from "@pithy-sh/core/src/error/cause";
|
|
19
|
+
import { fromZodError, InternalError, NotFoundError, ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
20
|
+
import { WorkerDomains } from "@pithy-sh/core/src/naming/domains";
|
|
21
|
+
import { DEFAULT_ENVIRONMENTS, DeclaredEnvironments } from "@pithy-sh/core/src/naming/environment";
|
|
22
|
+
import { assertValidProjectName, kebab } from "@pithy-sh/core/src/naming/resource";
|
|
23
|
+
import { z } from "zod";
|
|
24
|
+
import { CloudflareAccountName } from "../cloudflare/config";
|
|
25
|
+
import { discoverWorkers } from "./workers";
|
|
26
|
+
|
|
27
|
+
/** Adopter token configuration: per-profile overrides of the predefined defaults (permissions/resources/store). */
|
|
28
|
+
export interface TokenConfig {
|
|
29
|
+
/** Profile name → the fields to override on that profile's predefined default. */
|
|
30
|
+
overrides?: Record<string, ProfileOverride>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Adopter `pithy seed` configuration. */
|
|
34
|
+
export interface SeedProjectConfig {
|
|
35
|
+
/**
|
|
36
|
+
* Compose in `example`-flagged seed sets (tiny demo fixtures a capability ships for a quick look).
|
|
37
|
+
* Default off — an adopter opts in per project, and an example set never targets production
|
|
38
|
+
* regardless of this setting (its own `environments` allowlist excludes it).
|
|
39
|
+
*/
|
|
40
|
+
includeExamples?: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Environment names this project treats as production, beyond the built-in `production`/`prod`.
|
|
43
|
+
* Any env named here (case-insensitive) requires the hard type-to-confirm phrase, not just `--yes` —
|
|
44
|
+
* so a project whose production environment is named `live`, `prod-eu`, `main`, etc. gets the same
|
|
45
|
+
* strongest gate as the canonical names. List every production-class environment you run.
|
|
46
|
+
*/
|
|
47
|
+
productionEnvironments?: readonly string[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The **root** `pithy.config.ts`: project identity and project-wide policy. It deliberately carries no
|
|
52
|
+
* capabilities — what a Worker is *made of* is per-Worker and lives in `apps/<name>/pithy.config.ts`
|
|
53
|
+
* ({@link WorkerConfig}). These three settings are the ones that cannot be per-Worker:
|
|
54
|
+
*
|
|
55
|
+
* - `name` is the first segment of every feature resource name and the only key teardown has to find them by,
|
|
56
|
+
* so it must be one stable value for the whole project.
|
|
57
|
+
* - `environments` is the set of environments the project has — the second segment of the same names, and
|
|
58
|
+
* the set every command that iterates environments has to agree on.
|
|
59
|
+
* - `tokens` configures account-level Cloudflare API token profiles.
|
|
60
|
+
* - `seed.productionEnvironments` is a safety policy; a Worker must not be able to quietly omit it.
|
|
61
|
+
*/
|
|
62
|
+
export interface ProjectConfig {
|
|
63
|
+
/**
|
|
64
|
+
* The project name — a short, hyphenated-lowercase identifier (e.g. `acme`). It is the branch-first
|
|
65
|
+
* prefix `pithy feature` names every Cloudflare resource under (`<project>-f<issue>-<slug>-<resource>`),
|
|
66
|
+
* so the CF dashboard groups a feature's resources and teardown finds them by prefix. Optional; when
|
|
67
|
+
* absent it falls back to the app Worker's `wrangler.jsonc` name, then the project directory name.
|
|
68
|
+
*/
|
|
69
|
+
name?: string;
|
|
70
|
+
/**
|
|
71
|
+
* Every deployed environment this project has, e.g. `["staging", "prod"]` — the **second** segment of
|
|
72
|
+
* every Cloudflare name it composes, and the other half of the budget `name` is already governed by.
|
|
73
|
+
* Optional; absent means {@link DEFAULT_ENVIRONMENTS}, which is what the scaffold hardcoded before this
|
|
74
|
+
* setting existed.
|
|
75
|
+
*
|
|
76
|
+
* Typed `unknown` for the same reason {@link ProjectConfig.cloudflare} is: this file is the adopter's own
|
|
77
|
+
* TypeScript, `loadProject` imports it live, and duck-typing is the only other gate on it — so an
|
|
78
|
+
* unvalidated entry would reach a resource name as whatever they typed. {@link loadProjectEnvironments}
|
|
79
|
+
* is the gate.
|
|
80
|
+
*/
|
|
81
|
+
environments?: unknown;
|
|
82
|
+
/** Overrides for the predefined CF token profiles (`pithy token`). Optional. */
|
|
83
|
+
tokens?: TokenConfig;
|
|
84
|
+
/** `pithy seed` settings. Optional; defaults to no example seeds. */
|
|
85
|
+
seed?: SeedProjectConfig;
|
|
86
|
+
/**
|
|
87
|
+
* Which Cloudflare account this project belongs to — `{ accountName?, accountId? }`, validated by
|
|
88
|
+
* {@link loadProjectCloudflare}. Optional, and absent means exactly what it always meant: the
|
|
89
|
+
* credentials come from `<config>/cloudflare.json`.
|
|
90
|
+
*
|
|
91
|
+
* Typed `unknown` for the same reason {@link WorkerConfig.domains} is: this file is the adopter's own
|
|
92
|
+
* TypeScript, `loadProject` imports it live, and duck-typing is the only other gate on it — so an
|
|
93
|
+
* unvalidated `accountName` would reach a `join` as whatever they typed.
|
|
94
|
+
*/
|
|
95
|
+
cloudflare?: unknown;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The root config's `cloudflare` block: **which Cloudflare account this project belongs to** (#206).
|
|
100
|
+
*
|
|
101
|
+
* `<config>/cloudflare.json` is account-scoped, on the reasoning that one account holds many projects
|
|
102
|
+
* (#182). That reasoning is right and incomplete: it assumes one account per *machine*. A developer
|
|
103
|
+
* working across two companies has two, and without this block every project on the machine reads the
|
|
104
|
+
* same file — so switching accounts means editing that file in place and every project silently follows.
|
|
105
|
+
*
|
|
106
|
+
* **Strict, because a dropped key here is the whole failure mode.** A misspelled `accountid` that parsed
|
|
107
|
+
* to "no pin" would leave the project with exactly the silent, unverified resolution the pin exists to
|
|
108
|
+
* end, and nothing would say so.
|
|
109
|
+
*/
|
|
110
|
+
export const ProjectCloudflare = z
|
|
111
|
+
.strictObject({
|
|
112
|
+
accountName: CloudflareAccountName.optional().describe(
|
|
113
|
+
"Selects <config>/cloudflare.<name>.json. Absent selects cloudflare.json, exactly as before.",
|
|
114
|
+
),
|
|
115
|
+
accountId: z
|
|
116
|
+
.string()
|
|
117
|
+
.min(1)
|
|
118
|
+
.optional()
|
|
119
|
+
.describe(
|
|
120
|
+
"The account this project belongs to. An identifier, not a secret — safe in a repository, including a public one — and verified against whatever the credentials resolve to.",
|
|
121
|
+
),
|
|
122
|
+
})
|
|
123
|
+
.describe("Which Cloudflare account this project's credentials must belong to, from the root pithy.config.ts.");
|
|
124
|
+
|
|
125
|
+
/** Which Cloudflare account this project belongs to. Same name as its schema, as every Zod object here is. */
|
|
126
|
+
export type ProjectCloudflare = z.output<typeof ProjectCloudflare>;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Read and validate the root config's `cloudflare` declaration.
|
|
130
|
+
*
|
|
131
|
+
* Parsed rather than cast, on {@link loadWorkerDomains}'s argument and one more of its own: `accountName`
|
|
132
|
+
* becomes a **file name** in the config directory, which sits outside every checkout — so
|
|
133
|
+
* `ensureScaffoldPath` and the atomic writer, the two things that guard a path inside a project, never
|
|
134
|
+
* see it. {@link CloudflareAccountName} is the gate, and this is where a project's config meets it.
|
|
135
|
+
*
|
|
136
|
+
* Absent is not an error; it is the ordinary state of a machine with one Cloudflare account.
|
|
137
|
+
*/
|
|
138
|
+
export function loadProjectCloudflare(config: ProjectConfig): ProjectCloudflare | undefined {
|
|
139
|
+
if (config.cloudflare === undefined || config.cloudflare === null) return undefined;
|
|
140
|
+
const parsed = ProjectCloudflare.safeParse(config.cloudflare);
|
|
141
|
+
if (!parsed.success) {
|
|
142
|
+
// The schema's own sentences are promoted into `message` rather than left in `issues`, because the
|
|
143
|
+
// CLI's error renderer prints `message` and `action` and nothing else — and the whole point of the
|
|
144
|
+
// rule living at the schema is that its refusal names the config and the value wherever it surfaces.
|
|
145
|
+
throw fromZodError(parsed.error, {
|
|
146
|
+
message: `The \`cloudflare\` block in pithy.config.ts is not valid. ${parsed.error.issues.map((issue) => issue.message).join(" ")}`,
|
|
147
|
+
action:
|
|
148
|
+
"Fix `cloudflare` in the root pithy.config.ts. It takes `accountName` (a bare token selecting <config>/cloudflare.<name>.json) and `accountId` (the account the project belongs to).",
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
return parsed.data;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Read and validate the root config's `environments` declaration — **the project's environment set, in
|
|
156
|
+
* one place, for every command that iterates environments** (#241).
|
|
157
|
+
*
|
|
158
|
+
* Before this, nothing said. The set existed only as `env.<name>` stanzas in each Worker's
|
|
159
|
+
* `wrangler.jsonc` — per Worker, so two Workers could disagree with nobody reconciling them — while
|
|
160
|
+
* `ManagedEnvironment` held a closed enum of two and `seed.productionEnvironments` invited a project to
|
|
161
|
+
* name a third. An adopter adding `env.live` got a working `pithy migrate --env live` and a
|
|
162
|
+
* `pithy secrets provision` that skipped it in silence.
|
|
163
|
+
*
|
|
164
|
+
* Absent is not an error: it is the ordinary state of a project that runs staging and prod, and it answers
|
|
165
|
+
* {@link DEFAULT_ENVIRONMENTS} — the pair the scaffold hardcoded — so nothing about an existing project
|
|
166
|
+
* changes until it says otherwise.
|
|
167
|
+
*
|
|
168
|
+
* **Validated on every load, not at the first provision.** An environment name reaches Cloudflare resource
|
|
169
|
+
* names verbatim and a provisioned project cannot be renamed, so the one place that can still say no is
|
|
170
|
+
* before any command has acted on it.
|
|
171
|
+
*/
|
|
172
|
+
export function loadProjectEnvironments(config: ProjectConfig): DeclaredEnvironments {
|
|
173
|
+
if (config.environments === undefined || config.environments === null) return [...DEFAULT_ENVIRONMENTS];
|
|
174
|
+
const parsed = DeclaredEnvironments.safeParse(config.environments);
|
|
175
|
+
if (!parsed.success) {
|
|
176
|
+
// The schema's own sentences are promoted into `message`, as the `cloudflare` block's are, because the
|
|
177
|
+
// CLI's error renderer prints `message` and `action` and nothing else.
|
|
178
|
+
throw fromZodError(parsed.error, {
|
|
179
|
+
message: `The \`environments\` declaration in pithy.config.ts is not valid. ${parsed.error.issues.map((issue) => issue.message).join(" ")}`,
|
|
180
|
+
action: `Fix \`environments\` in the root pithy.config.ts. It is a list of deployed environment names, least-production first — e.g. ${JSON.stringify([...DEFAULT_ENVIRONMENTS])}. Changing it does not rename anything already provisioned.`,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
return parsed.data;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The environments a project has, loaded from its root config — the counterpart to
|
|
188
|
+
* {@link projectCloudflareAccount}, for a command that has a directory rather than a loaded config.
|
|
189
|
+
*/
|
|
190
|
+
export async function projectEnvironments(projectDir: string): Promise<DeclaredEnvironments> {
|
|
191
|
+
return loadProjectEnvironments(await loadProject(projectDir));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* One Worker's `apps/<name>/pithy.config.ts`: what *that* Worker is made of. Capabilities are per-Worker
|
|
196
|
+
* because everything they drive is per-Worker — the composed route tree (`createEntrypoint`), the
|
|
197
|
+
* `requiredBindings` written into that Worker's `wrangler.jsonc`, and Durable Object class migrations, which
|
|
198
|
+
* register a class against a specific script. A Worker that only needs KV composes only what it declares.
|
|
199
|
+
*
|
|
200
|
+
* Workers share a resource by declaring the **same binding name**: feature resource names are derived from
|
|
201
|
+
* `(project, issue, slug, binding, kind)` with no Worker segment, so two Workers that both declare `DB` are
|
|
202
|
+
* backed by one D1, and a Worker wanting its own declares a different binding (e.g. `COLLAB_DB`).
|
|
203
|
+
*/
|
|
204
|
+
export interface WorkerConfig {
|
|
205
|
+
/** Library capabilities this Worker composes, in order. `pithy add --worker <name>` registers them here. */
|
|
206
|
+
capabilities: Capability[];
|
|
207
|
+
/** This Worker's own app capability, composed last. */
|
|
208
|
+
app?: Capability;
|
|
209
|
+
/**
|
|
210
|
+
* Where this Worker answers, per environment — the one declaration `routes`, `vars.BASE_URL`, and every
|
|
211
|
+
* command that needs an address are derived from. Optional: a project without a domain yet is
|
|
212
|
+
* legitimate, and adding one later is a config edit plus a deploy.
|
|
213
|
+
*
|
|
214
|
+
* Validated through {@link WorkerDomains} by {@link loadWorkerDomains} rather than trusted off the
|
|
215
|
+
* import, because this file is the adopter's own TypeScript and nothing else checks it.
|
|
216
|
+
*/
|
|
217
|
+
domains?: unknown;
|
|
218
|
+
/**
|
|
219
|
+
* Optional bindings this Worker will not have, each mapped to the reason it will not.
|
|
220
|
+
*
|
|
221
|
+
* A capability marks a binding `optional` when its own code has a path for the binding's absence.
|
|
222
|
+
* Naming one here says this Worker takes that path deliberately: `pithy upgrade` leaves it out of
|
|
223
|
+
* `wrangler.jsonc`, and `pithy doctor` reports it as declined rather than missing — so a stanza
|
|
224
|
+
* deleted by hand stays deleted instead of returning on the next upgrade (#440).
|
|
225
|
+
*
|
|
226
|
+
* **The capability's own config is the first place to look.** Turning a feature off is a better
|
|
227
|
+
* answer than declining its binding, because the capability then declares nothing and there is
|
|
228
|
+
* nothing to decline. This is for the case where the feature is on and the resource is deliberately
|
|
229
|
+
* not provisioned.
|
|
230
|
+
*
|
|
231
|
+
* Typed `unknown` for the same reason {@link WorkerConfig.domains} is, and read through
|
|
232
|
+
* {@link readDeclinedBindings} — which reports rather than throws, because `pithy doctor` must
|
|
233
|
+
* still produce a report for a Worker whose declaration is malformed.
|
|
234
|
+
*/
|
|
235
|
+
declinedBindings?: unknown;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* The reason a Worker gives for declining an optional binding.
|
|
240
|
+
*
|
|
241
|
+
* **Required, and that is the feature.** A silent omission is what existed before #440 and is
|
|
242
|
+
* indistinguishable from having forgotten; `pithy doctor` prints this back on every run, so the next
|
|
243
|
+
* person reads why rather than guessing whether the absence was a choice. Single-line and bounded
|
|
244
|
+
* because it lands in a fixed-width terminal report beside the binding it explains.
|
|
245
|
+
*/
|
|
246
|
+
export const DeclineReason = z
|
|
247
|
+
.string()
|
|
248
|
+
.trim()
|
|
249
|
+
.min(1)
|
|
250
|
+
.max(160)
|
|
251
|
+
.refine((reason) => !/[\r\n]/.test(reason), { message: "A reason is one line." })
|
|
252
|
+
.describe(
|
|
253
|
+
"Why this Worker will not have the binding. Printed back by `pithy doctor` on every run, so write it for the next person: one line, up to 160 characters.",
|
|
254
|
+
);
|
|
255
|
+
export type DeclineReason = z.infer<typeof DeclineReason>;
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* A Worker's declined optional bindings — binding name to reason.
|
|
259
|
+
*
|
|
260
|
+
* A record rather than an array of objects because the binding name is the key everything downstream
|
|
261
|
+
* joins on, and a record makes a name declared twice unrepresentable rather than a case to resolve.
|
|
262
|
+
*/
|
|
263
|
+
/**
|
|
264
|
+
* A Worker env binding name, as a capability declares it and wrangler writes it.
|
|
265
|
+
*
|
|
266
|
+
* Its own schema rather than the reason's: keying a record on `DeclineReason` typechecked and held, but
|
|
267
|
+
* it reported "A reason is one line." about a malformed *key*, silently trimmed `" DB "` into `DB`, and
|
|
268
|
+
* let a 160-character sentence stand where a binding name belongs. A key and a value are different
|
|
269
|
+
* things and the failure an adopter reads should say which one they got wrong.
|
|
270
|
+
*
|
|
271
|
+
* Shaped the way every binding in the kit is: `SCREAMING_SNAKE_CASE`. That is a real constraint rather
|
|
272
|
+
* than a style note — an entry that cannot name a binding can only ever resolve `unrecognized`, so
|
|
273
|
+
* refusing it here turns a line that would be silently inert into one that says so.
|
|
274
|
+
*/
|
|
275
|
+
export const BindingName = z
|
|
276
|
+
.string()
|
|
277
|
+
.regex(/^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*$/, {
|
|
278
|
+
message: "A binding name is SCREAMING_SNAKE_CASE, like `DB` or `SUPPORT_BUCKET`.",
|
|
279
|
+
})
|
|
280
|
+
.max(64)
|
|
281
|
+
.describe("A Worker env binding name, as the capability declares it — SCREAMING_SNAKE_CASE.");
|
|
282
|
+
export type BindingName = z.infer<typeof BindingName>;
|
|
283
|
+
|
|
284
|
+
export const DeclinedBindings = z
|
|
285
|
+
.record(BindingName, DeclineReason)
|
|
286
|
+
.describe(
|
|
287
|
+
"Optional bindings this Worker will not have, keyed by binding name. `pithy upgrade` leaves each out of wrangler.jsonc; `pithy doctor` reports it as declined rather than missing.",
|
|
288
|
+
);
|
|
289
|
+
export type DeclinedBindings = z.infer<typeof DeclinedBindings>;
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* What a Worker's `declinedBindings` declaration turned out to be.
|
|
293
|
+
*
|
|
294
|
+
* `invalid` rather than a throw: `pithy doctor` reads this, and a doctor that refuses to report
|
|
295
|
+
* because one declaration is malformed is a doctor that goes silent exactly when something is wrong.
|
|
296
|
+
* `pithy upgrade` turns the same state into a refusal at its own gate, before it writes anything.
|
|
297
|
+
*/
|
|
298
|
+
export type DeclinedBindingsRead =
|
|
299
|
+
| { state: "read"; declared: DeclinedBindings }
|
|
300
|
+
| { state: "invalid"; problem: string };
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The spellings of `declinedBindings` that are near enough to be a typo rather than an unrelated key.
|
|
304
|
+
*
|
|
305
|
+
* Compared on lowercase alphanumerics, so `declinedBinding`, `declineBindings` and `declined_bindings`
|
|
306
|
+
* all land here. An adopter's own unrelated key is untouched — this only fires on something that was
|
|
307
|
+
* plainly meant to be this one and would otherwise be ignored in silence, which is the failure mode the
|
|
308
|
+
* whole feature exists to remove.
|
|
309
|
+
*/
|
|
310
|
+
const DECLINE_NEAR_MISSES = [
|
|
311
|
+
// The canonical key's own normalization, which catches every case difference and separator an
|
|
312
|
+
// adopter might reach for: `declined_bindings`, `DeclinedBindings`, `declined-bindings`.
|
|
313
|
+
"declinedbindings",
|
|
314
|
+
"declinedbinding",
|
|
315
|
+
"declinebindings",
|
|
316
|
+
"declinebinding",
|
|
317
|
+
"declinedbindingnames",
|
|
318
|
+
];
|
|
319
|
+
|
|
320
|
+
/** Normalize a config key for near-miss comparison: lowercase, letters and digits only. */
|
|
321
|
+
function normalizeKey(key: string): string {
|
|
322
|
+
return key.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Read a Worker's `declinedBindings` declaration.
|
|
327
|
+
*
|
|
328
|
+
* Absent is not an error; it is the ordinary state of every Worker that declines nothing. A key that is
|
|
329
|
+
* *nearly* `declinedBindings` is an error, because the scaffolded `const config = { … }` is an
|
|
330
|
+
* unannotated literal — TypeScript accepts the misspelling, nothing reads it, and the binding an
|
|
331
|
+
* adopter thought they had declined comes back on the next upgrade with no line anywhere saying why.
|
|
332
|
+
*/
|
|
333
|
+
export function readDeclinedBindings(config: WorkerConfig): DeclinedBindingsRead {
|
|
334
|
+
const record = config as unknown as Record<string, unknown>;
|
|
335
|
+
const nearMiss = Object.keys(record).find(
|
|
336
|
+
(key) => key !== "declinedBindings" && DECLINE_NEAR_MISSES.includes(normalizeKey(key)),
|
|
337
|
+
);
|
|
338
|
+
if (nearMiss !== undefined) {
|
|
339
|
+
return {
|
|
340
|
+
state: "invalid",
|
|
341
|
+
problem: `\`${nearMiss}\` is not a key this Worker's config declares. Did you mean \`declinedBindings\`?`,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
if (record.declinedBindings === undefined || record.declinedBindings === null) {
|
|
345
|
+
return { state: "read", declared: {} };
|
|
346
|
+
}
|
|
347
|
+
const parsed = DeclinedBindings.safeParse(record.declinedBindings);
|
|
348
|
+
if (!parsed.success) {
|
|
349
|
+
return {
|
|
350
|
+
state: "invalid",
|
|
351
|
+
problem: parsed.error.issues
|
|
352
|
+
.map((issue) => `${issue.path.join(".") || "declinedBindings"}: ${issue.message}`)
|
|
353
|
+
.join("; "),
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
return { state: "read", declared: parsed.data };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Read and validate a Worker's `domains` declaration.
|
|
361
|
+
*
|
|
362
|
+
* Parsed rather than cast: `pithy.config.ts` is the adopter's own module, `loadWorkerConfig` imports it
|
|
363
|
+
* live, and duck-typing is the only other gate on it — so an unvalidated `domains` would reach the
|
|
364
|
+
* wrangler generator as whatever they typed. A malformed declaration must fail with the field named,
|
|
365
|
+
* not produce a `routes` entry Cloudflare rejects at deploy.
|
|
366
|
+
*
|
|
367
|
+
* Absent is not an error; it is the ordinary state of a project that has not wired a domain yet.
|
|
368
|
+
*/
|
|
369
|
+
export function loadWorkerDomains(config: WorkerConfig): WorkerDomains | undefined {
|
|
370
|
+
if (config.domains === undefined || config.domains === null) return undefined;
|
|
371
|
+
const parsed = WorkerDomains.safeParse(config.domains);
|
|
372
|
+
if (!parsed.success) {
|
|
373
|
+
throw fromZodError(parsed.error, {
|
|
374
|
+
message: "This Worker's `domains` declaration is not valid.",
|
|
375
|
+
action: "Fix `domains` in the Worker's pithy.config.ts. Each entry is `{ pattern, zone }` — bare hostnames.",
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
return parsed.data;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function isWorkerConfig(value: unknown): value is WorkerConfig {
|
|
382
|
+
return typeof value === "object" && value !== null && Array.isArray((value as WorkerConfig).capabilities);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function isProjectConfig(value: unknown): value is ProjectConfig {
|
|
386
|
+
return typeof value === "object" && value !== null;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Why a `pithy.config.ts` would not import. Three causes an adopter fixes three different ways, plus the
|
|
391
|
+
* honest fourth: the classifier does not recognize this one and will not invent a remedy for it.
|
|
392
|
+
*/
|
|
393
|
+
export type ConfigLoadFailureKind = "unresolved-import" | "parse-error" | "threw-on-load" | "unknown";
|
|
394
|
+
|
|
395
|
+
/** A classified config-load failure: what went wrong, and the one sentence that fits it. */
|
|
396
|
+
export interface ConfigLoadFailure {
|
|
397
|
+
/** Which of the four causes this is. */
|
|
398
|
+
kind: ConfigLoadFailureKind;
|
|
399
|
+
/** The `action` line for the refusal — chosen from the failure, never asserted over it. */
|
|
400
|
+
action: string;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* The message, de-colored, for this classifier's own pattern tests — never for output.
|
|
405
|
+
*
|
|
406
|
+
* What may be *said* is core's {@link safeReason}, and only core's: the filter that decides whether a
|
|
407
|
+
* runtime's string is fit to show lived here, in the capability loaders, and in the vite plugin, in three
|
|
408
|
+
* near-verbatim copies, and the hole #223 found in it had to be closed in all three (#228). What stays
|
|
409
|
+
* here is the policy — which causes this classifier recognizes, and what it says about each.
|
|
410
|
+
*/
|
|
411
|
+
function rawMessage(cause: unknown): string {
|
|
412
|
+
return causeMessage(cause) ?? "";
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function isUnresolvedImport(cause: unknown): boolean {
|
|
416
|
+
const code = prop(cause, "code");
|
|
417
|
+
if (
|
|
418
|
+
code === "ERR_MODULE_NOT_FOUND" ||
|
|
419
|
+
code === "MODULE_NOT_FOUND" ||
|
|
420
|
+
code === "ERR_PACKAGE_PATH_NOT_EXPORTED" ||
|
|
421
|
+
code === "ERR_UNSUPPORTED_DIR_IMPORT"
|
|
422
|
+
) {
|
|
423
|
+
return true;
|
|
424
|
+
}
|
|
425
|
+
if (prop(cause, "name") === "ResolveMessage") return true;
|
|
426
|
+
return /Cannot find (?:package|module) |Failed to resolve (?:import|module)|Failed to load url /.test(
|
|
427
|
+
rawMessage(cause),
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function isParseError(cause: unknown): boolean {
|
|
432
|
+
const name = prop(cause, "name");
|
|
433
|
+
if (name === "SyntaxError" || name === "BuildMessage") return true;
|
|
434
|
+
if (prop(cause, "code") === "PARSE_ERROR") return true;
|
|
435
|
+
// Bun's build wrapper with its diagnostics already dropped — the shape every caller after the first
|
|
436
|
+
// sees, and the one `pithy doctor` renders. It proves a build produced diagnostics, so it is a parse
|
|
437
|
+
// error with no reason and no position rather than a config that threw. See core's `cause.ts`.
|
|
438
|
+
if (isBuildFailureWrapper(cause)) return true;
|
|
439
|
+
return /Transform failed|\[PARSE_ERROR]|Parse (?:error|failure)|Unexpected (?:token|end of input)/.test(
|
|
440
|
+
rawMessage(cause),
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Choose the refusal's `action` **from** the failure rather than asserting one over it (#207).
|
|
446
|
+
*
|
|
447
|
+
* A `pithy.config.ts` that will not import used to get one sentence whatever went wrong: *install the
|
|
448
|
+
* project's dependencies, then check the config for errors*. For a missing dependency that is right. For
|
|
449
|
+
* a stray brace it is confidently wrong, and being confidently wrong is worse than saying nothing —
|
|
450
|
+
* an adopter runs `bun install`, nothing changes, and the parser's own message, which would have told
|
|
451
|
+
* them, was captured into `detail` and discarded one frame up. That is #172 recurring: a config that
|
|
452
|
+
* would not load naming the wrong cause, diagnosed wrong twice before anyone traced the import edge.
|
|
453
|
+
*
|
|
454
|
+
* So each cause gets the sentence that fits it, and the fourth gets none:
|
|
455
|
+
*
|
|
456
|
+
* - **unresolved-import** — the specifier is named, and `bun install` is right *here*, where it was earned.
|
|
457
|
+
* - **parse-error** — the parser's reason and position. It says installing will not help, because the
|
|
458
|
+
* adopter has read the opposite for as long as this refusal has existed.
|
|
459
|
+
* - **threw-on-load** — the config's own error, which is neither of the above.
|
|
460
|
+
* - **unknown** — no remedy. A wrong action is worse than no action, because it is followed.
|
|
461
|
+
*
|
|
462
|
+
* Exported because the runtime that ships (`bin` runs on Bun, whose `ResolveMessage`/`BuildMessage` are
|
|
463
|
+
* their own shapes) is not the runtime the suite runs on. Tested directly, it is tested for both.
|
|
464
|
+
*
|
|
465
|
+
* @param wrapped whatever the `try` caught, wrapper and all. {@link rootCause} opens it first — see #223:
|
|
466
|
+
* Bun wraps two or more build diagnostics in an `AggregateError`, and a stray brace cascades, so the
|
|
467
|
+
* wrapped shape is the *common* one. #207 fixed the bare case and this one classified worse than it.
|
|
468
|
+
*/
|
|
469
|
+
export function classifyConfigLoadFailure(wrapped: unknown): ConfigLoadFailure {
|
|
470
|
+
// Bun hands `import()` failures over inside an `AggregateError`. Classify what is inside it.
|
|
471
|
+
const cause = rootCause(wrapped);
|
|
472
|
+
|
|
473
|
+
if (isUnresolvedImport(cause)) {
|
|
474
|
+
const specifier = unresolvedSpecifier(cause);
|
|
475
|
+
return {
|
|
476
|
+
kind: "unresolved-import",
|
|
477
|
+
action: specifier
|
|
478
|
+
? `Nothing resolves "${specifier}". Install the project's dependencies (bun install), or correct that import.`
|
|
479
|
+
: "An import in the config does not resolve. Install the project's dependencies (bun install), then check its imports.",
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (isParseError(cause)) {
|
|
484
|
+
// Both of these can answer "nothing" — and one of the reasons they can is Bun's build wrapper, whose
|
|
485
|
+
// whole message is a count and a path. That suppression is core's, once, rather than written out here
|
|
486
|
+
// and in two other classifiers that each had to be found and patched (#223, #228).
|
|
487
|
+
const reason = safeReason(cause);
|
|
488
|
+
const at = failurePosition(cause);
|
|
489
|
+
const where = at ? ` Line ${at.line}, column ${at.column}.` : "";
|
|
490
|
+
return {
|
|
491
|
+
kind: "parse-error",
|
|
492
|
+
action: `The config does not parse${reason ? `: ${reason}` : ""}.${where} Fix the file — installing dependencies will not help.`,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Anything that carries a message, whether or not it extends `Error` — the config's own throw.
|
|
497
|
+
if (causeMessage(cause) !== undefined) {
|
|
498
|
+
const reason = safeReason(cause);
|
|
499
|
+
return {
|
|
500
|
+
kind: "threw-on-load",
|
|
501
|
+
action: reason
|
|
502
|
+
? `The config threw while loading: ${reason}. Fix that in the config.`
|
|
503
|
+
: "The config threw while loading. Run the file directly to see what it throws.",
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
return { kind: "unknown", action: "Check pithy.config.ts. Run the file directly to see how it fails." };
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Import a config the current process has **just written**, and get what is on disk rather than what was
|
|
512
|
+
* imported before the write.
|
|
513
|
+
*
|
|
514
|
+
* A module cache keyed on the path is correct almost everywhere and wrong in exactly one place: a command
|
|
515
|
+
* that edits a `pithy.config.ts` and then has to read the result. `pithy add` is that command — it
|
|
516
|
+
* resolves the target Worker (importing its config) before wiring, and its closing migrate imports the
|
|
517
|
+
* config again to build the registry. The second import returned the *pre-wiring* module, so the registry
|
|
518
|
+
* never contained the capability that had just been added and `add` applied none of its migrations while
|
|
519
|
+
* reporting a clean run. `pithy add auth` left a Worker that booted and answered 500 on every auth route,
|
|
520
|
+
* because the tables were not there (#273).
|
|
521
|
+
*
|
|
522
|
+
* **Only a different file busts it.** Measured on Bun 1.3: a `?t=…` query on the file URL does not, and
|
|
523
|
+
* neither does the same file reached by a differently-spelled path. So this imports a copy, beside the
|
|
524
|
+
* original — where the config's own relative imports and `import.meta.dirname` still resolve to the same
|
|
525
|
+
* directory — and removes it. Dot-prefixed and uniquely named, so a crashed run leaves nothing a tool
|
|
526
|
+
* collects and two concurrent runs cannot collide.
|
|
527
|
+
*/
|
|
528
|
+
async function importFreshCopy(path: string): Promise<{ default?: unknown }> {
|
|
529
|
+
const copy = join(dirname(path), `.pithy.reload.${randomUUID()}.ts`);
|
|
530
|
+
await copyFile(path, copy);
|
|
531
|
+
try {
|
|
532
|
+
return (await import(pathToFileURL(copy).href)) as { default?: unknown };
|
|
533
|
+
} finally {
|
|
534
|
+
await rm(copy, { force: true });
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Import a `pithy.config.ts` and return its default export, with an error that names its own cause.
|
|
540
|
+
*
|
|
541
|
+
* `fresh` is for a caller that has written the file in this process — see {@link importFreshCopy}. Every
|
|
542
|
+
* other caller takes the cache, because a config imported twice in one command should be one module.
|
|
543
|
+
*/
|
|
544
|
+
async function importConfig(path: string, missing: () => never, fresh = false): Promise<unknown> {
|
|
545
|
+
try {
|
|
546
|
+
await access(path);
|
|
547
|
+
} catch {
|
|
548
|
+
missing();
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
let module: { default?: unknown };
|
|
552
|
+
try {
|
|
553
|
+
module = fresh ? await importFreshCopy(path) : ((await import(pathToFileURL(path).href)) as { default?: unknown });
|
|
554
|
+
} catch (cause) {
|
|
555
|
+
// The file is present but would not import. Which of the three ways it failed decides what to tell
|
|
556
|
+
// the adopter — see {@link classifyConfigLoadFailure}. The raw cause still goes to `detail` and stops
|
|
557
|
+
// there: the CLI renderer prints `message` and `action` only, and the HTTP codec strips `detail`.
|
|
558
|
+
// That boundary is unchanged. What changed is that the part of the cause an adopter can act on now
|
|
559
|
+
// reaches them through `action`, in a form that carries no path, no source line, and no stack.
|
|
560
|
+
const { kind, action } = classifyConfigLoadFailure(cause);
|
|
561
|
+
throw new InternalError({
|
|
562
|
+
message: `Could not load ${path}.`,
|
|
563
|
+
action,
|
|
564
|
+
detail: `${kind}: ${causeMessage(cause) ?? String(cause)}`,
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
return module.default;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** Options for {@link loadWorkerConfig}. */
|
|
571
|
+
export interface LoadWorkerConfigOptions {
|
|
572
|
+
/**
|
|
573
|
+
* Re-read the file rather than take the module cache. **Only for a caller that has written this config
|
|
574
|
+
* in this process** — `pithy add`'s closing migrate is the one that must, and the one whose absence of
|
|
575
|
+
* it meant a capability's migrations silently never ran (see `importFreshCopy`).
|
|
576
|
+
*/
|
|
577
|
+
fresh?: boolean;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Load one Worker's `apps/<name>/pithy.config.ts` — the capabilities that Worker composes. Imported live
|
|
582
|
+
* (the config is code), so this runs under a TS-capable runtime; Phase 0 ships the bin on Bun.
|
|
583
|
+
*/
|
|
584
|
+
export async function loadWorkerConfig(workerDir: string, options?: LoadWorkerConfigOptions): Promise<WorkerConfig> {
|
|
585
|
+
const path = join(workerDir, "pithy.config.ts");
|
|
586
|
+
const value = await importConfig(
|
|
587
|
+
path,
|
|
588
|
+
() => {
|
|
589
|
+
throw new NotFoundError({
|
|
590
|
+
message: `No pithy.config.ts in ${workerDir}.`,
|
|
591
|
+
action: "Every worker under apps/ needs one. pithy worker add creates it.",
|
|
592
|
+
});
|
|
593
|
+
},
|
|
594
|
+
options?.fresh === true,
|
|
595
|
+
);
|
|
596
|
+
if (!isWorkerConfig(value)) {
|
|
597
|
+
throw new InternalError({
|
|
598
|
+
message: `${path} doesn't default-export a worker config.`,
|
|
599
|
+
action: "Export default { capabilities, app }.",
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
return value;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Load the **root** `pithy.config.ts` — the project's identity and policy. Capabilities are not here; they
|
|
607
|
+
* live per Worker ({@link loadWorkerConfig}).
|
|
608
|
+
*/
|
|
609
|
+
export async function loadProject(projectDir: string): Promise<ProjectConfig> {
|
|
610
|
+
const path = join(projectDir, "pithy.config.ts");
|
|
611
|
+
const value = await importConfig(path, () => {
|
|
612
|
+
throw new NotFoundError({
|
|
613
|
+
message: "No pithy.config.ts here.",
|
|
614
|
+
action: "Run from a Pithy project. pithy init creates one.",
|
|
615
|
+
});
|
|
616
|
+
});
|
|
617
|
+
if (!isProjectConfig(value)) {
|
|
618
|
+
throw new InternalError({
|
|
619
|
+
message: "pithy.config.ts doesn't default-export a config.",
|
|
620
|
+
action: "Export default { name }.",
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
// Validated on every load, so a name that would not survive `CloudflareAccountName` refuses the
|
|
624
|
+
// command that read the config rather than whichever later call happened to build a path out of it.
|
|
625
|
+
// The value is *returned*, never published: an ambient account is what made six call sites resolve
|
|
626
|
+
// credentials before anything had established which account they were for.
|
|
627
|
+
loadProjectCloudflare(value);
|
|
628
|
+
// Same rule, same reason: an environment name reaches Cloudflare resource names verbatim, and a
|
|
629
|
+
// provisioned project cannot be renamed — so a declaration that would not survive the naming rule
|
|
630
|
+
// refuses the command that read the config, rather than the later call that composed a name from it.
|
|
631
|
+
loadProjectEnvironments(value);
|
|
632
|
+
return value;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* The Cloudflare account a project belongs to, loaded from its root config — **the one way a command
|
|
637
|
+
* gets an account to resolve credentials for.**
|
|
638
|
+
*
|
|
639
|
+
* `cloudflareEnv` takes the account as an argument rather than reading an ambient, and this is what
|
|
640
|
+
* every caller passes it. Loading the project is the only way to learn the answer, so the two are one
|
|
641
|
+
* call here: a command cannot resolve credentials "before" the project any more than it can call this
|
|
642
|
+
* function without awaiting it.
|
|
643
|
+
*
|
|
644
|
+
* Throws what {@link loadProject} throws when there is no config here — a command that is inside a
|
|
645
|
+
* project and cannot read its config has a worse problem than which account it is for. A caller that
|
|
646
|
+
* legitimately has no project (`pithy init` before the scaffold exists) passes `null` instead of asking.
|
|
647
|
+
*/
|
|
648
|
+
export async function projectCloudflareAccount(projectDir: string): Promise<ProjectCloudflare | null> {
|
|
649
|
+
return loadProjectCloudflare(await loadProject(projectDir)) ?? null;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** Every capability one Worker composes, in order: libraries first, its app last. */
|
|
653
|
+
export function allCapabilities(config: WorkerConfig): Capability[] {
|
|
654
|
+
return config.app ? [...config.capabilities, config.app] : [...config.capabilities];
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* The project name, leniently guessed. Prefers the explicit `pithy.config.ts` `name`, then the first
|
|
659
|
+
* discovered worker's `wrangler.jsonc` name, then the project directory's own name — always normalized
|
|
660
|
+
* to hyphenated-lowercase. The fallbacks are **not stable**: `discoverWorkers` sorts alphabetically, so
|
|
661
|
+
* adding an app that sorts earlier changes the guess, and the directory basename differs between a
|
|
662
|
+
* worktree checkout and a normal clone. Fine for a cosmetic default; never use this where the name
|
|
663
|
+
* feeds a naming convention another command must reproduce later — use {@link requireProjectName} there.
|
|
664
|
+
*/
|
|
665
|
+
export async function resolveProjectName(config: ProjectConfig, projectDir: string): Promise<string> {
|
|
666
|
+
if (config.name) return kebab(config.name);
|
|
667
|
+
const [worker] = await discoverWorkers(projectDir);
|
|
668
|
+
if (worker) return kebab(worker.name);
|
|
669
|
+
return kebab(basename(projectDir));
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* The project name for the `pithy feature` resource-naming convention — the FIRST SEGMENT of every
|
|
674
|
+
* Cloudflare resource name (`<project>-f<issue>-<slug>-<binding>-<kind>`) and the only key
|
|
675
|
+
* `pithy feature destroy` has to find and delete them again. Unlike {@link resolveProjectName}, this
|
|
676
|
+
* never guesses: it requires an explicit `pithy.config.ts` `name`, because any fallback that can differ
|
|
677
|
+
* between machines or checkouts (an alphabetically-first worker, a worktree's directory basename) would
|
|
678
|
+
* make teardown recompute names that match nothing, delete nothing, and exit 0 — a silent resource leak.
|
|
679
|
+
* Throws an actionable `ValidationError` when `name` is absent.
|
|
680
|
+
*
|
|
681
|
+
* It also holds the name to `assertValidProjectName`, and that is the *second* half of the same guard.
|
|
682
|
+
* `scaffoldProject` keeps a bad name from being created; this keeps an already-created one from getting
|
|
683
|
+
* anywhere. Cloudflare's namespaces disagree about what a legal project segment is — D1, KV, and R2 take
|
|
684
|
+
* a digit-leading name, Worker scripts and Workflows refuse it — so without this check `pithy add` and
|
|
685
|
+
* `pithy migrate` provision real resources and only the first host-worker deploy fails, leaving a
|
|
686
|
+
* half-provisioned project whose only documented fix orphans everything already created. Every command
|
|
687
|
+
* resolves the project through here, so every command refuses on the first one instead.
|
|
688
|
+
*/
|
|
689
|
+
export function requireProjectName(config: ProjectConfig): string {
|
|
690
|
+
if (!config.name) {
|
|
691
|
+
throw new ValidationError({
|
|
692
|
+
message: "pithy.config.ts has no `name`.",
|
|
693
|
+
action:
|
|
694
|
+
"Set `name` in pithy.config.ts. Every feature resource name — and pithy feature destroy's ability to find and delete it later — derives from this name, so it must stay stable across machines and checkouts.",
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
assertValidProjectName(config.name);
|
|
698
|
+
// `kebab` is core's, imported rather than reimplemented: a project name has to normalize identically
|
|
699
|
+
// in every command that composes a resource name, and a second copy here would drift.
|
|
700
|
+
return kebab(config.name);
|
|
701
|
+
}
|