@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,584 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
5
|
+
import { CloudflareClients } from "@pithy-sh/cloudflare/src/client/clients";
|
|
6
|
+
import { PithyError } from "@pithy-sh/core/src/error/pithyError";
|
|
7
|
+
import { readMigrationOwner } from "@pithy-sh/core/src/migrations/owner";
|
|
8
|
+
import { assertValidProjectName, isValidProjectName, kebab } from "@pithy-sh/core/src/naming/resource";
|
|
9
|
+
import { type CloudflareAccountSelection, cloudflareEnv } from "../cloudflare/config";
|
|
10
|
+
import { loadProject, loadProjectCloudflare, type ProjectConfig } from "../project/config";
|
|
11
|
+
import { discoverWorkers, type WorkerTarget } from "../project/workers";
|
|
12
|
+
import { readWranglerConfig } from "../project/wrangler";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Whether the project name in the root `pithy.config.ts` still matches the names this project's resources
|
|
16
|
+
* were provisioned under.
|
|
17
|
+
*
|
|
18
|
+
* **Why this is a health check.** Every provisioned name now leads with the project
|
|
19
|
+
* (`<project>-<env>-<thing>`), and teardown *recomputes* those names rather than scanning a prefix. So one
|
|
20
|
+
* edit to `name` orphans everything: `pithy feature destroy` computes names that no longer exist, finds
|
|
21
|
+
* nothing, and exits 0 while the real D1, KV, buckets, and Worker scripts keep running and keep billing.
|
|
22
|
+
* Provisioning quietly builds a parallel set beside them. Nothing else in the toolchain notices, which is
|
|
23
|
+
* exactly why it belongs in the command whose job is noticing.
|
|
24
|
+
*
|
|
25
|
+
* **A rename is never inferred from a name's shape.** `<app>-<env>-<resource>` is the ordinary Cloudflare
|
|
26
|
+
* naming convention, so *every* database an adopter named that way before they had ever heard of Pithy has
|
|
27
|
+
* Pithy's shape. Reading a project segment off one name and calling the difference a rename told adopters
|
|
28
|
+
* on the migrate-an-existing-Worker-into-Pithy path — the adoption path — that their live production
|
|
29
|
+
* database was an orphan, and advised deleting it. Shape narrows the candidates. It establishes nothing.
|
|
30
|
+
*
|
|
31
|
+
* Two kinds of evidence do, and they are ranked:
|
|
32
|
+
*
|
|
33
|
+
* 1. **Proof — Pithy's own owner stamp.** `pithy migrate` (and every other writing command) records the
|
|
34
|
+
* project in `pithy_migrations_owner` on the database itself. A live database whose stamp names a
|
|
35
|
+
* *different* project than the config is proof: Pithy made that database, under that name, for this
|
|
36
|
+
* codebase. Nothing about it is inferred. Only proof reaches `orphaned`, and even then the remedy never
|
|
37
|
+
* says "delete" — the stamp proves one database's provenance, not the whole list's.
|
|
38
|
+
* 2. **Deduction — the wiring contradicts the config wholesale.** The stamp only covers databases Pithy has
|
|
39
|
+
* migrated, so it narrows rather than replaces the local check. The project name is **one string**: a
|
|
40
|
+
* rename moves every derived name at once, so it cannot leave some names on the new name. That makes the
|
|
41
|
+
* signature checkable from files alone — *every* declared name leads with one and the same other project,
|
|
42
|
+
* and the configured name appears nowhere. One odd name out is a resource the adopter brought with them;
|
|
43
|
+
* the whole wiring disagreeing is the config and the wiring contradicting each other, which is a fault
|
|
44
|
+
* whoever caused it. That is {@link wholesaleRename}, and it is what `drifted` means now.
|
|
45
|
+
*
|
|
46
|
+
* The account is still asked whether a name is live, but existence is *not* evidence of ownership: a
|
|
47
|
+
* database that exists under a name the adopter chose is just their database. It only colors the report.
|
|
48
|
+
*/
|
|
49
|
+
export type ProjectNameState =
|
|
50
|
+
/**
|
|
51
|
+
* The root `pithy.config.ts` loaded and carries no `name`. Nothing to reconcile; never fails the exit.
|
|
52
|
+
*
|
|
53
|
+
* **Only that case.** It used to cover a missing config file too, and the one message the two shared —
|
|
54
|
+
* "add `name` to pithy.config.ts" — was written for this branch and wrong for the other: it named a key
|
|
55
|
+
* in a file that did not exist, in a directory with no project to name. A config with no `name` is now
|
|
56
|
+
* the rare hand-edited case (`pithy init` always writes one), and it is the only case this answers.
|
|
57
|
+
* When the config cannot be read at all, {@link checkProjectName} returns `null` instead.
|
|
58
|
+
*/
|
|
59
|
+
| "unconfigured"
|
|
60
|
+
/**
|
|
61
|
+
* A `name` is set and no Cloudflare namespace could carry it — {@link isValidProjectName} refuses it.
|
|
62
|
+
*
|
|
63
|
+
* Distinct from `unconfigured` on purpose, and it **fails the exit**. Collapsing the two reported an
|
|
64
|
+
* illegal name as "not set", which told the operator to add a name they had already added while
|
|
65
|
+
* `pithy add`, `pithy migrate`, `pithy secrets provision`, and `pithy token mint` were all hard-failing
|
|
66
|
+
* on that same project — and left a CI gate on `pithy doctor` green. Unlike `unconfigured` and
|
|
67
|
+
* `could-not-check` this is a positively established fault, which is the standard the failing states
|
|
68
|
+
* are held to.
|
|
69
|
+
*/
|
|
70
|
+
| "invalid"
|
|
71
|
+
/** Every provisioned name this project's wiring declares leads with the configured project. */
|
|
72
|
+
| "ok"
|
|
73
|
+
/** The wiring could not be read (no worker declares a name, or a `wrangler.jsonc` would not parse). Never fails the exit. */
|
|
74
|
+
| "could-not-check"
|
|
75
|
+
/**
|
|
76
|
+
* The wiring contradicts the config wholesale — every declared name leads with one other project and the
|
|
77
|
+
* configured name appears nowhere ({@link wholesaleRename}). Established from local files alone, and it
|
|
78
|
+
* claims nothing about who owns what: the two sources of truth in this repo disagree, and one is wrong.
|
|
79
|
+
*/
|
|
80
|
+
| "drifted"
|
|
81
|
+
/**
|
|
82
|
+
* Proven: a live database carries Pithy's own `pithy_migrations_owner` stamp naming a project other than
|
|
83
|
+
* the configured one. Pithy made it, under that name, and the configured name will never find it again.
|
|
84
|
+
*/
|
|
85
|
+
| "orphaned";
|
|
86
|
+
|
|
87
|
+
/** One resource whose declared name leads with a project segment that is not the configured one. */
|
|
88
|
+
export interface MisnamedResource {
|
|
89
|
+
/** The resource name exactly as `wrangler.jsonc` declares it. */
|
|
90
|
+
name: string;
|
|
91
|
+
/** The project segment that name leads with — a *candidate* old name until something proves it. */
|
|
92
|
+
project: string;
|
|
93
|
+
/** The kind of Cloudflare resource: the two whose names live in `wrangler.jsonc`. */
|
|
94
|
+
kind: "d1" | "r2";
|
|
95
|
+
/** The Worker whose `wrangler.jsonc` declares it, so the fix has an address. */
|
|
96
|
+
worker: string;
|
|
97
|
+
/** The environment stanza it sits in — `dev` for the top-level one. */
|
|
98
|
+
env: string;
|
|
99
|
+
/** The Worker env binding it is bound to. */
|
|
100
|
+
binding: string;
|
|
101
|
+
/**
|
|
102
|
+
* Whether the account holds a resource by this name — `null` when the account was not consulted or would
|
|
103
|
+
* not answer. Existence is context, never evidence of ownership: a live database under a name the adopter
|
|
104
|
+
* chose is simply theirs. Tri-state on purpose, because "I did not find out" is not "it is not there".
|
|
105
|
+
*/
|
|
106
|
+
provisioned: boolean | null;
|
|
107
|
+
/**
|
|
108
|
+
* The project recorded in this database's `pithy_migrations_owner` stamp, or `null` when there is none to
|
|
109
|
+
* read (an R2 bucket, a database Pithy never migrated, an unreachable account). This is the only field
|
|
110
|
+
* that ever *proves* ownership — `owner === project` means Pithy made this resource under that name.
|
|
111
|
+
*/
|
|
112
|
+
owner: string | null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** What `doctor` learned about the configured project name. */
|
|
116
|
+
export interface ProjectNameCheck {
|
|
117
|
+
state: ProjectNameState;
|
|
118
|
+
/**
|
|
119
|
+
* The configured project name, or `null` when there is none to report. Kebabed — the form every
|
|
120
|
+
* resource name is composed from — except under `invalid`, where it is the raw configured value: an
|
|
121
|
+
* illegal name is never composed with, and the operator has to recognize the string they typed.
|
|
122
|
+
*/
|
|
123
|
+
project: string | null;
|
|
124
|
+
/** Every declared resource name leading with a different project segment. Empty unless drifted or orphaned. */
|
|
125
|
+
misnamed: MisnamedResource[];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** One resource name a Worker's `wrangler.jsonc` declares, with the environment and binding it belongs to. */
|
|
129
|
+
export interface DeclaredResource {
|
|
130
|
+
/** The declared `database_name` / `bucket_name`. */
|
|
131
|
+
name: string;
|
|
132
|
+
/** Which kind of entry it came from. */
|
|
133
|
+
kind: "d1" | "r2";
|
|
134
|
+
/** The Worker that declares it. */
|
|
135
|
+
worker: string;
|
|
136
|
+
/** The environment stanza — `dev` for the top-level one. */
|
|
137
|
+
env: string;
|
|
138
|
+
/** The binding the entry is bound to. */
|
|
139
|
+
binding: string;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The `wrangler.jsonc` keys this reads. Only the two entry kinds that carry a resource *name*. */
|
|
143
|
+
interface NamedBindings {
|
|
144
|
+
d1_databases?: { binding?: string; database_name?: string }[];
|
|
145
|
+
r2_buckets?: { binding?: string; bucket_name?: string }[];
|
|
146
|
+
env?: Record<string, NamedBindings | undefined>;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Every environment's stanza: the top-level one (the dev environment) plus each `env.<name>`. */
|
|
150
|
+
function envStanzas(config: NamedBindings): { env: string; stanza: NamedBindings }[] {
|
|
151
|
+
const list: { env: string; stanza: NamedBindings }[] = [{ env: "dev", stanza: config }];
|
|
152
|
+
for (const [env, stanza] of Object.entries(config.env ?? {})) {
|
|
153
|
+
if (stanza) list.push({ env, stanza });
|
|
154
|
+
}
|
|
155
|
+
return list;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Pull the named entries out of one stanza. An entry with no binding or no name declares nothing. */
|
|
159
|
+
function stanzaResources(stanza: NamedBindings, worker: string, env: string): DeclaredResource[] {
|
|
160
|
+
const found: DeclaredResource[] = [];
|
|
161
|
+
for (const entry of stanza.d1_databases ?? []) {
|
|
162
|
+
if (entry.binding && entry.database_name) {
|
|
163
|
+
found.push({ name: entry.database_name, kind: "d1", worker, env, binding: entry.binding });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
for (const entry of stanza.r2_buckets ?? []) {
|
|
167
|
+
if (entry.binding && entry.bucket_name) {
|
|
168
|
+
found.push({ name: entry.bucket_name, kind: "r2", worker, env, binding: entry.binding });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return found;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** What {@link declaredResources} found, and whether it managed to read everything it tried to. */
|
|
175
|
+
export interface DeclaredResources {
|
|
176
|
+
/** Every named D1 database and R2 bucket declared across every Worker and every environment. */
|
|
177
|
+
resources: DeclaredResource[];
|
|
178
|
+
/**
|
|
179
|
+
* True when the wiring could not be read in full: the Worker set would not enumerate (a
|
|
180
|
+
* `pithy.worker.jsonc` that will not parse), or at least one Worker's `wrangler.jsonc` would not.
|
|
181
|
+
*/
|
|
182
|
+
unreadable: boolean;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Every resource name this project's Workers declare, across every environment.
|
|
187
|
+
*
|
|
188
|
+
* Only D1 and R2 appear, because they are the only entries wrangler gives a name field
|
|
189
|
+
* (`database_name`, `bucket_name`). A `kv_namespaces` entry has no title, so a KV namespace's name lives
|
|
190
|
+
* only in the account and there is nothing local to compare it against.
|
|
191
|
+
*
|
|
192
|
+
* An unreadable `wrangler.jsonc` is recorded rather than thrown: the other Workers still have something
|
|
193
|
+
* to say, and a partial answer plus "I could not read all of it" beats a diagnostic that gives up. A
|
|
194
|
+
* Worker that has **no** `wrangler.jsonc` is skipped outright and is not unreadable — `apps/` also holds
|
|
195
|
+
* non-Worker processes (a Vite frontend joins the dev set through `pithy.worker.jsonc` alone), and
|
|
196
|
+
* reporting a healthy project as unchecked because of one is worse than saying nothing about it.
|
|
197
|
+
*
|
|
198
|
+
* Discovery itself gets the same treatment. `discoverWorkers` parses every `pithy.worker.jsonc` and
|
|
199
|
+
* **throws** on one that will not parse or will not validate — correct for `dev` and `deploy`, where a
|
|
200
|
+
* typo'd manifest must stop the run, and exactly wrong here: `doctor` exists to work in the broken
|
|
201
|
+
* project. So a failed enumeration is the same answer as an unreadable Worker, just for all of them.
|
|
202
|
+
*/
|
|
203
|
+
export async function declaredResources(projectDir: string): Promise<DeclaredResources> {
|
|
204
|
+
let workers: WorkerTarget[];
|
|
205
|
+
try {
|
|
206
|
+
workers = await discoverWorkers(projectDir);
|
|
207
|
+
} catch {
|
|
208
|
+
return { resources: [], unreadable: true };
|
|
209
|
+
}
|
|
210
|
+
const resources: DeclaredResource[] = [];
|
|
211
|
+
let unreadable = false;
|
|
212
|
+
for (const worker of workers) {
|
|
213
|
+
if (!worker.hasWrangler) continue;
|
|
214
|
+
let config: NamedBindings;
|
|
215
|
+
try {
|
|
216
|
+
config = (await readWranglerConfig(worker.dir)) as NamedBindings;
|
|
217
|
+
} catch {
|
|
218
|
+
unreadable = true;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
for (const { env, stanza } of envStanzas(config)) {
|
|
222
|
+
resources.push(...stanzaResources(stanza, worker.name, env));
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return { resources, unreadable };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The project segment of a declared name, when that name is one Pithy's rule *could* have produced for this
|
|
230
|
+
* binding in this environment — otherwise `null`.
|
|
231
|
+
*
|
|
232
|
+
* The suffix is the discriminator: `<env>-<binding>`, kebab-cased, is exactly what the naming rule puts
|
|
233
|
+
* after the project, so a name ending in it has Pithy's shape and everything before it reads as a project
|
|
234
|
+
* segment. **Shape is a filter, never a finding.** `<app>-<env>-<resource>` is also the ordinary Cloudflare
|
|
235
|
+
* convention every adopter already uses, so this says only "that name is a candidate" — never "Pithy made
|
|
236
|
+
* it". A caller that treats a non-null return as a rename is the bug this comment used to describe as the
|
|
237
|
+
* fix. See {@link wholesaleRename} and the owner stamp for what actually establishes one.
|
|
238
|
+
*/
|
|
239
|
+
export function projectSegmentOf(name: string, env: string, binding: string): string | null {
|
|
240
|
+
const suffix = `-${kebab(env)}-${kebab(binding)}`;
|
|
241
|
+
if (!name.endsWith(suffix)) return null;
|
|
242
|
+
const project = name.slice(0, -suffix.length);
|
|
243
|
+
return project.length > 0 ? project : null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Whether a declared name leads with this project — the whole leading segment, and nothing about the rest.
|
|
248
|
+
*
|
|
249
|
+
* The counterpart to {@link projectSegmentOf}, and deliberately not it. That function answers "could Pithy's
|
|
250
|
+
* rule have produced this exact name for this binding", which is the right filter for picking *candidates*
|
|
251
|
+
* and the wrong one for asking whether the configured project appears at all: **several Pithy-provisioned
|
|
252
|
+
* names put the capability in the thing slot rather than the binding.**
|
|
253
|
+
* `resourceNames(project).env(env).r2("storage")` composes `acme-prod-storage` for the `STORAGE_BUCKET`
|
|
254
|
+
* binding; media and support do the same. Asked through the strict tail, a correctly named bucket Pithy made
|
|
255
|
+
* itself reads as no evidence at all, and {@link wholesaleRename} fires on the adoption path it exists to
|
|
256
|
+
* protect.
|
|
257
|
+
*
|
|
258
|
+
* A whole segment, not a prefix: `acmecorp-prod-db` does not lead with `acme`.
|
|
259
|
+
*/
|
|
260
|
+
function leadsWithProject(name: string, project: string): boolean {
|
|
261
|
+
return name === project || name.startsWith(`${project}-`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** A candidate before the account has said anything about it: shape only, no evidence attached. */
|
|
265
|
+
export type MisnamedCandidate = Omit<MisnamedResource, "provisioned" | "owner">;
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Every declared resource whose name has Pithy's shape but leads with a project segment that is not ours.
|
|
269
|
+
*
|
|
270
|
+
* These are **candidates**, not findings. Most projects with one legacy database have exactly one, and it is
|
|
271
|
+
* not a rename. {@link checkProjectName} decides.
|
|
272
|
+
*/
|
|
273
|
+
export function misnamedResources(project: string, declared: DeclaredResource[]): MisnamedCandidate[] {
|
|
274
|
+
const ours = kebab(project);
|
|
275
|
+
const found: MisnamedCandidate[] = [];
|
|
276
|
+
for (const resource of declared) {
|
|
277
|
+
const segment = projectSegmentOf(resource.name, resource.env, resource.binding);
|
|
278
|
+
if (segment === null || segment === ours) continue;
|
|
279
|
+
found.push({
|
|
280
|
+
name: resource.name,
|
|
281
|
+
project: segment,
|
|
282
|
+
kind: resource.kind,
|
|
283
|
+
worker: resource.worker,
|
|
284
|
+
env: resource.env,
|
|
285
|
+
binding: resource.binding,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
return found;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Whether the wiring contradicts the config *wholesale* — the one shape a rename can leave.
|
|
293
|
+
*
|
|
294
|
+
* The project name is a single string. Change it and every name derived from it moves together, so a rename
|
|
295
|
+
* cannot leave some declared names on the new name and some on the old. Three conditions follow, and all
|
|
296
|
+
* three are checked against local files only:
|
|
297
|
+
*
|
|
298
|
+
* - **The configured name appears nowhere.** One resource declared under the configured project settles it:
|
|
299
|
+
* this project provisions under its current name, so the odd names out are things brought in from
|
|
300
|
+
* somewhere else, not stragglers. (A rename followed by re-provisioning does produce a mix — that case is
|
|
301
|
+
* the owner stamp's, which is the only thing that can tell the two apart.) Asked through
|
|
302
|
+
* {@link leadsWithProject} rather than the strict shape: the question is whether the project *leads* any
|
|
303
|
+
* declared name, and a bucket named `<project>-<env>-storage` for a `STORAGE_BUCKET` binding is the
|
|
304
|
+
* configured name appearing, however little it looks like `<env>-<binding>`.
|
|
305
|
+
* - **Exactly one foreign segment.** Two would be two renames, which one string cannot have done.
|
|
306
|
+
* - **More than one distinct name carries it.** This is the concession, and it is deliberate: a single
|
|
307
|
+
* `<something>-<env>-<binding>` is indistinguishable from the Cloudflare convention, and calling one name
|
|
308
|
+
* a rename is precisely the false positive that reported an adopter's production database as an orphan.
|
|
309
|
+
* Deduped by name, because two Workers sharing a binding declare one resource twice, not two resources.
|
|
310
|
+
*/
|
|
311
|
+
export function wholesaleRename(
|
|
312
|
+
project: string,
|
|
313
|
+
declared: DeclaredResource[],
|
|
314
|
+
candidates: MisnamedCandidate[],
|
|
315
|
+
): boolean {
|
|
316
|
+
if (candidates.length === 0) return false;
|
|
317
|
+
const ours = kebab(project);
|
|
318
|
+
for (const resource of declared) {
|
|
319
|
+
if (leadsWithProject(resource.name, ours)) return false;
|
|
320
|
+
}
|
|
321
|
+
if (new Set(candidates.map((entry) => entry.project)).size !== 1) return false;
|
|
322
|
+
return new Set(candidates.map((entry) => entry.name)).size >= 2;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** What the account could be made to say about one candidate name. Absent from the map means "unknown". */
|
|
326
|
+
export interface AccountEvidence {
|
|
327
|
+
/** A resource by this exact name exists in the account. Context for the report, never proof of ownership. */
|
|
328
|
+
exists: boolean;
|
|
329
|
+
/** The project in this database's `pithy_migrations_owner` stamp — the one authoritative ownership record. */
|
|
330
|
+
owner: string | null;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* The account seam: per-name evidence for the candidates, keyed by resource name. A name with no entry was
|
|
335
|
+
* never established either way (no credentials, an unreachable account, a listing that was refused), which
|
|
336
|
+
* is reported as unknown rather than as absence.
|
|
337
|
+
*
|
|
338
|
+
* **The account is on the seam, not behind it (#234).** A stub that took only the candidates would let the
|
|
339
|
+
* real implementation keep a defaulted account and no test would ever notice, which is exactly the state
|
|
340
|
+
* this replaced.
|
|
341
|
+
*/
|
|
342
|
+
export type AccountProbe = (
|
|
343
|
+
candidates: MisnamedCandidate[],
|
|
344
|
+
account: CloudflareAccountSelection | null,
|
|
345
|
+
) => Promise<Map<string, AccountEvidence>>;
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* The recorded owner of a database, read through the one read-only seam `@pithy-sh/core` exposes.
|
|
349
|
+
*
|
|
350
|
+
* Never throws and never invents: an unstamped database, a database whose bookkeeping table has never been
|
|
351
|
+
* created, or a token without query permission all come back `null` — absence of proof, which keeps the
|
|
352
|
+
* verdict on the local deduction instead of escalating it.
|
|
353
|
+
*/
|
|
354
|
+
async function ownerStampOf(clients: CloudflareClients, databaseId: string): Promise<string | null> {
|
|
355
|
+
try {
|
|
356
|
+
const owner = await readMigrationOwner(clients.d1(databaseId) as unknown as D1Database);
|
|
357
|
+
return owner?.project ?? null;
|
|
358
|
+
} catch {
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Ask the account about the candidates: does a resource by that name exist, and — for D1, the only kind
|
|
365
|
+
* that records it — which project stamped it. Never throws; a failed listing simply leaves those names out
|
|
366
|
+
* of the map.
|
|
367
|
+
*/
|
|
368
|
+
export async function probeAccountEvidence(
|
|
369
|
+
candidates: MisnamedCandidate[],
|
|
370
|
+
/**
|
|
371
|
+
* The Cloudflare account this project belongs to, from its own root `pithy.config.ts`. The verdict this
|
|
372
|
+
* function can reach — `orphaned`, "a live database is not yours" — is one no adopter should ever read
|
|
373
|
+
* off the wrong account (#206).
|
|
374
|
+
*
|
|
375
|
+
* **Required, and ahead of the `connect` seam, because it used to be `= null` behind it (#234).** A
|
|
376
|
+
* default parameter is the quietest of the three shapes an optional account can take: the `null` is
|
|
377
|
+
* written at the declaration, so it reads like a decision somebody made, while every call site says
|
|
378
|
+
* nothing at all. And the failure it produces is not a failed call — it is a *sentence*. A project on
|
|
379
|
+
* `cloudflare.accountName` got the default account's credentials here, found no database by its own
|
|
380
|
+
* name in a tenant that was never asked, and the deduction it fed says the adopter's live production
|
|
381
|
+
* database belongs to someone else. Wrong credentials that refuse are a bad afternoon; wrong
|
|
382
|
+
* credentials that answer confidently are the bug this parameter exists to make unwritable.
|
|
383
|
+
*/
|
|
384
|
+
account: CloudflareAccountSelection | null,
|
|
385
|
+
/**
|
|
386
|
+
* How the account is reached. Injectable for the same reason `probeAccount` is one level up, but a level
|
|
387
|
+
* lower: stubbing `probeAccount` replaces this whole function, so the branching *inside* it — which kinds
|
|
388
|
+
* get probed, how a missing database differs from an unreadable listing, whether a stamp is read at all —
|
|
389
|
+
* had no test at all. It is the only path that can reach the `orphaned` verdict, and that verdict tells
|
|
390
|
+
* an adopter a live database is not theirs.
|
|
391
|
+
*/
|
|
392
|
+
connect: (credentials: { accountId: string; apiToken: string }) => CloudflareClients = (credentials) =>
|
|
393
|
+
new CloudflareClients(credentials),
|
|
394
|
+
): Promise<Map<string, AccountEvidence>> {
|
|
395
|
+
const evidence = new Map<string, AccountEvidence>();
|
|
396
|
+
// **A pin the credentials contradict ends the probe, before the network.** `cloudflareEnv` throws on a
|
|
397
|
+
// mismatch, which is right everywhere it is a command's own resolution — but this is a diagnostic, and a
|
|
398
|
+
// `pithy doctor` that exits on the fault it exists to report tells nobody anything. So the refusal is
|
|
399
|
+
// kept and the throw is not: there is no account this run is entitled to ask, so it asks none and
|
|
400
|
+
// establishes nothing, which leaves the verdict on the local deduction and out of reach of `orphaned`.
|
|
401
|
+
// The mismatch itself is already a line of this same report — `Cloudflare:`, `account_mismatch` — and
|
|
402
|
+
// one fact belongs in one line, which is the rule the rest of this file is written to.
|
|
403
|
+
let vars: Record<string, string>;
|
|
404
|
+
try {
|
|
405
|
+
vars = cloudflareEnv({ account });
|
|
406
|
+
} catch {
|
|
407
|
+
return evidence;
|
|
408
|
+
}
|
|
409
|
+
const accountId = vars.CLOUDFLARE_ACCOUNT_ID ?? "";
|
|
410
|
+
const apiToken = vars.CLOUDFLARE_API_TOKEN ?? "";
|
|
411
|
+
if (!accountId || !apiToken) return evidence;
|
|
412
|
+
|
|
413
|
+
const clients = connect({ accountId, apiToken });
|
|
414
|
+
const databases = new Set(candidates.filter((entry) => entry.kind === "d1").map((entry) => entry.name));
|
|
415
|
+
const buckets = new Set(candidates.filter((entry) => entry.kind === "r2").map((entry) => entry.name));
|
|
416
|
+
|
|
417
|
+
if (databases.size > 0) {
|
|
418
|
+
try {
|
|
419
|
+
const live = new Map((await clients.d1Provisioner().listDatabases()).map((db) => [db.name, db.uuid]));
|
|
420
|
+
for (const name of databases) {
|
|
421
|
+
const id = live.get(name);
|
|
422
|
+
evidence.set(name, { exists: id !== undefined, owner: id ? await ownerStampOf(clients, id) : null });
|
|
423
|
+
}
|
|
424
|
+
} catch {
|
|
425
|
+
// Unreachable or unauthorized. The names stay unknown — the verdict falls back to the local deduction.
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
if (buckets.size > 0) {
|
|
429
|
+
try {
|
|
430
|
+
const live = new Set((await clients.r2Provisioner().listBuckets()).map((bucket) => bucket.name));
|
|
431
|
+
for (const name of buckets) evidence.set(name, { exists: live.has(name), owner: null });
|
|
432
|
+
} catch {
|
|
433
|
+
// Same. R2 carries no stamp either way, so a bucket is only ever context.
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return evidence;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Injectable seams for {@link checkProjectName} — the account probe, so a unit test never reaches one. */
|
|
440
|
+
export interface CheckProjectNameOptions {
|
|
441
|
+
/** Account-evidence seam; defaults to {@link probeAccountEvidence}. */
|
|
442
|
+
probeAccount?: AccountProbe;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Probe the configured project name against what this project's wiring declares and what the account
|
|
447
|
+
* holds.
|
|
448
|
+
*
|
|
449
|
+
* Never throws: a diagnostic command has to keep working in exactly the broken environment it exists to
|
|
450
|
+
* diagnose, so every failure becomes a state rather than an exception. The account is consulted only when
|
|
451
|
+
* there is a candidate to ask about — a healthy project pays for no network call at all.
|
|
452
|
+
*
|
|
453
|
+
* **The verdict is evidence-ranked, and shape is not evidence.** Proof first: a candidate database whose
|
|
454
|
+
* `pithy_migrations_owner` stamp names its own segment was made by Pithy under that name, which is
|
|
455
|
+
* `orphaned` and is the only state that says a resource is this project's. Then the local deduction: a
|
|
456
|
+
* {@link wholesaleRename}, which is `drifted` and says only that the config and the wiring contradict each
|
|
457
|
+
* other. Anything less — one legacy name, a mix of names, two unrelated foreign projects — is `ok`. It has
|
|
458
|
+
* to be: the adopter who migrates an existing Worker in has all three, and none of them is a rename.
|
|
459
|
+
*
|
|
460
|
+
* **`requireProjectName`'s two refusals are taken apart rather than caught together.** It throws both for
|
|
461
|
+
* an absent `name` and for a present-but-illegal one, and one `catch` around it turned the second into the
|
|
462
|
+
* first — an illegal name reported as "not set", exiting 0, in the one command whose job is to name that
|
|
463
|
+
* problem. So the config is read directly and the two are branched, through the same seams
|
|
464
|
+
* `requireProjectName` itself uses ({@link isValidProjectName}, {@link kebab}) so no second rule exists here
|
|
465
|
+
* to drift from it.
|
|
466
|
+
*
|
|
467
|
+
* **`null` means the question does not arise here.** There is a configured project name to check only if
|
|
468
|
+
* the root `pithy.config.ts` could be read; when it could not — absent, or present and unloadable — this
|
|
469
|
+
* declines to answer rather than inventing a verdict. Its caller already reports *which* of the two it was
|
|
470
|
+
* (`doctor`'s `Project:` block: "no pithy.config.ts here", or "could not load — …"), and a second line
|
|
471
|
+
* restating that fact in different words is how the message this replaced came to advise adding a key to a
|
|
472
|
+
* file that did not exist. One fact, one line, in the block whose job it is.
|
|
473
|
+
*/
|
|
474
|
+
export async function checkProjectName(
|
|
475
|
+
projectDir: string,
|
|
476
|
+
options: CheckProjectNameOptions = {},
|
|
477
|
+
): Promise<ProjectNameCheck | null> {
|
|
478
|
+
let config: ProjectConfig;
|
|
479
|
+
try {
|
|
480
|
+
config = await loadProject(projectDir);
|
|
481
|
+
} catch {
|
|
482
|
+
return null;
|
|
483
|
+
}
|
|
484
|
+
const configured = config.name;
|
|
485
|
+
if (!configured) return { state: "unconfigured", project: null, misnamed: [] };
|
|
486
|
+
if (!isValidProjectName(configured)) return { state: "invalid", project: configured, misnamed: [] };
|
|
487
|
+
const project = kebab(configured);
|
|
488
|
+
|
|
489
|
+
const { resources, unreadable } = await declaredResources(projectDir);
|
|
490
|
+
const nothingToSay: ProjectNameCheck = {
|
|
491
|
+
state: unreadable ? "could-not-check" : "ok",
|
|
492
|
+
project,
|
|
493
|
+
misnamed: [],
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
const candidates = misnamedResources(project, resources);
|
|
497
|
+
if (candidates.length === 0) return nothingToSay;
|
|
498
|
+
|
|
499
|
+
// The account comes from the config already loaded above — the same value `projectCloudflareAccount`
|
|
500
|
+
// returns, without a second import of the adopter's `pithy.config.ts`. A project naming none passes
|
|
501
|
+
// `null`, which is a claim about this project rather than an omission (#234).
|
|
502
|
+
const account = loadProjectCloudflare(config) ?? null;
|
|
503
|
+
const evidence = await (options.probeAccount ?? probeAccountEvidence)(candidates, account);
|
|
504
|
+
const misnamed: MisnamedResource[] = candidates.map((entry) => {
|
|
505
|
+
const found = evidence.get(entry.name);
|
|
506
|
+
return { ...entry, provisioned: found ? found.exists : null, owner: found?.owner ?? null };
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
// Proof. Only a stamp that names the candidate's own segment says Pithy made it under that name, and
|
|
510
|
+
// only the segments that were proven are reported — a second foreign project in the same wiring is not
|
|
511
|
+
// implicated by the first one's stamp.
|
|
512
|
+
const proven = new Set(misnamed.filter((entry) => entry.owner === entry.project).map((entry) => entry.project));
|
|
513
|
+
if (proven.size > 0) {
|
|
514
|
+
return { state: "orphaned", project, misnamed: misnamed.filter((entry) => proven.has(entry.project)) };
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Deduction. Nothing here claims a resource is ours; it says the two local sources of truth disagree.
|
|
518
|
+
// Only on a *complete* read of the wiring: the deduction turns on the configured name appearing nowhere,
|
|
519
|
+
// and a `wrangler.jsonc` that would not parse is exactly where it might have been. Proof above survives a
|
|
520
|
+
// partial read — a stamp is a fact about one database — but an inference drawn from missing files is not
|
|
521
|
+
// one, and `could-not-check` is the honest answer.
|
|
522
|
+
if (!unreadable && wholesaleRename(project, resources, candidates)) {
|
|
523
|
+
return { state: "drifted", project, misnamed };
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
return nothingToSay;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** The old project name every mismatch leads with, deduplicated and joined — usually exactly one. */
|
|
530
|
+
function formerProjects(check: ProjectNameCheck): string {
|
|
531
|
+
return [...new Set(check.misnamed.map((entry) => entry.project))].sort((a, b) => a.localeCompare(b)).join(", ");
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Why {@link isValidProjectName} refused this name, in the words of the rule itself.
|
|
536
|
+
*
|
|
537
|
+
* Asked of `assertValidProjectName` rather than restated, so the sentence stays true as that rule grows —
|
|
538
|
+
* a second copy here would keep describing the charset long after another constraint started doing the
|
|
539
|
+
* refusing, and doctor would name a rule the name does not break.
|
|
540
|
+
*/
|
|
541
|
+
function projectNameProblem(name: string): string {
|
|
542
|
+
try {
|
|
543
|
+
assertValidProjectName(name);
|
|
544
|
+
return `"${name}" can't be a project name.`; // unreachable while the state is set by the same rule
|
|
545
|
+
} catch (error) {
|
|
546
|
+
if (!(error instanceof PithyError)) throw error;
|
|
547
|
+
return `${error.payload.message} ${error.payload.action ?? ""}`.trim();
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** The one-line report for `renderDoctorText`, and the `action` a failing state should prompt. */
|
|
552
|
+
export function describeProjectName(check: ProjectNameCheck): string {
|
|
553
|
+
switch (check.state) {
|
|
554
|
+
case "ok":
|
|
555
|
+
return `${check.project} — every resource name matches`;
|
|
556
|
+
case "unconfigured":
|
|
557
|
+
return "not set (add `name` to pithy.config.ts — every resource name derives from it)";
|
|
558
|
+
case "invalid":
|
|
559
|
+
return `${projectNameProblem(check.project ?? "")} Nothing can be provisioned until it changes — pithy add, migrate, secrets, and token all refuse it.`;
|
|
560
|
+
case "could-not-check":
|
|
561
|
+
return "could not read every worker's wrangler.jsonc — nothing to compare the name against";
|
|
562
|
+
// Says only what the files say: every name this project declares belongs to another project. It does
|
|
563
|
+
// not claim those resources are ours — nothing has established that — so it names both readings of the
|
|
564
|
+
// contradiction and asks for one of them, and it never suggests deleting anything.
|
|
565
|
+
case "drifted": {
|
|
566
|
+
// Plural by construction — `wholesaleRename` needs two distinct names before it will say anything.
|
|
567
|
+
// Counted off `misnamed`, and phrased to claim exactly that: these are the names that lead with the
|
|
568
|
+
// other project, not the whole declared set. The wiring can also hold names Pithy's rule could never
|
|
569
|
+
// have produced — a hand-picked `legacy_store` is neither a candidate nor the configured name — so
|
|
570
|
+
// "all N resource names this project declares" was a number the check had not established.
|
|
571
|
+
const count = new Set(check.misnamed.map((entry) => entry.name)).size;
|
|
572
|
+
return `${count} resource names this project declares lead with "${formerProjects(check)}", and none leads with ${check.project} — pithy would provision a second set beside them; rename the project, or rename the resources`;
|
|
573
|
+
}
|
|
574
|
+
// Proven, and still no deletion advice: the stamp establishes one database's provenance, not the whole
|
|
575
|
+
// list's, and telling an adopter to delete a live database on anything less than certainty is the
|
|
576
|
+
// failure mode this guards. The action covers both readings of a foreign stamp — a rename to undo, or a
|
|
577
|
+
// binding pointed at another project's database, which `claimMigrationOwnership` refuses either way.
|
|
578
|
+
case "orphaned": {
|
|
579
|
+
const count = check.misnamed.filter((entry) => entry.owner === entry.project).length;
|
|
580
|
+
const subject = count === 1 ? "1 resource is" : `${count} resources are`;
|
|
581
|
+
return `${subject} stamped "${formerProjects(check)}" by pithy migrate, not ${check.project} — ${check.project} will never find ${count === 1 ? "it" : "them"} again; rename the project back, or move the data onto resources ${check.project} owns`;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|