@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,356 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { relative } from "node:path";
|
|
5
|
+
import { NotFoundError } from "@pithy-sh/core/src/error/pithyError";
|
|
6
|
+
import type { WorkerDomains } from "@pithy-sh/core/src/naming/domains";
|
|
7
|
+
import { LOCAL_ENVIRONMENT } from "@pithy-sh/core/src/naming/environment";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { type CloudflareAccountSelection, cloudflareEnv } from "../cloudflare/config";
|
|
10
|
+
import type { StatePathOptions } from "../notifier/state";
|
|
11
|
+
import { loadWorkerConfig, loadWorkerDomains } from "./config";
|
|
12
|
+
import { dashboardListUrl, dashboardUrl } from "./dashboard";
|
|
13
|
+
import { resolveWorkerAddress } from "./workerAddress";
|
|
14
|
+
import { discoverWorkers as discoverWorkersDefault, type WorkerTarget } from "./workers";
|
|
15
|
+
import { readOptionalWranglerConfig } from "./wrangler";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The read-only inventory behind `pithy env`. Every Worker lives in `apps/<name>/` with its **own**
|
|
19
|
+
* `wrangler.jsonc`, so the inventory is per Worker × per environment: for each Worker, the top-level
|
|
20
|
+
* stanza is its `dev` environment and each `env.<name>` is another (staging, production). For every
|
|
21
|
+
* environment it reports each binding, its resolved id, whether that resource is provisioned, the
|
|
22
|
+
* environment's base URL, and a Cloudflare dashboard link per linkable resource. It writes nothing
|
|
23
|
+
* and never fails on a not-provisioned resource — provisioning health belongs to `pithy doctor`.
|
|
24
|
+
*
|
|
25
|
+
* Discovery reads `apps/` directly, and the environment set still comes from `wrangler.jsonc` alone — an
|
|
26
|
+
* inventory must print in a project whose dependencies are not installed. A Worker's `pithy.config.ts` is
|
|
27
|
+
* read only for its `domains` declaration, and only ever opportunistically: a config that is missing,
|
|
28
|
+
* unimportable, or carries a malformed `domains` block leaves the declaration unresolved and the
|
|
29
|
+
* wrangler-derived report intact. A discovered process with no `wrangler.jsonc` (a Vite frontend in the
|
|
30
|
+
* dev set) has no environments and is skipped.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** The wrangler binding array key and id field for each id-carrying resource kind (mirrors feature/wranglerEnv.ts KIND_TO_WRANGLER). */
|
|
34
|
+
type ResourceKind = "d1" | "kv" | "r2" | "durable_object";
|
|
35
|
+
|
|
36
|
+
/** One binding entry as it appears in a wrangler.jsonc binding array; only the id field for its kind is populated. */
|
|
37
|
+
interface RawBinding {
|
|
38
|
+
binding?: string;
|
|
39
|
+
database_id?: string;
|
|
40
|
+
id?: string;
|
|
41
|
+
bucket_name?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A durable-object binding: the binding name and the class it resolves to. */
|
|
45
|
+
interface RawDurableObjectBinding {
|
|
46
|
+
name?: string;
|
|
47
|
+
class_name?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A route entry — either a bare pattern string or the object form wrangler also accepts. */
|
|
51
|
+
type RawRoute = string | { pattern?: string };
|
|
52
|
+
|
|
53
|
+
/** One wrangler environment stanza (the top-level doc is the `dev` stanza; each `env.<name>` is another). */
|
|
54
|
+
interface RawStanza {
|
|
55
|
+
name?: string;
|
|
56
|
+
d1_databases?: RawBinding[];
|
|
57
|
+
kv_namespaces?: RawBinding[];
|
|
58
|
+
r2_buckets?: RawBinding[];
|
|
59
|
+
durable_objects?: { bindings?: RawDurableObjectBinding[] };
|
|
60
|
+
route?: RawRoute;
|
|
61
|
+
routes?: RawRoute[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The parsed wrangler.jsonc: the top-level `dev` stanza plus the named `env.*` stanzas. */
|
|
65
|
+
interface RawWrangler extends RawStanza {
|
|
66
|
+
env?: Record<string, RawStanza | undefined>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const EnvResource = z
|
|
70
|
+
.object({
|
|
71
|
+
kind: z.enum(["d1", "kv", "r2", "durable_object"]).describe("The Cloudflare resource kind backing this binding."),
|
|
72
|
+
binding: z.string().describe("The Worker binding name, e.g. DB or SESSIONS."),
|
|
73
|
+
id: z
|
|
74
|
+
.string()
|
|
75
|
+
.nullable()
|
|
76
|
+
.describe(
|
|
77
|
+
"The resolved resource id (D1 uuid, KV namespace id, R2 bucket name, DO class name), or null when absent.",
|
|
78
|
+
),
|
|
79
|
+
provisioned: z
|
|
80
|
+
.boolean()
|
|
81
|
+
.describe("True when the id is present, non-empty, and not a placeholder — i.e. the resource actually exists."),
|
|
82
|
+
dashboardUrl: z
|
|
83
|
+
.string()
|
|
84
|
+
.nullable()
|
|
85
|
+
.describe(
|
|
86
|
+
"The Cloudflare dashboard link for this resource — its own page when the id is knowable, else that product's list page. Null with no account id, when not provisioned, or for a kind with neither.",
|
|
87
|
+
),
|
|
88
|
+
})
|
|
89
|
+
.describe("One binding in an environment: its kind, resolved id, provisioned state, and dashboard link.");
|
|
90
|
+
export type EnvResource = z.output<typeof EnvResource>;
|
|
91
|
+
|
|
92
|
+
export const EnvironmentReport = z
|
|
93
|
+
.object({
|
|
94
|
+
name: z.string().describe('The environment name: "dev" for the top-level stanza, else the env.<name> key.'),
|
|
95
|
+
local: z
|
|
96
|
+
.boolean()
|
|
97
|
+
.describe(
|
|
98
|
+
"True when this environment runs on the machine rather than in an account — the top-level wrangler stanza, which is always the local one and is never an `env.<name>`. Read it before judging anything about a Cloudflare resource: the presence of one is not a property a local environment has, so absence is not a deficiency there.",
|
|
99
|
+
),
|
|
100
|
+
scriptName: z
|
|
101
|
+
.string()
|
|
102
|
+
.nullable()
|
|
103
|
+
.describe(
|
|
104
|
+
"The Worker script name this environment deploys under (root name for dev, env.<name>.name otherwise), or null.",
|
|
105
|
+
),
|
|
106
|
+
baseUrl: z
|
|
107
|
+
.string()
|
|
108
|
+
.nullable()
|
|
109
|
+
.describe(
|
|
110
|
+
'The environment\'s base URL: "local" for dev (there is no public address for a local run), else resolved in order from the `domains` declaration, the first route pattern, and a hand-set `vars.BASE_URL` — or null when the Worker declares no address at all.',
|
|
111
|
+
),
|
|
112
|
+
workerDashboardUrl: z
|
|
113
|
+
.string()
|
|
114
|
+
.nullable()
|
|
115
|
+
.describe(
|
|
116
|
+
"The Cloudflare dashboard link for this environment's Worker, or null with no account id or script name.",
|
|
117
|
+
),
|
|
118
|
+
resources: z.array(EnvResource).describe("Every binding declared for this environment."),
|
|
119
|
+
})
|
|
120
|
+
.describe("One environment's Worker identity, base URL, and full binding inventory.");
|
|
121
|
+
export type EnvironmentReport = z.output<typeof EnvironmentReport>;
|
|
122
|
+
|
|
123
|
+
export const WorkerEnvironments = z
|
|
124
|
+
.object({
|
|
125
|
+
worker: z.string().describe("The Worker's name, as `pithy worker list` shows it."),
|
|
126
|
+
dir: z.string().describe("The Worker's directory, relative to the project root (e.g. apps/api)."),
|
|
127
|
+
environments: z
|
|
128
|
+
.array(EnvironmentReport)
|
|
129
|
+
.describe("Every environment declared in this Worker's wrangler.jsonc, dev first."),
|
|
130
|
+
})
|
|
131
|
+
.describe("One Worker's environments — each Worker under apps/ carries its own wrangler.jsonc.");
|
|
132
|
+
export type WorkerEnvironments = z.output<typeof WorkerEnvironments>;
|
|
133
|
+
|
|
134
|
+
export const EnvInventory = z
|
|
135
|
+
.object({
|
|
136
|
+
accountId: z
|
|
137
|
+
.string()
|
|
138
|
+
.nullable()
|
|
139
|
+
.describe(
|
|
140
|
+
"The Cloudflare account id, from the credentials file this project's account selects — <config>/cloudflare.json, or cloudflare.<accountName>.json — with the environment overlaid per key. Null when neither supplies one; dashboard links are omitted then.",
|
|
141
|
+
),
|
|
142
|
+
workers: z
|
|
143
|
+
.array(WorkerEnvironments)
|
|
144
|
+
.describe("Every Worker with a wrangler.jsonc, in discovery order, each with its own environments."),
|
|
145
|
+
})
|
|
146
|
+
.describe(
|
|
147
|
+
"The read-only `pithy env` inventory: every Worker's environments, their bindings, resolved ids, provisioned state, and dashboard links.",
|
|
148
|
+
);
|
|
149
|
+
export type EnvInventory = z.output<typeof EnvInventory>;
|
|
150
|
+
|
|
151
|
+
/** A value is not provisioned if it is empty or a placeholder token like `<database_id>`. */
|
|
152
|
+
function isPlaceholder(value: string): boolean {
|
|
153
|
+
const trimmed = value.trim();
|
|
154
|
+
return trimmed === "" || /^<.+>$/.test(trimmed) || /placeholder/i.test(trimmed);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Build one resource report, resolving provisioned state and the dashboard link (only for a provisioned, linkable kind with an account id). */
|
|
158
|
+
function makeResource(
|
|
159
|
+
kind: ResourceKind,
|
|
160
|
+
binding: string | undefined,
|
|
161
|
+
rawId: string | undefined,
|
|
162
|
+
accountId: string | null,
|
|
163
|
+
/**
|
|
164
|
+
* The id the **dashboard** addresses this resource by, when that differs from the id we display.
|
|
165
|
+
* Omit it when they are the same. Pass `null` when the project cannot know it — the resource then
|
|
166
|
+
* falls back to that product's list page rather than a deep link built from the wrong identifier,
|
|
167
|
+
* which would look authoritative and land on a 404.
|
|
168
|
+
*/
|
|
169
|
+
linkId?: string | null,
|
|
170
|
+
): EnvResource {
|
|
171
|
+
const id = rawId ?? null;
|
|
172
|
+
const provisioned = id !== null && !isPlaceholder(id);
|
|
173
|
+
const target = linkId === undefined ? id : linkId;
|
|
174
|
+
// With a dashboard id, link straight at the resource. Without one — a Durable Object class, a queue name —
|
|
175
|
+
// fall back to that product's list page: one click from the resource, and never a fabricated deep link.
|
|
176
|
+
const url =
|
|
177
|
+
accountId && provisioned
|
|
178
|
+
? ((target ? dashboardUrl(kind, accountId, target) : null) ?? dashboardListUrl(kind, accountId))
|
|
179
|
+
: null;
|
|
180
|
+
return { kind, binding: binding ?? "", id, provisioned, dashboardUrl: url };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Every binding declared in a stanza, in a stable kind order. */
|
|
184
|
+
function collectResources(stanza: RawStanza, accountId: string | null): EnvResource[] {
|
|
185
|
+
const resources: EnvResource[] = [];
|
|
186
|
+
for (const entry of stanza.d1_databases ?? [])
|
|
187
|
+
resources.push(makeResource("d1", entry.binding, entry.database_id, accountId));
|
|
188
|
+
for (const entry of stanza.kv_namespaces ?? [])
|
|
189
|
+
resources.push(makeResource("kv", entry.binding, entry.id, accountId));
|
|
190
|
+
for (const entry of stanza.r2_buckets ?? [])
|
|
191
|
+
resources.push(makeResource("r2", entry.binding, entry.bucket_name, accountId));
|
|
192
|
+
// A DO binding names a **class**; the dashboard addresses a DO by the namespace id Cloudflare
|
|
193
|
+
// assigns, which `wrangler.jsonc` never carries. So the class name is shown and no link is built
|
|
194
|
+
// — resolving the namespace id would take a Cloudflare API call this read-only command does not make.
|
|
195
|
+
for (const entry of stanza.durable_objects?.bindings ?? [])
|
|
196
|
+
resources.push(makeResource("durable_object", entry.name, entry.class_name, accountId, null));
|
|
197
|
+
return resources;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The base URL for an environment, through the one resolver.
|
|
202
|
+
*
|
|
203
|
+
* A local environment keeps its `"local"` sentinel — there is no public address for a local run, and
|
|
204
|
+
* the real answer is `http://localhost:<port>` from the port pinned in `.dev.config.json`, which is not
|
|
205
|
+
* this report's business. For every other environment `resolveWorkerAddress` decides, in its documented
|
|
206
|
+
* order: the `domains` declaration, then the first route, then a hand-set `vars.BASE_URL`.
|
|
207
|
+
*
|
|
208
|
+
* Keyed on the report's own `local`, not on the string `dev`. Localness is decided once, where it is
|
|
209
|
+
* known for a structural reason, and every derivation reads that one answer.
|
|
210
|
+
*
|
|
211
|
+
* The resolver is **offline by construction**, which is what lets it be used here: `pithy env` is
|
|
212
|
+
* contractually read-only and always exits 0, so a resolver that reached Cloudflare would turn it into a
|
|
213
|
+
* command that fails without credentials.
|
|
214
|
+
*/
|
|
215
|
+
function deriveBaseUrl(
|
|
216
|
+
name: string,
|
|
217
|
+
local: boolean,
|
|
218
|
+
stanza: RawStanza,
|
|
219
|
+
domains: WorkerDomains | undefined,
|
|
220
|
+
): string | null {
|
|
221
|
+
if (local) return "local";
|
|
222
|
+
return resolveWorkerAddress({ environment: name, domains, stanza })?.url ?? null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Assemble one environment report from its stanza. */
|
|
226
|
+
function buildEnvironment(
|
|
227
|
+
name: string,
|
|
228
|
+
local: boolean,
|
|
229
|
+
stanza: RawStanza,
|
|
230
|
+
scriptName: string | null,
|
|
231
|
+
accountId: string | null,
|
|
232
|
+
domains: WorkerDomains | undefined,
|
|
233
|
+
): EnvironmentReport {
|
|
234
|
+
const workerDashboardUrl = accountId && scriptName ? dashboardUrl("worker", accountId, scriptName) : null;
|
|
235
|
+
return {
|
|
236
|
+
name,
|
|
237
|
+
local,
|
|
238
|
+
scriptName,
|
|
239
|
+
baseUrl: deriveBaseUrl(name, local, stanza, domains),
|
|
240
|
+
workerDashboardUrl,
|
|
241
|
+
resources: collectResources(stanza, accountId),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Every environment one Worker declares: the top-level stanza as the local one, then each `env.<name>`.
|
|
247
|
+
*
|
|
248
|
+
* **Localness comes from where the stanza is, not from what it is called.** The top-level stanza *is*
|
|
249
|
+
* the local environment — `DeclaredEnvironments` refuses to let a project declare `dev`, precisely
|
|
250
|
+
* because it is never an `env.<name>` — so the flag is set at the one place that knows for a structural
|
|
251
|
+
* reason. Comparing a name against `"dev"` downstream would be the same guess re-encoded one layer
|
|
252
|
+
* lower, and every consumer would have to make it again.
|
|
253
|
+
*/
|
|
254
|
+
function buildEnvironments(
|
|
255
|
+
config: RawWrangler,
|
|
256
|
+
accountId: string | null,
|
|
257
|
+
domains: WorkerDomains | undefined,
|
|
258
|
+
): EnvironmentReport[] {
|
|
259
|
+
const environments: EnvironmentReport[] = [
|
|
260
|
+
buildEnvironment(LOCAL_ENVIRONMENT, true, config, config.name ?? null, accountId, domains),
|
|
261
|
+
];
|
|
262
|
+
for (const [name, stanza] of Object.entries(config.env ?? {})) {
|
|
263
|
+
if (!stanza) continue;
|
|
264
|
+
environments.push(buildEnvironment(name, false, stanza, stanza.name ?? null, accountId, domains));
|
|
265
|
+
}
|
|
266
|
+
return environments;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Options for {@link buildEnvInventory}. */
|
|
270
|
+
export interface EnvInventoryOptions {
|
|
271
|
+
/** The project root — the parent of `apps/`. */
|
|
272
|
+
projectDir: string;
|
|
273
|
+
/** Inventory only this Worker, by the name `pithy worker list` shows or its `apps/<dir>` basename. */
|
|
274
|
+
worker?: string;
|
|
275
|
+
/** Discovery seam (default: `discoverWorkers`), so tests need no `apps/` tree on disk. */
|
|
276
|
+
discoverWorkers?: (projectDir: string) => Promise<WorkerTarget[]>;
|
|
277
|
+
/** Where the Pithy config directory is. Defaults to the real one; a seam so a test reads its own. */
|
|
278
|
+
paths?: StatePathOptions;
|
|
279
|
+
/**
|
|
280
|
+
* The Cloudflare account this project belongs to, from `projectCloudflareAccount(projectDir)`.
|
|
281
|
+
*
|
|
282
|
+
* The inventory prints the account id every Worker's resources live under, so reading it from the
|
|
283
|
+
* wrong account's file would label this project's bindings with another company's account (#206).
|
|
284
|
+
*
|
|
285
|
+
* **Required, and that is the fix rather than a detail of it (#226).** It was `account?:` for one
|
|
286
|
+
* release, and `pithy env` never passed it — an omission and a deliberate `null` are the same bytes
|
|
287
|
+
* at the call site, so the compiler had nothing to say and the reviewer had nothing to look at.
|
|
288
|
+
* `null` is the answer for a project that names no account, and it is now a word somebody wrote.
|
|
289
|
+
*/
|
|
290
|
+
account: CloudflareAccountSelection | null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Build the environment inventory for a project: every Worker under `apps/`, each read from its own
|
|
295
|
+
* `wrangler.jsonc`, plus the Cloudflare account id from `<config>/cloudflare.json`/the environment. A project with no
|
|
296
|
+
* Worker at all is a clean `NotFoundError`; a missing account id is not — the inventory still prints,
|
|
297
|
+
* only without dashboard links. `worker` narrows the report to one Worker.
|
|
298
|
+
*/
|
|
299
|
+
export async function buildEnvInventory(options: EnvInventoryOptions): Promise<EnvInventory> {
|
|
300
|
+
const discover = options.discoverWorkers ?? discoverWorkersDefault;
|
|
301
|
+
const targets = (await discover(options.projectDir)).filter((target) => target.hasWrangler !== false);
|
|
302
|
+
if (targets.length === 0) {
|
|
303
|
+
throw new NotFoundError({
|
|
304
|
+
message: "No workers here.",
|
|
305
|
+
action: "Every worker lives in apps/<name> with its own wrangler.jsonc. Run pithy init to start a project.",
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const selected =
|
|
310
|
+
options.worker === undefined
|
|
311
|
+
? targets
|
|
312
|
+
: targets.filter((target) => target.name === options.worker || target.dir.endsWith(`/${options.worker}`));
|
|
313
|
+
if (selected.length === 0) {
|
|
314
|
+
throw new NotFoundError({
|
|
315
|
+
message: `No worker named "${options.worker}".`,
|
|
316
|
+
action: `Run pithy worker list to see this project's workers. Known: ${targets.map((t) => t.name).join(", ")}.`,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// A pinned `cloudflare.accountId` that disagrees with what the credentials resolve to throws here,
|
|
321
|
+
// before a single line is printed. `pithy env` is otherwise contractually exit-0, and this is the one
|
|
322
|
+
// exception worth making: an inventory labeled with an account the project does not claim is not a
|
|
323
|
+
// degraded report, it is a wrong one.
|
|
324
|
+
const accountId = cloudflareEnv({ ...options.paths, account: options.account }).CLOUDFLARE_ACCOUNT_ID ?? null;
|
|
325
|
+
|
|
326
|
+
const workers: WorkerEnvironments[] = [];
|
|
327
|
+
for (const target of selected) {
|
|
328
|
+
// Discovery said this Worker had a wrangler.jsonc; between then and now it may have vanished. `pithy
|
|
329
|
+
// env` reports, it does not gate — carry on with the other Workers rather than failing the inventory.
|
|
330
|
+
// Absence is the only thing skipped, and which failure is an absence is `readOptionalFile`'s decision,
|
|
331
|
+
// reached through `readOptionalWranglerConfig`. A config that is there and will not open still refuses:
|
|
332
|
+
// a Worker silently missing from an inventory is the under-report this whole family exists to prevent.
|
|
333
|
+
const raw = await readOptionalWranglerConfig(target.dir);
|
|
334
|
+
if (raw === null) continue;
|
|
335
|
+
const config = raw as RawWrangler;
|
|
336
|
+
// The declaration, when this Worker has one. Read defensively: `pithy env` reports and does not
|
|
337
|
+
// gate, so a Worker whose `pithy.config.ts` is missing, unimportable, or carries a malformed
|
|
338
|
+
// `domains` block must still have its wrangler-derived environments listed rather than fail the
|
|
339
|
+
// whole inventory. The declaration simply does not contribute for that Worker, and the route
|
|
340
|
+
// fallback answers instead.
|
|
341
|
+
let domains: WorkerDomains | undefined;
|
|
342
|
+
try {
|
|
343
|
+
domains = loadWorkerDomains(await loadWorkerConfig(target.dir));
|
|
344
|
+
} catch {
|
|
345
|
+
domains = undefined;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
workers.push({
|
|
349
|
+
worker: target.name,
|
|
350
|
+
dir: relative(options.projectDir, target.dir),
|
|
351
|
+
environments: buildEnvironments(config, accountId, domains),
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return EnvInventory.parse({ accountId, workers });
|
|
356
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
5
|
+
import {
|
|
6
|
+
assertValidEnvironment,
|
|
7
|
+
type DeclaredEnvironments,
|
|
8
|
+
ENVIRONMENTS,
|
|
9
|
+
FEATURE_ENVIRONMENT,
|
|
10
|
+
} from "@pithy-sh/core/src/naming/environment";
|
|
11
|
+
import { ManagedEnvironment, managedEnvironments } from "@pithy-sh/secrets/src/scope";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The `--env` flag, at the CLI edge.
|
|
15
|
+
*
|
|
16
|
+
* An environment is not a label a command carries around: it sits in the middle of every Cloudflare name
|
|
17
|
+
* the project composes (`<project>-<env>-<thing>`), so its length is subtracted from every other
|
|
18
|
+
* segment's budget and its characters have to be legal in every namespace Pithy writes into. Until this
|
|
19
|
+
* module existed, `--env` was a bare string that nothing checked — `pithy migrate --env Production`
|
|
20
|
+
* happily addressed an environment that does not exist, and a fourteen-character `--env` quietly ate the
|
|
21
|
+
* room a project name had already been accepted against.
|
|
22
|
+
*
|
|
23
|
+
* The rule itself lives in `@pithy-sh/core/src/naming/environment`, next to the budgets derived from it.
|
|
24
|
+
* This module exists so the CLI has one grep-able name for "the flag has been checked", and one arg
|
|
25
|
+
* description that says what is legal.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The shared citty definition for `--env`. Every command that takes one spreads this, so the help text
|
|
30
|
+
* says the same thing everywhere and a new command cannot invent its own wording.
|
|
31
|
+
*
|
|
32
|
+
* `dev` is the default because every command is safe there: it is local, it is the top-level wrangler
|
|
33
|
+
* stanza, and a missing flag should never reach a deployed environment by accident.
|
|
34
|
+
*
|
|
35
|
+
* **The text names the default set, not the project's.** citty resolves an arg's description when the
|
|
36
|
+
* command tree is built — before any `pithy.config.ts` has been read, and in `pithy --help` outside a
|
|
37
|
+
* project entirely — so a declaration cannot reach it. The refusal is where the project's own set is
|
|
38
|
+
* named: {@link requireManagedEnvironment} lists exactly what this project declared.
|
|
39
|
+
*/
|
|
40
|
+
export const ENV_ARG = {
|
|
41
|
+
type: "string",
|
|
42
|
+
default: "dev",
|
|
43
|
+
description: `Target environment: ${ENVIRONMENTS.join(", ")}, or one declared in pithy.config.ts`,
|
|
44
|
+
} as const;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* {@link ENV_ARG} with the flag's *purpose* spelled out, for the handful of commands where "target
|
|
48
|
+
* environment" is not the whole story — `--drop`'s environment, the one the pending count is taken
|
|
49
|
+
* against. The legal set is appended here rather than retyped, so it stays one list.
|
|
50
|
+
*/
|
|
51
|
+
export function envArg(purpose: string): { type: "string"; default: "dev"; description: string } {
|
|
52
|
+
return { ...ENV_ARG, description: `${purpose}: ${ENVIRONMENTS.join(", ")}` };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The `--env` of a command where the flag is genuinely optional — today only `pithy deploy`, whose bare
|
|
57
|
+
* form ships each worker's top-level wrangler stanza, which is not an environment at all. No default,
|
|
58
|
+
* and {@link requireEnvironment} is applied only to a value that was actually given.
|
|
59
|
+
*/
|
|
60
|
+
export function optionalEnvArg(purpose: string): { type: "string"; description: string } {
|
|
61
|
+
return { type: "string", description: `${purpose}: ${ENVIRONMENTS.join(", ")}` };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Check a `--env` value and hand it back, so a command can validate inline:
|
|
66
|
+
* `const env = requireEnvironment(args.env)`.
|
|
67
|
+
*
|
|
68
|
+
* **Call it first, before anything else in the command body.** The point of validating at the edge is
|
|
69
|
+
* that an illegal environment costs nothing — no config load, no Cloudflare call, no half-written
|
|
70
|
+
* wrangler file — and the operator gets one sentence naming the mistake.
|
|
71
|
+
*
|
|
72
|
+
* Throws a `ValidationError` (a flag is something a human typed, never an internal fault) whose action
|
|
73
|
+
* names the fix: `production` is answered with `prod`, an over-long name with the number, and
|
|
74
|
+
* anything else with the charset.
|
|
75
|
+
*/
|
|
76
|
+
export function requireEnvironment(value: string): string {
|
|
77
|
+
assertValidEnvironment(value);
|
|
78
|
+
return value;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The same check, narrowed to an environment Pithy **deploys** to — and, since #241, to one **this
|
|
83
|
+
* project declared**. `dev` is a legal environment and not a legal target: it resolves from `.dev.vars`
|
|
84
|
+
* and a local Miniflare, so provisioning it would look up a Worker that was never deployed.
|
|
85
|
+
*
|
|
86
|
+
* One helper rather than a bare `ManagedEnvironment.parse` at each call site, because a Zod failure is
|
|
87
|
+
* not a `PithyError`: it escapes `withErrorReporting` and prints a stack trace where the operator should
|
|
88
|
+
* see one sentence. And `dev` gets its own answer, since typing it is a reasonable mistake with an
|
|
89
|
+
* unreasonable error.
|
|
90
|
+
*
|
|
91
|
+
* **`declared` is the project's own set, from the root `pithy.config.ts`** ({@link loadProjectEnvironments}).
|
|
92
|
+
* Passing it is what makes `--env live` a refusal that names the declared environments instead of a value
|
|
93
|
+
* that half the CLI accepted and the other half skipped — and it is what lets a project that *does*
|
|
94
|
+
* declare `live` use it everywhere. A caller with no project loaded has nothing to check against and no
|
|
95
|
+
* business calling this.
|
|
96
|
+
*/
|
|
97
|
+
export function requireManagedEnvironment(
|
|
98
|
+
value: string,
|
|
99
|
+
declared: DeclaredEnvironments | readonly string[],
|
|
100
|
+
): ManagedEnvironment {
|
|
101
|
+
const parsed = ManagedEnvironment.safeParse(requireEnvironment(value));
|
|
102
|
+
const environments = managedEnvironments(declared);
|
|
103
|
+
if (parsed.success && environments.includes(parsed.data)) return parsed.data;
|
|
104
|
+
throw new ValidationError({
|
|
105
|
+
message: `--env must be one of ${environments.join(", ")}. Got ${JSON.stringify(value)}.`,
|
|
106
|
+
action: refusalAction(value, environments),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* What to do about an `--env` this project does not have. Three answers, because three different
|
|
112
|
+
* mistakes are behind them and "declare it" is the right advice for only one.
|
|
113
|
+
*
|
|
114
|
+
* `dev` is local. `feature` is a branch's, it has its own flag, and telling someone to declare it would
|
|
115
|
+
* send them to a declaration `DeclaredEnvironments` refuses — a feature's config is generated rather than
|
|
116
|
+
* committed, so one stanza would have two owners. Anything else really is a project that has not
|
|
117
|
+
* declared the environment yet.
|
|
118
|
+
*/
|
|
119
|
+
function refusalAction(value: string, environments: readonly string[]): string {
|
|
120
|
+
if (value === "dev") return "This deploys to a Cloudflare account, and dev is local-only. Run `pithy dev` instead.";
|
|
121
|
+
if (value === FEATURE_ENVIRONMENT) {
|
|
122
|
+
return "A branch's environment is never declared. Run `pithy provision --feature` from inside its worktree.";
|
|
123
|
+
}
|
|
124
|
+
return `Declare it in \`environments\` in the root pithy.config.ts, or pass one this project has: --env ${environments[0]}`;
|
|
125
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { Capability } from "@pithy-sh/core/src/capability/capability";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What an adopter plugged into a capability, read back for `pithy doctor`.
|
|
8
|
+
*
|
|
9
|
+
* A capability composed from a package has a name and a version the CLI can read off `package.json`,
|
|
10
|
+
* and `Project capabilities:` reports it. Something an adopter passed *into* a capability's config has
|
|
11
|
+
* neither — and it can still add routes to the Worker and tables to the database. A Better Auth plugin
|
|
12
|
+
* (#271) is the first of these, and it will not be the last, so this reads the generic
|
|
13
|
+
* `Capability.extensions` seam rather than knowing what a Better Auth plugin is. The CLI does not
|
|
14
|
+
* depend on `@pithy-sh/auth`, and after this it still does not need to.
|
|
15
|
+
*
|
|
16
|
+
* Report-only, and offline: it names what is composed. It establishes no fault and never gates the
|
|
17
|
+
* exit — a plugin an adopter deliberately added is not drift. The failure it exists to prevent is the
|
|
18
|
+
* one where nothing anywhere says the plugin is there.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** One extension, and where it was found. */
|
|
22
|
+
export interface CapabilityExtensionEntry {
|
|
23
|
+
/** The Worker whose `pithy.config.ts` composed it. */
|
|
24
|
+
worker: string;
|
|
25
|
+
/** The capability it was plugged into. */
|
|
26
|
+
capability: string;
|
|
27
|
+
/** The extension point, in that capability's vocabulary (e.g. `better-auth-plugin`). */
|
|
28
|
+
kind: string;
|
|
29
|
+
/** The extension's identity — what an adopter would go and delete. */
|
|
30
|
+
id: string;
|
|
31
|
+
/** The tables it introduced, if any. */
|
|
32
|
+
tables: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Every extension every Worker composes, in Worker then composition order. */
|
|
36
|
+
export interface ExtensionsCheck {
|
|
37
|
+
extensions: CapabilityExtensionEntry[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** One Worker's name and the capabilities its own `pithy.config.ts` composes. */
|
|
41
|
+
export interface ExtensionScope {
|
|
42
|
+
name: string;
|
|
43
|
+
capabilities: Capability[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Collect every composed extension across a project's Workers. */
|
|
47
|
+
export function checkExtensions(workers: readonly ExtensionScope[]): ExtensionsCheck {
|
|
48
|
+
const extensions: CapabilityExtensionEntry[] = [];
|
|
49
|
+
for (const worker of workers) {
|
|
50
|
+
for (const capability of worker.capabilities) {
|
|
51
|
+
for (const extension of capability.extensions ?? []) {
|
|
52
|
+
extensions.push({
|
|
53
|
+
worker: worker.name,
|
|
54
|
+
capability: capability.name,
|
|
55
|
+
kind: extension.kind,
|
|
56
|
+
id: extension.id,
|
|
57
|
+
tables: [...(extension.tables ?? [])],
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { extensions };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** One extension's line: what it is, and what it put in the database. */
|
|
66
|
+
export function describeExtension(entry: CapabilityExtensionEntry): string {
|
|
67
|
+
const tables = entry.tables.length > 0 ? `tables ${entry.tables.join(", ")}` : "no tables";
|
|
68
|
+
return `${entry.capability}: ${entry.id} (${entry.kind}), ${tables}.`;
|
|
69
|
+
}
|