@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,118 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Cloudflare dashboard deep-link builders, keyed by resource kind. `pithy env` renders a
|
|
6
|
+
* provisioned resource's id as a link to the exact dashboard page for that resource, so an
|
|
7
|
+
* operator jumps straight from the inventory to the console.
|
|
8
|
+
*
|
|
9
|
+
* **Every path here was checked against a live dashboard.** A kind with no confirmed path gets no
|
|
10
|
+
* link — {@link dashboardUrl} returns `null` for it — because an unconfirmed deep link is worse
|
|
11
|
+
* than a bare id: it looks authoritative and lands on a 404. All links share the base
|
|
12
|
+
* `https://dash.cloudflare.com/<account_id>/…`.
|
|
13
|
+
*
|
|
14
|
+
* Two shapes of link live here. **Resource links** address one provisioned resource by its id.
|
|
15
|
+
* **Account links** address a product page that has no per-resource route at all (Workers AI usage,
|
|
16
|
+
* Images, Stream, Email Service); they ignore the id, so a caller may pass an empty one.
|
|
17
|
+
*
|
|
18
|
+
* Two kinds deliberately have no builder:
|
|
19
|
+
* - **`ratelimit`** — Workers Rate Limiting has no dashboard page at all.
|
|
20
|
+
* - **`service`** — a service binding is not a resource; it is direct RPC to *another Worker*. Its
|
|
21
|
+
* page is that Worker's, so a caller resolves the target script name and asks for the `worker`
|
|
22
|
+
* kind instead.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** Kinds whose dashboard page addresses one resource by id. */
|
|
26
|
+
type ResourceKind =
|
|
27
|
+
| "worker"
|
|
28
|
+
| "d1"
|
|
29
|
+
| "kv"
|
|
30
|
+
| "r2"
|
|
31
|
+
| "vectorize"
|
|
32
|
+
| "workflow"
|
|
33
|
+
| "durable_object"
|
|
34
|
+
| "queue"
|
|
35
|
+
| "secret"
|
|
36
|
+
| "turnstile";
|
|
37
|
+
|
|
38
|
+
/** Kinds with no per-resource page — the link is the product's account-level page. */
|
|
39
|
+
type AccountKind = "ai" | "images" | "stream" | "email";
|
|
40
|
+
|
|
41
|
+
/** The base every dashboard link is built on. */
|
|
42
|
+
const DASHBOARD_BASE = "https://dash.cloudflare.com";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* One builder per id-addressed kind. `id` is that kind's **dashboard** identifier, which is not
|
|
46
|
+
* always the identifier `wrangler.jsonc` carries — see the notes below before wiring a caller.
|
|
47
|
+
*
|
|
48
|
+
* D1 and KV land on the resource's metrics tab, which is the dashboard's own default page for those
|
|
49
|
+
* two, so the link lands exactly where clicking through the console would.
|
|
50
|
+
*/
|
|
51
|
+
const RESOURCE_BUILDERS: Record<ResourceKind, (accountId: string, id: string) => string> = {
|
|
52
|
+
worker: (accountId, id) => `${DASHBOARD_BASE}/${accountId}/workers/services/view/${id}/production`,
|
|
53
|
+
d1: (accountId, id) => `${DASHBOARD_BASE}/${accountId}/workers/d1/databases/${id}/metrics`,
|
|
54
|
+
kv: (accountId, id) => `${DASHBOARD_BASE}/${accountId}/workers/kv/namespaces/${id}/metrics`,
|
|
55
|
+
r2: (accountId, id) => `${DASHBOARD_BASE}/${accountId}/r2/default/buckets/${id}`,
|
|
56
|
+
vectorize: (accountId, id) => `${DASHBOARD_BASE}/${accountId}/ai/vectorize/${id}`,
|
|
57
|
+
workflow: (accountId, id) => `${DASHBOARD_BASE}/${accountId}/workers/workflows/${id}/instances`,
|
|
58
|
+
// `id` is the Durable Object **namespace id** Cloudflare assigns (32-char hex), not the class
|
|
59
|
+
// name. `wrangler.jsonc` carries only the class name, so a caller holding just that must not build
|
|
60
|
+
// this link — see `envInventory`, which renders DO bindings unlinked for exactly this reason.
|
|
61
|
+
durable_object: (accountId, id) => `${DASHBOARD_BASE}/${accountId}/workers/durable-objects/view/${id}/overview`,
|
|
62
|
+
// `id` is the **queue id** (32-char hex), not the queue name a producer binding declares. Same
|
|
63
|
+
// caveat as `durable_object`: a caller holding only the name must not build this link.
|
|
64
|
+
queue: (accountId, id) => `${DASHBOARD_BASE}/${accountId}/workers/queues/${id}/metrics`,
|
|
65
|
+
// `id` is the Secrets Store id, not a secret's name — the page is the store, not one entry.
|
|
66
|
+
secret: (accountId, id) => `${DASHBOARD_BASE}/${accountId}/secrets-store/${id}`,
|
|
67
|
+
// `id` is the widget's sitekey (e.g. `0x4AAA…`), which `pithy turnstile` writes into wrangler vars.
|
|
68
|
+
turnstile: (accountId, id) => `${DASHBOARD_BASE}/${accountId}/turnstile/widget/${id}`,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The product **list** page for a kind whose per-resource id a project cannot know from config alone.
|
|
73
|
+
*
|
|
74
|
+
* A Durable Object binding names a *class* and a queue binding names a *queue*, but the dashboard addresses
|
|
75
|
+
* both by an id Cloudflare assigns — a DO namespace id when a Worker exporting the class deploys, a queue id
|
|
76
|
+
* when the queue is created. Neither is written back into `wrangler.jsonc`, and resolving one would take an
|
|
77
|
+
* API call this read-only, credential-optional command deliberately does not make. So the link goes to the
|
|
78
|
+
* product's list, which is one click from the resource and always correct, rather than nowhere.
|
|
79
|
+
*
|
|
80
|
+
* Both routes below were checked against a live dashboard, like every other path in this file.
|
|
81
|
+
*/
|
|
82
|
+
const LIST_BUILDERS: Record<string, (accountId: string) => string> = {
|
|
83
|
+
durable_object: (accountId) => `${DASHBOARD_BASE}/${accountId}/workers/durable-objects`,
|
|
84
|
+
queue: (accountId) => `${DASHBOARD_BASE}/${accountId}/workers/queues`,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The list page for a kind, or `null` when it has none. Used as the fallback when a resource is real and
|
|
89
|
+
* provisioned but its dashboard id is not knowable from the project's own config.
|
|
90
|
+
*/
|
|
91
|
+
export function dashboardListUrl(kind: string, accountId: string): string | null {
|
|
92
|
+
const build = LIST_BUILDERS[kind];
|
|
93
|
+
return build ? build(accountId) : null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** One builder per account-level kind. The resource id is not part of the route. */
|
|
97
|
+
const ACCOUNT_BUILDERS: Record<AccountKind, (accountId: string) => string> = {
|
|
98
|
+
ai: (accountId) => `${DASHBOARD_BASE}/${accountId}/ai/workers-ai/usage`,
|
|
99
|
+
images: (accountId) => `${DASHBOARD_BASE}/${accountId}/images/hosted`,
|
|
100
|
+
stream: (accountId) => `${DASHBOARD_BASE}/${accountId}/stream/videos`,
|
|
101
|
+
email: (accountId) => `${DASHBOARD_BASE}/${accountId}/email-service/sending`,
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Build the Cloudflare dashboard URL for a resource, or `null` when the kind has no confirmed
|
|
106
|
+
* dashboard path. `kind` is a plain string so callers can pass any binding kind (`ratelimit`,
|
|
107
|
+
* `service`, …) without narrowing; an unsupported kind returns `null` rather than a guessed link.
|
|
108
|
+
* An account-level kind ignores `id`, so `""` is fine for those.
|
|
109
|
+
*/
|
|
110
|
+
export function dashboardUrl(kind: string, accountId: string, id: string): string | null {
|
|
111
|
+
const account = (ACCOUNT_BUILDERS as Record<string, ((accountId: string) => string) | undefined>)[kind];
|
|
112
|
+
if (account) return account(accountId);
|
|
113
|
+
|
|
114
|
+
const resource = (RESOURCE_BUILDERS as Record<string, ((accountId: string, id: string) => string) | undefined>)[kind];
|
|
115
|
+
// An id-addressed kind with no id has nothing to point at — a bare product page would be a lie.
|
|
116
|
+
if (!resource || !id) return null;
|
|
117
|
+
return resource(accountId, id);
|
|
118
|
+
}
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { InternalError, messageOf, NotFoundError, PithyError } from "@pithy-sh/core/src/error/pithyError";
|
|
7
|
+
import type { WorkerDomains } from "@pithy-sh/core/src/naming/domains";
|
|
8
|
+
import type { CliAuditEmit } from "../audit/cliAudit";
|
|
9
|
+
import { type CloudflareAccountSelection, cloudflareEnv } from "../cloudflare/config";
|
|
10
|
+
import { isSourceEnvironment, wranglerConfigPath } from "../provision/featureConfig";
|
|
11
|
+
import { red } from "../terminal/style";
|
|
12
|
+
import { loadWorkerConfig, loadWorkerDomains } from "./config";
|
|
13
|
+
import { detectPackageManager, execArgs, type PackageManager } from "./packageManager";
|
|
14
|
+
import type { DeployVerification, VerifyDeployResult } from "./verifyDeploy";
|
|
15
|
+
import { isDeployFailure, verifyDeployedVersion } from "./verifyDeploy";
|
|
16
|
+
import { type AddressStanza, resolveWorkerAddress } from "./workerAddress";
|
|
17
|
+
import { parseWorkerManifest } from "./workerManifest";
|
|
18
|
+
import { discoverWorkers, type WorkerTarget } from "./workers";
|
|
19
|
+
import { readWranglerConfig, runWrangler } from "./wrangler";
|
|
20
|
+
|
|
21
|
+
/** The `wrangler deploy` runner for one worker — injectable so tests exercise orchestration without wrangler. */
|
|
22
|
+
export type RunDeploy = (target: WorkerTarget, args: string[]) => Promise<string>;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The UI build runner for one worker — injectable so tests exercise orchestration without a real build.
|
|
26
|
+
*
|
|
27
|
+
* `environment` is not optional decoration. `@pithy-sh/vite` resolves each capability's client-safe
|
|
28
|
+
* projection *for a named environment* at build time — a Turnstile sitekey differs per environment, and
|
|
29
|
+
* the plugin falls back to `dev` when nothing says otherwise. A build that does not carry the deploy's
|
|
30
|
+
* `--env` therefore inlines dev values into a production bundle, which for Turnstile means shipping
|
|
31
|
+
* Cloudflare's always-passes test sitekey. It is silent, and it defeats the gate entirely.
|
|
32
|
+
*/
|
|
33
|
+
export type RunBuild = (
|
|
34
|
+
target: WorkerTarget,
|
|
35
|
+
command: string,
|
|
36
|
+
args: string[],
|
|
37
|
+
environment: string | undefined,
|
|
38
|
+
) => Promise<void>;
|
|
39
|
+
|
|
40
|
+
export interface DeployProjectOptions {
|
|
41
|
+
/** The project root — the parent of `apps/`, where every Worker lives. */
|
|
42
|
+
projectDir: string;
|
|
43
|
+
/**
|
|
44
|
+
* The Cloudflare account this project belongs to, from `projectCloudflareAccount(projectDir)` — or
|
|
45
|
+
* `null` for a project that names none.
|
|
46
|
+
*
|
|
47
|
+
* **Required, and stated by the caller rather than defaulted.** An omitted account would resolve
|
|
48
|
+
* whatever the last-loaded project happened to select, and the failure that produces is a successful
|
|
49
|
+
* deploy to the wrong tenant. There is no safe default, so there is no default (#206).
|
|
50
|
+
*/
|
|
51
|
+
account: CloudflareAccountSelection | null;
|
|
52
|
+
/** Target environment; omitted deploys each worker's top-level config (no `--env`). */
|
|
53
|
+
env?: string;
|
|
54
|
+
/** Test seam: run one worker's deploy and return its captured stdout. Defaults to real wrangler. */
|
|
55
|
+
runDeploy?: RunDeploy;
|
|
56
|
+
/** Test seam: run one worker's UI build. Defaults to the real package-manager invocation. */
|
|
57
|
+
runBuild?: RunBuild;
|
|
58
|
+
/** Audit emitter. Defaults to recording nothing, so a caller without audit wiring still works. */
|
|
59
|
+
audit?: CliAuditEmit;
|
|
60
|
+
/**
|
|
61
|
+
* Test seam: probe a declared domain for the version just shipped. Defaults to the real HTTP probe.
|
|
62
|
+
*
|
|
63
|
+
* Injected rather than reached for, so the deploy tests never touch the network and the retry/backoff
|
|
64
|
+
* behavior is exercised without waiting for it.
|
|
65
|
+
*/
|
|
66
|
+
verifyDeploy?: VerifyDeploy;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Probe one address for one expected version. The seam `verifyDeployedVersion` fills by default. */
|
|
70
|
+
export type VerifyDeploy = (options: { url: string; expectedVersion: string }) => Promise<VerifyDeployResult>;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Shipping code is production-affecting the moment `prod` is the named target — everything else
|
|
74
|
+
* (`staging`, a bare deploy with no `--env`) is routine. Exported so the command layer and tests agree on
|
|
75
|
+
* the same rule.
|
|
76
|
+
*/
|
|
77
|
+
export function deploySeverity(env: string | undefined): "info" | "warning" {
|
|
78
|
+
return env === "prod" ? "warning" : "info";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** One worker's deploy outcome — the `--json` row and the human summary line both read from this. */
|
|
82
|
+
export interface WorkerDeploy {
|
|
83
|
+
/** The worker's name. */
|
|
84
|
+
name: string;
|
|
85
|
+
/** Whether `wrangler deploy` succeeded for this worker. */
|
|
86
|
+
ok: boolean;
|
|
87
|
+
/** The deployed version id, when wrangler's output carried one. */
|
|
88
|
+
versionId?: string;
|
|
89
|
+
/** The worker's public URL, when wrangler's output carried one. */
|
|
90
|
+
url?: string;
|
|
91
|
+
/**
|
|
92
|
+
* Whether this worker's UI build ran and succeeded. Absent when the worker declares no `ui` block —
|
|
93
|
+
* so `false` means the build is what failed, and the deploy never ran.
|
|
94
|
+
*/
|
|
95
|
+
built?: boolean;
|
|
96
|
+
/** The failure reason, present only when `ok` is false. */
|
|
97
|
+
error?: string;
|
|
98
|
+
/**
|
|
99
|
+
* What probing the **declared** domain concluded about the version just shipped.
|
|
100
|
+
*
|
|
101
|
+
* Absent when there was nothing to check: a bare `pithy deploy` with no `--env` has no environment to
|
|
102
|
+
* resolve a domain for, and a Worker that declares no address has no domain to probe. `mismatch` and
|
|
103
|
+
* `unreachable` are the two that fail the command — see `isDeployFailure`.
|
|
104
|
+
*/
|
|
105
|
+
verification?: DeployVerification;
|
|
106
|
+
/** The one-line explanation behind `verification`, for the summary and the `--json` row. */
|
|
107
|
+
verificationDetail?: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Scrape the version id and public url from `wrangler deploy` output — best-effort, both optional. */
|
|
111
|
+
function parseDeployOutput(stdout: string): { versionId?: string; url?: string } {
|
|
112
|
+
const summary: { versionId?: string; url?: string } = {};
|
|
113
|
+
const version = stdout.match(/Version ID:\s*(\S+)/);
|
|
114
|
+
if (version) summary.versionId = version[1];
|
|
115
|
+
// The deployed URL is the last one wrangler prints (after upload), not an earlier docs/dashboard link.
|
|
116
|
+
const urls = stdout.match(/https?:\/\/\S+/g);
|
|
117
|
+
if (urls) summary.url = urls[urls.length - 1];
|
|
118
|
+
return summary;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The failure reason for a thrown deploy. For a `PithyError` (how `runWrangler` reports a non-zero
|
|
123
|
+
* exit) that means the `detail` — wrangler's captured exit code and stderr, the part a CI operator
|
|
124
|
+
* needs — not just the generic public `message`. Anything else falls back to the shared `messageOf`.
|
|
125
|
+
*/
|
|
126
|
+
function reasonOf(error: unknown): string {
|
|
127
|
+
if (error instanceof PithyError) return error.payload.detail ?? error.payload.message;
|
|
128
|
+
return messageOf(error);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The default deploy step: `wrangler deploy [--env <env>]` in the worker's directory, quiet on
|
|
133
|
+
* success (its output is captured and summarized, not streamed — the brand voice). Wrangler reads
|
|
134
|
+
* `CLOUDFLARE_API_TOKEN`/`CLOUDFLARE_ACCOUNT_ID`, so CI needs no interactive login; we also pass them
|
|
135
|
+
* from the project's own credentials file so a local deploy authenticates the same way.
|
|
136
|
+
*
|
|
137
|
+
* **The account is an argument, and that is load-bearing here more than anywhere.** This is the exact
|
|
138
|
+
* pair handed to `wrangler deploy`: resolving it against the wrong account does not fail, it ships to
|
|
139
|
+
* another company's tenant and exits 0. A pinned `accountId` that disagrees refuses before wrangler is
|
|
140
|
+
* ever spawned (#206).
|
|
141
|
+
*/
|
|
142
|
+
function defaultRunDeploy(account: CloudflareAccountSelection | null): RunDeploy {
|
|
143
|
+
const vars = cloudflareEnv({ account });
|
|
144
|
+
const env: Record<string, string> = {};
|
|
145
|
+
if (vars.CLOUDFLARE_API_TOKEN) env.CLOUDFLARE_API_TOKEN = vars.CLOUDFLARE_API_TOKEN;
|
|
146
|
+
if (vars.CLOUDFLARE_ACCOUNT_ID) env.CLOUDFLARE_ACCOUNT_ID = vars.CLOUDFLARE_ACCOUNT_ID;
|
|
147
|
+
return async (target, args) => {
|
|
148
|
+
const { stdout } = await runWrangler(args, { cwd: target.dir, env });
|
|
149
|
+
return stdout;
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const runProcess = promisify(execFile);
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The default UI build step: the worker's `ui.build` argv, through the project's package manager, in the
|
|
157
|
+
* worker's own directory. Quiet on success like the deploy step — the output is captured and surfaced only
|
|
158
|
+
* as the failure `detail`. A build's chunk table can be long, so the buffer is generous; truncation would
|
|
159
|
+
* turn a real failure into a confusing one.
|
|
160
|
+
*/
|
|
161
|
+
const defaultRunBuild: RunBuild = async (target, command, args, environment) => {
|
|
162
|
+
try {
|
|
163
|
+
await runProcess(command, args, {
|
|
164
|
+
cwd: target.dir,
|
|
165
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
166
|
+
// `ENVIRONMENT` is the same signal the deployed Worker reads (wrangler.jsonc `vars`), and it is
|
|
167
|
+
// what the Vite plugin resolves each capability's projection against. Absent, it builds `dev`.
|
|
168
|
+
...(environment ? { env: { ...process.env, ENVIRONMENT: environment } } : {}),
|
|
169
|
+
});
|
|
170
|
+
} catch (cause) {
|
|
171
|
+
throw new InternalError({
|
|
172
|
+
message: `${command} ${args.join(" ")} failed.`,
|
|
173
|
+
action: `Build ${target.name} by hand: ${command} ${args.join(" ")}.`,
|
|
174
|
+
detail: cause instanceof Error ? cause.message : String(cause),
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The build command for one worker, or `undefined` when it serves no front end. The SPA lives in the Worker
|
|
181
|
+
* that serves it, so its assets have to exist before `wrangler deploy` uploads them. `vite build` also writes
|
|
182
|
+
* `.wrangler/deploy/config.json` in the worker's directory, which redirects the following plain
|
|
183
|
+
* `wrangler deploy` to the built config — so the deploy argv needs no `-c` and does not change.
|
|
184
|
+
*/
|
|
185
|
+
async function uiBuild(
|
|
186
|
+
worker: WorkerTarget,
|
|
187
|
+
packageManager: PackageManager,
|
|
188
|
+
): Promise<{ command: string; args: string[] } | undefined> {
|
|
189
|
+
const manifest = await parseWorkerManifest(worker.dir);
|
|
190
|
+
const build = manifest?.ui?.build;
|
|
191
|
+
if (!build || build.length === 0) return undefined;
|
|
192
|
+
return execArgs(packageManager, build[0] as string, build.slice(1));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Deploy the project's Workers — the logic behind `pithy deploy`. It enumerates the worker registry
|
|
197
|
+
* (`apps/*` — there is no root Worker) and runs `wrangler deploy` in each worker's own directory, against
|
|
198
|
+
* that worker's own `wrangler.jsonc`, letting wrangler own bundling, upload, bindings, and routes. One
|
|
199
|
+
* worker's failure does not abort the batch: every worker is attempted and reported, so the caller can
|
|
200
|
+
* exit non-zero if any `ok` is false.
|
|
201
|
+
*
|
|
202
|
+
* A worker that serves a front end (a `ui` block in its `pithy.worker.jsonc`) is **built first**, in its own
|
|
203
|
+
* directory, through the adopter's package manager. A failed build fails that worker and skips its deploy —
|
|
204
|
+
* shipping a Worker whose assets are stale, or missing, is worse than not shipping it.
|
|
205
|
+
*/
|
|
206
|
+
/**
|
|
207
|
+
* Probe one Worker's declared domain for the version just shipped, or return null when there is nothing
|
|
208
|
+
* to check.
|
|
209
|
+
*
|
|
210
|
+
* Three ways to have nothing to check, and all three are ordinary rather than failures. **No `--env`**:
|
|
211
|
+
* a bare `pithy deploy` ships each Worker's top-level stanza, which is the `dev` environment, and `dev`
|
|
212
|
+
* has no public address by design. **No version id**: wrangler printed none, so there is nothing to
|
|
213
|
+
* correlate against. **No declared address**: the Worker has no domain, no route, and no `BASE_URL`, so
|
|
214
|
+
* there is nowhere to probe — and inventing `workers.dev` here would be exactly the assumption #89 rules
|
|
215
|
+
* out, since the subdomain can be disabled per account and commonly is in production.
|
|
216
|
+
*/
|
|
217
|
+
async function verifyWorkerDeploy(
|
|
218
|
+
worker: WorkerTarget,
|
|
219
|
+
env: string | undefined,
|
|
220
|
+
versionId: string | undefined,
|
|
221
|
+
probe: VerifyDeploy,
|
|
222
|
+
): Promise<VerifyDeployResult | null> {
|
|
223
|
+
if (!env || env === "dev" || !versionId) return null;
|
|
224
|
+
|
|
225
|
+
let domains: WorkerDomains | undefined;
|
|
226
|
+
try {
|
|
227
|
+
domains = loadWorkerDomains(await loadWorkerConfig(worker.dir));
|
|
228
|
+
} catch {
|
|
229
|
+
domains = undefined;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
let stanza: AddressStanza | undefined;
|
|
233
|
+
try {
|
|
234
|
+
stanza = ((await readWranglerConfig(worker.dir)) as { env?: Record<string, AddressStanza | undefined> }).env?.[env];
|
|
235
|
+
} catch {
|
|
236
|
+
stanza = undefined;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const address = resolveWorkerAddress({ environment: env, domains, stanza });
|
|
240
|
+
if (!address) return null;
|
|
241
|
+
|
|
242
|
+
return probe({ url: address.url, expectedVersion: versionId });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export async function deployProject(options: DeployProjectOptions): Promise<WorkerDeploy[]> {
|
|
246
|
+
// Only real Workers deploy. A non-Worker process in the dev set (a Vite frontend with a
|
|
247
|
+
// `pithy.worker.jsonc` but no `wrangler.jsonc`) has nothing for `wrangler deploy` to ship.
|
|
248
|
+
const workers = (await discoverWorkers(options.projectDir)).filter((worker) => worker.hasWrangler !== false);
|
|
249
|
+
if (workers.length === 0) {
|
|
250
|
+
throw new NotFoundError({
|
|
251
|
+
message: "No deployable workers here.",
|
|
252
|
+
action: "Every worker lives in apps/<name> with its own wrangler.jsonc. Run pithy worker add <name>.",
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const run = options.runDeploy ?? defaultRunDeploy(options.account);
|
|
257
|
+
const build = options.runBuild ?? defaultRunBuild;
|
|
258
|
+
const packageManager = await detectPackageManager(options.projectDir);
|
|
259
|
+
// `--config` for a feature environment, and only there. Provisioning writes a feature's ids into a
|
|
260
|
+
// generated config under `.wrangler/` rather than into the tracked `wrangler.jsonc` (#242), so wrangler
|
|
261
|
+
// has to be told where they are. A declared environment's ids are in the file wrangler already reads.
|
|
262
|
+
const configFor = (worker: WorkerTarget): string[] =>
|
|
263
|
+
options.env && !isSourceEnvironment(options.env) ? ["--config", wranglerConfigPath(worker.dir, options.env)] : [];
|
|
264
|
+
const args = options.env ? ["deploy", "--env", options.env] : ["deploy"];
|
|
265
|
+
const audit = options.audit ?? (async () => {});
|
|
266
|
+
const probe = options.verifyDeploy ?? ((probeOptions) => verifyDeployedVersion(probeOptions));
|
|
267
|
+
const severity = deploySeverity(options.env);
|
|
268
|
+
|
|
269
|
+
const deploys: WorkerDeploy[] = [];
|
|
270
|
+
for (const worker of workers) {
|
|
271
|
+
// Stays undefined for an API-only worker, turns false while a UI worker's build is in flight: a
|
|
272
|
+
// `built: false` row is how a `--json` consumer reads "the build failed, the deploy never ran".
|
|
273
|
+
let built: boolean | undefined;
|
|
274
|
+
try {
|
|
275
|
+
const ui = await uiBuild(worker, packageManager);
|
|
276
|
+
if (ui) {
|
|
277
|
+
built = false;
|
|
278
|
+
await build(worker, ui.command, ui.args, options.env);
|
|
279
|
+
built = true;
|
|
280
|
+
}
|
|
281
|
+
const stdout = await run(worker, [...args, ...configFor(worker)]);
|
|
282
|
+
const deploy: WorkerDeploy = {
|
|
283
|
+
name: worker.name,
|
|
284
|
+
ok: true,
|
|
285
|
+
...parseDeployOutput(stdout),
|
|
286
|
+
...(built === undefined ? {} : { built }),
|
|
287
|
+
};
|
|
288
|
+
// Prove the Worker just shipped is the one answering at the address this project claims. Not a
|
|
289
|
+
// liveness probe — the old version answering happily is exactly the failure worth catching — and
|
|
290
|
+
// not a comparison against the URL wrangler printed, which under versions may be a version-scoped
|
|
291
|
+
// preview rather than the stable route.
|
|
292
|
+
const verified = await verifyWorkerDeploy(worker, options.env, deploy.versionId, probe);
|
|
293
|
+
if (verified) {
|
|
294
|
+
deploy.verification = verified.status;
|
|
295
|
+
deploy.verificationDetail = verified.detail;
|
|
296
|
+
}
|
|
297
|
+
deploys.push(deploy);
|
|
298
|
+
await audit({
|
|
299
|
+
action: "deploy/worker_deployed",
|
|
300
|
+
outcome: "success",
|
|
301
|
+
severity,
|
|
302
|
+
resourceType: "cf_worker",
|
|
303
|
+
resourceId: worker.name,
|
|
304
|
+
// Neither `worker` nor `env` belongs here any more. The environment is the `environment`
|
|
305
|
+
// column the recorder stamps, and the Worker deployed is already `resourceId` — which is
|
|
306
|
+
// also the truer home for it, since that Worker is what this action *targeted*, not where
|
|
307
|
+
// the action came from (a CLI deploy comes from no Worker at all).
|
|
308
|
+
metadata: { versionId: deploy.versionId ?? null, verification: deploy.verification ?? null },
|
|
309
|
+
});
|
|
310
|
+
} catch (error) {
|
|
311
|
+
const reason = reasonOf(error);
|
|
312
|
+
deploys.push({ name: worker.name, ok: false, ...(built === undefined ? {} : { built }), error: reason });
|
|
313
|
+
// A failed deploy is exactly what an audit trail is for — record it too, not just successes.
|
|
314
|
+
await audit({
|
|
315
|
+
action: "deploy/worker_deployed",
|
|
316
|
+
outcome: "failure",
|
|
317
|
+
severity,
|
|
318
|
+
resourceType: "cf_worker",
|
|
319
|
+
resourceId: worker.name,
|
|
320
|
+
// Same as the success path: `worker` is `resourceId` and `env` is the `environment` column.
|
|
321
|
+
// What stays is what is genuinely per-event — which stage failed, and why.
|
|
322
|
+
metadata: {
|
|
323
|
+
stage: built === false ? "build" : "deploy",
|
|
324
|
+
error: reason,
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return deploys;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** One worker's human summary line — brand voice; red on failure, url + version id on success. */
|
|
333
|
+
export function summarizeDeploy(deploy: WorkerDeploy): string {
|
|
334
|
+
// A build failure and a deploy failure are different problems with different fixes, so they read differently.
|
|
335
|
+
if (!deploy.ok) {
|
|
336
|
+
const problem = deploy.built === false ? "build failed." : "failed.";
|
|
337
|
+
return red(`${deploy.name}: ${problem}`) + (deploy.error ? ` ${deploy.error}` : "");
|
|
338
|
+
}
|
|
339
|
+
const detail = [deploy.url, deploy.versionId].filter(Boolean).join(" ");
|
|
340
|
+
const line = detail ? `${deploy.name}: deployed. ${detail}` : `${deploy.name}: deployed.`;
|
|
341
|
+
// Wrangler's own URL keeps appearing above, as it always did — it tells a human where their deploy
|
|
342
|
+
// went. The verification line below is the separate question of whether the *declared* address is now
|
|
343
|
+
// serving what was just shipped, and it is the only one that can fail the command.
|
|
344
|
+
if (!deploy.verification || deploy.verification === "verified") return line;
|
|
345
|
+
const note = deploy.verificationDetail ?? "";
|
|
346
|
+
// Red for the two that fail the command, plain for the two that do not — one rule, `isDeployFailure`,
|
|
347
|
+
// so the color and the exit code can never disagree about which is which.
|
|
348
|
+
return isDeployFailure(deploy.verification) ? `${line}\n ${red(note)}` : `${line}\n ${note}`;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Whether any Worker's declared address is consistently serving something other than what just shipped. */
|
|
352
|
+
export function deployVerificationFailed(deploys: readonly WorkerDeploy[]): boolean {
|
|
353
|
+
return deploys.some((deploy) => deploy.verification !== undefined && isDeployFailure(deploy.verification));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* The warn line when the target env's schema is behind — deploy never migrates, so it only surfaces
|
|
358
|
+
* the drift and points at `pithy migrate`. `undefined` when nothing is pending or the count is unknown.
|
|
359
|
+
*/
|
|
360
|
+
export function pendingWarning(pending: number | undefined, env: string): string | undefined {
|
|
361
|
+
if (!pending || pending <= 0) return undefined;
|
|
362
|
+
const plural = pending === 1 ? "" : "s";
|
|
363
|
+
return `${pending} migration${plural} unapplied for ${env}. Deploy does not migrate — run pithy migrate --env ${env}.`;
|
|
364
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { ConflictError } from "@pithy-sh/core/src/error/pithyError";
|
|
5
|
+
import { writeFileAtomic } from "./atomic";
|
|
6
|
+
import { readOptionalFile } from "./readOptionalFile";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The mode a `.dev.vars` this module *creates* lands with. `pithy init` seeds the project's own at 0600;
|
|
10
|
+
* this is the same rule for every other one — a `.dev.vars.<env>`, or a shared file a checkout has not
|
|
11
|
+
* got yet — because the very next thing written into it is a Cloudflare API token or the master key. The
|
|
12
|
+
* umask is not a permission policy for a credential file. A file already there keeps its own mode.
|
|
13
|
+
*/
|
|
14
|
+
const DEV_VARS_MODE = 0o600;
|
|
15
|
+
|
|
16
|
+
function detectEol(content: string): "\r\n" | "\n" {
|
|
17
|
+
return content.includes("\r\n") ? "\r\n" : "\n";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Upsert `KEY=value` lines into a `.dev.vars` file body, preserving every other line — comments, blanks,
|
|
22
|
+
* and unrelated keys — and updating a key in place where it already exists. New keys are appended. Pure;
|
|
23
|
+
* the caller owns the file IO. Existing line endings (CRLF or LF) are preserved; empty content defaults to LF.
|
|
24
|
+
*/
|
|
25
|
+
export function upsertDevVarsContent(content: string, vars: Record<string, string>): string {
|
|
26
|
+
const eol = detectEol(content);
|
|
27
|
+
const updates = new Map(Object.entries(vars));
|
|
28
|
+
const lines = content.length === 0 ? [] : content.replace(/\r?\n$/, "").split(/\r?\n/);
|
|
29
|
+
const written = new Set<string>();
|
|
30
|
+
const out: string[] = [];
|
|
31
|
+
for (const line of lines) {
|
|
32
|
+
const eq = line.indexOf("=");
|
|
33
|
+
if (eq === -1 || line.trimStart().startsWith("#")) {
|
|
34
|
+
out.push(line);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const key = line.slice(0, eq).trim();
|
|
38
|
+
if (!updates.has(key)) {
|
|
39
|
+
out.push(line);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
// First occurrence of an upserted key: update in place. Any later duplicate of the same key is
|
|
43
|
+
// dropped, so a key never appears twice — otherwise `parseDevVars` (last-wins) would read a stale value.
|
|
44
|
+
if (!written.has(key)) {
|
|
45
|
+
out.push(`${key}=${updates.get(key)}`);
|
|
46
|
+
written.add(key);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
for (const [key, value] of updates) {
|
|
50
|
+
if (!written.has(key)) out.push(`${key}=${value}`);
|
|
51
|
+
}
|
|
52
|
+
return out.length === 0 ? "" : `${out.join(eol)}${eol}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Remove the given `KEY=` lines from a `.dev.vars` file body, leaving everything else untouched. */
|
|
56
|
+
export function removeDevVarsContent(content: string, keys: string[]): string {
|
|
57
|
+
const eol = detectEol(content);
|
|
58
|
+
const drop = new Set(keys);
|
|
59
|
+
const lines = content.length === 0 ? [] : content.replace(/\r?\n$/, "").split(/\r?\n/);
|
|
60
|
+
const out = lines.filter((line) => {
|
|
61
|
+
const eq = line.indexOf("=");
|
|
62
|
+
if (eq === -1 || line.trimStart().startsWith("#")) return true;
|
|
63
|
+
return !drop.has(line.slice(0, eq).trim());
|
|
64
|
+
});
|
|
65
|
+
return out.length === 0 ? "" : `${out.join(eol)}${eol}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Read a dev-vars file (empty if absent), upsert the keys, and write it back atomically — owner-only when
|
|
70
|
+
* it has to be created, and *through* a symlink at `path` rather than over it, when the link is one we
|
|
71
|
+
* could have made ourselves. See {@link writeFileAtomic}.
|
|
72
|
+
*/
|
|
73
|
+
export async function upsertDevVars(path: string, vars: Record<string, string>): Promise<void> {
|
|
74
|
+
const content = await readDevVarsFile(path);
|
|
75
|
+
await writeFileAtomic(path, upsertDevVarsContent(content ?? "", vars), { mode: DEV_VARS_MODE });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Read a dev-vars file (no-op if absent), remove the keys, and write it back atomically. */
|
|
79
|
+
export async function removeDevVars(path: string, keys: string[]): Promise<void> {
|
|
80
|
+
const content = await readDevVarsFile(path);
|
|
81
|
+
if (content === null) return;
|
|
82
|
+
await writeFileAtomic(path, removeDevVarsContent(content, keys), { mode: DEV_VARS_MODE });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The file's current contents, or `null` — **and `null` means the file is not there, nothing else.**
|
|
87
|
+
*
|
|
88
|
+
* Both writers above are read-modify-write over a credential file, so "absent" is the one answer that
|
|
89
|
+
* licenses replacing it. A `catch(() => "")` licensed it for every failure: `EACCES` on a file that
|
|
90
|
+
* plainly exists, `EISDIR`, `ELOOP`, an I/O error — and the atomic write then landed a `.dev.vars`
|
|
91
|
+
* holding **only the keys being upserted**, every other secret in it gone. No attacker is involved, and
|
|
92
|
+
* the file is gitignored, so there is no copy of what it held. `removeDevVars` had the same shape with a
|
|
93
|
+
* quieter failure: it returned early and its caller printed success, telling the adopter a credential
|
|
94
|
+
* was removed while it sat in the file untouched.
|
|
95
|
+
*
|
|
96
|
+
* This is the third data loss on this branch from that one shape, so the errno decision belongs to
|
|
97
|
+
* {@link readOptionalFile} and is made once for every reader. What stays here is the sentence a
|
|
98
|
+
* credential file deserves. A file we cannot read is not a file we may overwrite.
|
|
99
|
+
*/
|
|
100
|
+
async function readDevVarsFile(path: string): Promise<string | null> {
|
|
101
|
+
return readOptionalFile(path, {
|
|
102
|
+
unreadable: ({ code, cause }) =>
|
|
103
|
+
new ConflictError(
|
|
104
|
+
{
|
|
105
|
+
message: `Cannot update ${path}: Pithy could not read what is already in it.`,
|
|
106
|
+
action:
|
|
107
|
+
"Fix the file's permissions, or move it aside, and run the command again. Pithy won't rewrite a credential file it could not read.",
|
|
108
|
+
detail: `${code ?? "unknown error"} while reading ${path}`,
|
|
109
|
+
},
|
|
110
|
+
{ cause },
|
|
111
|
+
),
|
|
112
|
+
});
|
|
113
|
+
}
|