@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,359 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { CloudflareClients } from "@pithy-sh/cloudflare/src/client/clients";
|
|
5
|
+
import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
6
|
+
import { FEATURE_ENVIRONMENT } from "@pithy-sh/core/src/naming/environment";
|
|
7
|
+
import { MAX_ISSUE_DIGITS } from "@pithy-sh/core/src/naming/limits";
|
|
8
|
+
import { defineCommand } from "citty";
|
|
9
|
+
import { type CliAuditEmit, createCliAudit } from "../audit/cliAudit";
|
|
10
|
+
import { type CloudflareAccountSelection, cloudflareAccountConfirmation, cloudflareEnv } from "../cloudflare/config";
|
|
11
|
+
import { createFeature } from "../feature/create";
|
|
12
|
+
import { type DestroyReport, destroyedBeforeFailure, destroyFeature } from "../feature/destroy";
|
|
13
|
+
import { branchIdentityWithoutWorkers, deriveIdentityFromBranch } from "../feature/identity";
|
|
14
|
+
import { syncFeatureDevConfig } from "../feature/sync";
|
|
15
|
+
import { behindRemote, mainRepoRoot } from "../feature/worktree";
|
|
16
|
+
import { migrateProject } from "../migrations/run";
|
|
17
|
+
import { loadProject, loadProjectCloudflare, projectCloudflareAccount, requireProjectName } from "../project/config";
|
|
18
|
+
import { requireEnvironment } from "../project/environment";
|
|
19
|
+
import { type CapabilitySet, isUnknown, projectCapabilitySet } from "../project/workerScope";
|
|
20
|
+
import { AUDIT_DESTINATION_ENV, cloudflareProvisioners, type ResourceProvisioners } from "../provision/resources";
|
|
21
|
+
import { cloudflareSecretsStore, type SecretsStore } from "../provision/store";
|
|
22
|
+
import { seedProject } from "../seed/run";
|
|
23
|
+
import { formatDone, formatJsonLine, withErrorReporting } from "../terminal/output";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The feature's own ephemeral CF environment.
|
|
27
|
+
*
|
|
28
|
+
* `destroy` takes an `--env` because it names the environment its audit trail records; it deletes by
|
|
29
|
+
* recomputed feature name regardless, so the flag never changes what goes. Standing the environment up is
|
|
30
|
+
* `pithy provision --feature`'s job — one job with two spellings, and this is not one of them.
|
|
31
|
+
*/
|
|
32
|
+
const DEFAULT_FEATURE_ENV = FEATURE_ENVIRONMENT;
|
|
33
|
+
|
|
34
|
+
/** Build the CF control-plane provisioners from the environment's credentials, or null when they are absent. */
|
|
35
|
+
function buildProvisioners(account: CloudflareAccountSelection | null): ResourceProvisioners | null {
|
|
36
|
+
const vars = cloudflareEnv({ account });
|
|
37
|
+
const accountId = vars.CLOUDFLARE_ACCOUNT_ID ?? "";
|
|
38
|
+
const apiToken = vars.CLOUDFLARE_API_TOKEN ?? "";
|
|
39
|
+
if (!accountId || !apiToken) return null;
|
|
40
|
+
// What vouches for that id travels with it (#378). `find` is find-or-create's first half, and an empty
|
|
41
|
+
// listing from an account nothing claims is not the absence the second half reads it as.
|
|
42
|
+
const confirmation = cloudflareAccountConfirmation({ account });
|
|
43
|
+
return cloudflareProvisioners(new CloudflareClients({ accountId, apiToken }), { accountId, confirmation });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The account's Secrets Store, or `null` when this project has not recorded one.
|
|
48
|
+
*
|
|
49
|
+
* **Absent is a degraded feature environment, never a failed command.** A project that composes no
|
|
50
|
+
* `secrets` capability has no store id and needs none; one that does gets its own master key and its
|
|
51
|
+
* `secrets_store_secrets` bindings. Either way the rest of the feature stands up, and the report says
|
|
52
|
+
* which happened.
|
|
53
|
+
*/
|
|
54
|
+
function buildStore(account: CloudflareAccountSelection | null): SecretsStore | null {
|
|
55
|
+
const vars = cloudflareEnv({ account });
|
|
56
|
+
const accountId = vars.CLOUDFLARE_ACCOUNT_ID ?? "";
|
|
57
|
+
const apiToken = vars.CLOUDFLARE_API_TOKEN ?? "";
|
|
58
|
+
const storeId = vars.SECRETS_STORE_ID ?? "";
|
|
59
|
+
if (!accountId || !apiToken || !storeId) return null;
|
|
60
|
+
return cloudflareSecretsStore(new CloudflareClients({ accountId, apiToken }), storeId);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The audit emitter for a feature command, or a no-op when auditing is unavailable. Feature provisioning
|
|
65
|
+
* and teardown change real infrastructure — and `destroy` runs headlessly in CI — so every create and
|
|
66
|
+
* delete leaves a record of what happened under which token. Credentials are the same ones the command
|
|
67
|
+
* already needs; without them there is nothing to record through and the emitter is inert.
|
|
68
|
+
*
|
|
69
|
+
* These are control-plane operations: they touch real Cloudflare whatever environment is named, so they use
|
|
70
|
+
* `createCliAudit` directly rather than the remote-gated variant, and are always recorded.
|
|
71
|
+
*/
|
|
72
|
+
async function buildAudit(
|
|
73
|
+
projectDir: string,
|
|
74
|
+
capabilities: CapabilitySet,
|
|
75
|
+
account: CloudflareAccountSelection | null,
|
|
76
|
+
): Promise<CliAuditEmit> {
|
|
77
|
+
const vars = cloudflareEnv({ account });
|
|
78
|
+
const accountId = vars.CLOUDFLARE_ACCOUNT_ID ?? "";
|
|
79
|
+
const apiToken = vars.CLOUDFLARE_API_TOKEN ?? "";
|
|
80
|
+
if (!accountId || !apiToken) return async () => {};
|
|
81
|
+
return createCliAudit({
|
|
82
|
+
projectDir,
|
|
83
|
+
env: AUDIT_DESTINATION_ENV,
|
|
84
|
+
capabilities,
|
|
85
|
+
clients: new CloudflareClients({ accountId, apiToken }),
|
|
86
|
+
apiToken,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** `pithy feature create <slug> --issue <n>` — local, automatic. Run from the main checkout. */
|
|
91
|
+
const create = defineCommand({
|
|
92
|
+
meta: {
|
|
93
|
+
name: "create",
|
|
94
|
+
description: "Stand up a feature's local environment: worktree, ports, dev.vars, migrate + seed",
|
|
95
|
+
},
|
|
96
|
+
args: {
|
|
97
|
+
slug: { type: "positional", required: true, description: "Short kebab-case name, e.g. media-cli" },
|
|
98
|
+
issue: { type: "string", required: true, description: "Issue number this feature tracks" },
|
|
99
|
+
"skip-install": { type: "boolean", default: false, description: "Skip installing dependencies in the worktree" },
|
|
100
|
+
json: { type: "boolean", default: false, description: "Machine-readable output" },
|
|
101
|
+
},
|
|
102
|
+
run: ({ args }) =>
|
|
103
|
+
withErrorReporting(args.json, async () => {
|
|
104
|
+
const projectDir = process.cwd();
|
|
105
|
+
// Digits *and* the digit budget, both here. `featureResourceName` reserves `MAX_ISSUE_DIGITS` for
|
|
106
|
+
// the issue segment and refuses anything longer — but it is not reached until provisioning, by
|
|
107
|
+
// which point the branch and the worktree exist. Refusing at the boundary leaves nothing behind.
|
|
108
|
+
if (!/^\d+$/.test(args.issue) || args.issue.length > MAX_ISSUE_DIGITS) {
|
|
109
|
+
throw new ValidationError({
|
|
110
|
+
message: `Issue must be a number of at most ${MAX_ISSUE_DIGITS} digits (got "${args.issue}").`,
|
|
111
|
+
action: "Pass --issue <number>.",
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(args.slug)) {
|
|
115
|
+
throw new ValidationError({
|
|
116
|
+
message: `Slug must be kebab-case (got "${args.slug}").`,
|
|
117
|
+
action: "Use lowercase words joined by hyphens, e.g. media-cli.",
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Capabilities are read from the worktree it creates, not from here: the feature branch is what
|
|
122
|
+
// decides which Workers exist and what each composes.
|
|
123
|
+
const report = await createFeature({
|
|
124
|
+
projectDir,
|
|
125
|
+
issue: args.issue,
|
|
126
|
+
slug: args.slug,
|
|
127
|
+
skipInstall: args["skip-install"],
|
|
128
|
+
});
|
|
129
|
+
// Only when a branch was actually cut from the trunk. An existing worktree is a no-op and an
|
|
130
|
+
// existing branch is *attached* — its base is whatever somebody else cut, months ago — so a
|
|
131
|
+
// sentence about this trunk would be false on both. `#454` is about false sentences over bases.
|
|
132
|
+
const behind = report.base === null ? null : await behindRemote();
|
|
133
|
+
|
|
134
|
+
if (args.json) {
|
|
135
|
+
process.stdout.write(`${formatJsonLine({ ...report, behindRemote: behind })}\n`);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
// Said, never refused — `#454`. The branch was cut from local `main`, which is usually what somebody
|
|
139
|
+
// wants and is sometimes deliberate. Being told is what stops it becoming a surprise at merge time.
|
|
140
|
+
if (behind !== null && report.base !== null) {
|
|
141
|
+
process.stdout.write(
|
|
142
|
+
`${report.base} is ${behind} commit(s) behind origin/${report.base}. ` +
|
|
143
|
+
`Cut from local ${report.base}. git pull to change that.\n`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
process.stdout.write(`Worktree ${report.worktree}.\n`);
|
|
147
|
+
process.stdout.write(`Branch ${report.branch}.\n`);
|
|
148
|
+
for (const [worker, endpoint] of Object.entries(report.dev.workers)) {
|
|
149
|
+
process.stdout.write(`${worker}: ${endpoint.origin}\n`);
|
|
150
|
+
}
|
|
151
|
+
process.stdout.write("Local backend migrated and seeded.\n");
|
|
152
|
+
process.stdout.write(`${formatDone()}\n`);
|
|
153
|
+
}),
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* `pithy feature sync` — make this worktree's local environment ready, whatever state it is in. Run from
|
|
158
|
+
* the worktree with no arguments; the branch says which feature it is.
|
|
159
|
+
*
|
|
160
|
+
* Two everyday cases, one command. **You added a worker:** it takes the next free port from the feature's
|
|
161
|
+
* already-reserved block and leaves every existing worker where it was. **A colleague pushed the branch and
|
|
162
|
+
* you pulled it:** none of the local state is in git — `.dev.config.json` and the port reservation are both
|
|
163
|
+
* machine-local — so this creates them on your machine (with your own free port block, which is why they
|
|
164
|
+
* are not committed) and brings your local backend up to date. Everything it does is idempotent, so running
|
|
165
|
+
* it when nothing is missing simply reports that nothing moved.
|
|
166
|
+
*
|
|
167
|
+
* **It touches no `.dev.vars`, and its help text must not offer to.** Each Worker's is generated by
|
|
168
|
+
* `pithy dev` from sources already on this machine (#154), so there is nothing here to reconcile — and a
|
|
169
|
+
* flag whose description promises otherwise sends someone to the wrong command for a file that is not
|
|
170
|
+
* broken.
|
|
171
|
+
*/
|
|
172
|
+
const sync = defineCommand({
|
|
173
|
+
meta: {
|
|
174
|
+
name: "sync",
|
|
175
|
+
description: "Make this feature's local environment ready: ports, migrate + seed",
|
|
176
|
+
},
|
|
177
|
+
args: {
|
|
178
|
+
"skip-data": { type: "boolean", default: false, description: "Reconcile ports only, leaving the backend alone" },
|
|
179
|
+
json: { type: "boolean", default: false, description: "Machine-readable output" },
|
|
180
|
+
},
|
|
181
|
+
run: ({ args }) =>
|
|
182
|
+
withErrorReporting(args.json, async () => {
|
|
183
|
+
const projectDir = process.cwd();
|
|
184
|
+
const { branch } = await deriveIdentityFromBranch(projectDir);
|
|
185
|
+
const report = await syncFeatureDevConfig({
|
|
186
|
+
mainRoot: await mainRepoRoot(),
|
|
187
|
+
worktreePath: projectDir,
|
|
188
|
+
branch,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// A freshly-pulled branch has an empty local backend; both steps are idempotent when it is not.
|
|
192
|
+
// Each fans out over the worktree's Workers, so a Worker the branch added is covered without asking.
|
|
193
|
+
let data = false;
|
|
194
|
+
if (!args["skip-data"]) {
|
|
195
|
+
// Both steps name the project, resolved once. The migrate stamps each database as this
|
|
196
|
+
// project's — the check that refuses another project's D1 — and the seed carries the same name
|
|
197
|
+
// into the account-wide Images/Stream stores, which is all a later sweep has to go on.
|
|
198
|
+
// One config load, two facts (#234). The account is this worktree's own, never the default
|
|
199
|
+
// credentials file: a `dev` fan-out is local, but a media fixture reaches Images and Stream,
|
|
200
|
+
// which have no local emulation and belong to exactly one account.
|
|
201
|
+
const config = await loadProject(projectDir);
|
|
202
|
+
const project = requireProjectName(config);
|
|
203
|
+
const account = loadProjectCloudflare(config) ?? null;
|
|
204
|
+
await migrateProject({ env: "dev", projectDir, project, account });
|
|
205
|
+
await seedProject({ env: "dev", projectDir, project, account, json: true });
|
|
206
|
+
data = true;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (args.json) {
|
|
210
|
+
// **One field, `data`, because there is one flag (#231).** It used to emit `migrated: data,
|
|
211
|
+
// seeded: data` — two keys off one boolean, so they can never disagree, so every consumer's
|
|
212
|
+
// `if (migrated && !seeded)` is a branch that can never fire and can never be tested. Should the
|
|
213
|
+
// steps ever be split, that dead branch silently becomes live with whatever meaning the split
|
|
214
|
+
// invents. `migrated` is also already taken: on `pithy upgrade` it means the narrower "the
|
|
215
|
+
// migration step ran", so `feature sync` spending it on "migrated *and* seeded" was itself a
|
|
216
|
+
// shared key with two meanings — the defect #231 is about. Two facts that can differ is not on
|
|
217
|
+
// offer either: both steps throw, so any run that reaches this line ran both or neither.
|
|
218
|
+
//
|
|
219
|
+
// **`added`/`removed` are published as `addedWorkers`/`removedWorkers` (#235).** `removed` is
|
|
220
|
+
// `boolean` on `pithy alias --remove` and on `pithy dashboard disconnect` — "was it removed?" —
|
|
221
|
+
// and a `string[]` here, so `if (result.removed)` is true for both and means opposite things.
|
|
222
|
+
// The collection is the outlier, and naming its contents is what tells the two apart; `added`
|
|
223
|
+
// comes with it because the pair is read together, and half a qualified pair invites exactly the
|
|
224
|
+
// misreading the qualification is for. The report keeps its own field names: `SyncReport` is the
|
|
225
|
+
// reconciler's domain type, and only what leaves through `--json` is a published name.
|
|
226
|
+
const { added: addedWorkers, removed: removedWorkers, ...rest } = report;
|
|
227
|
+
process.stdout.write(
|
|
228
|
+
`${formatJsonLine({ command: "feature.sync", ...rest, addedWorkers, removedWorkers, data })}\n`,
|
|
229
|
+
);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
for (const worker of report.added) {
|
|
233
|
+
process.stdout.write(`${worker}: ${report.dev.workers[worker]?.origin} (new).\n`);
|
|
234
|
+
}
|
|
235
|
+
for (const worker of report.removed) {
|
|
236
|
+
process.stdout.write(`${worker}: released.\n`);
|
|
237
|
+
}
|
|
238
|
+
if (report.added.length === 0 && report.removed.length === 0) {
|
|
239
|
+
process.stdout.write("Ports unchanged.\n");
|
|
240
|
+
}
|
|
241
|
+
if (data) process.stdout.write("Local backend migrated and seeded.\n");
|
|
242
|
+
process.stdout.write(`${formatDone()}\n`);
|
|
243
|
+
}),
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
/** `pithy feature destroy` — teardown. Run from within the worktree. */
|
|
247
|
+
const destroy = defineCommand({
|
|
248
|
+
meta: { name: "destroy", description: "Tear down the feature: delete CF resources, free ports, prune the worktree" },
|
|
249
|
+
args: {
|
|
250
|
+
env: { type: "string", description: `Environment to tear down (default: "${DEFAULT_FEATURE_ENV}")` },
|
|
251
|
+
"local-only": {
|
|
252
|
+
type: "boolean",
|
|
253
|
+
default: false,
|
|
254
|
+
description: "Tear down only the worktree and ports, leaving Cloudflare resources in place",
|
|
255
|
+
},
|
|
256
|
+
json: { type: "boolean", default: false, description: "Machine-readable output" },
|
|
257
|
+
},
|
|
258
|
+
run: ({ args }) =>
|
|
259
|
+
withErrorReporting(args.json, async () => {
|
|
260
|
+
const projectDir = process.cwd();
|
|
261
|
+
/*
|
|
262
|
+
Identity without the Worker configs, and capabilities only if they load — `#454`.
|
|
263
|
+
|
|
264
|
+
Teardown's local half frees the port block and prunes the worktree, and needs neither. It is also
|
|
265
|
+
the half most needed when a Worker config will not load: a `feature create` that failed partway
|
|
266
|
+
leaves exactly that, and `destroy` used to throw on the same config before reaching the teardown —
|
|
267
|
+
so the command that frees the block was unavailable in the state that produced the leak.
|
|
268
|
+
|
|
269
|
+
The remote half genuinely cannot run without them, and skipping it silently is the worst outcome
|
|
270
|
+
(every D1/KV/R2 leaks while the run reports success). So an unloadable config is refused unless
|
|
271
|
+
`--local-only` says the remote half is not wanted.
|
|
272
|
+
*/
|
|
273
|
+
const identity = await branchIdentityWithoutWorkers(projectDir);
|
|
274
|
+
const capabilities = await projectCapabilitySet(projectDir);
|
|
275
|
+
if (isUnknown(capabilities) && !args["local-only"]) {
|
|
276
|
+
throw new ValidationError({
|
|
277
|
+
// The set's own diagnosis, which names the worker and says whether the config is broken or
|
|
278
|
+
// simply absent. Inventing a sentence here is how "will not load" came to point at a file that
|
|
279
|
+
// does not exist (#454) — the fault that guard was added to stop reporting.
|
|
280
|
+
message: "This project's capability set is unknown, so its resources cannot be deleted.",
|
|
281
|
+
action:
|
|
282
|
+
`${capabilities.unknown} Fix it and re-run, or pass --local-only to free this feature's ports ` +
|
|
283
|
+
"and prune its worktree without touching Cloudflare.",
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
const account = await projectCloudflareAccount(projectDir);
|
|
287
|
+
const provisioners = buildProvisioners(account);
|
|
288
|
+
|
|
289
|
+
// Without credentials the remote half cannot run. Skipping it silently is the worst outcome: every
|
|
290
|
+
// D1/KV/R2 leaks while the run reports success, and teardown then deletes the branch the resource
|
|
291
|
+
// names are derived from — so a later attempt can no longer work out what to delete. A CI job whose
|
|
292
|
+
// credentials did not propagate must fail loudly. `--local-only` is the deliberate opt-out.
|
|
293
|
+
if (!provisioners && !args["local-only"]) {
|
|
294
|
+
throw new ValidationError({
|
|
295
|
+
message: "Cloudflare credentials are missing, so the feature's resources cannot be deleted.",
|
|
296
|
+
action:
|
|
297
|
+
"Set CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN to tear down the remote environment, " +
|
|
298
|
+
"or pass --local-only to remove just the worktree and its ports.",
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const store = buildStore(account);
|
|
303
|
+
let report: DestroyReport;
|
|
304
|
+
try {
|
|
305
|
+
report = await destroyFeature({
|
|
306
|
+
projectDir,
|
|
307
|
+
identity,
|
|
308
|
+
capabilities: isUnknown(capabilities) ? [] : [...capabilities],
|
|
309
|
+
...(store && !args["local-only"] ? { store } : {}),
|
|
310
|
+
env: requireEnvironment(args.env ?? DEFAULT_FEATURE_ENV),
|
|
311
|
+
...(provisioners && !args["local-only"] ? { provisioners } : {}),
|
|
312
|
+
// `capabilities`, not `capabilities ?? []`. The teardown below takes the empty set because with
|
|
313
|
+
// `--local-only` there is nothing remote to reconcile, but auditing must not read *unknowable*
|
|
314
|
+
// as *this project composed no trail* — that is the conflation this whole change undoes (#455).
|
|
315
|
+
audit: await buildAudit(projectDir, capabilities, account),
|
|
316
|
+
});
|
|
317
|
+
} catch (error) {
|
|
318
|
+
// A teardown that failed on the fourth delete has already destroyed three, and until #380 the
|
|
319
|
+
// record of them died with the throw. What went is written to stdout here, then the same error
|
|
320
|
+
// is rethrown for `withErrorReporting` to render on stderr and exit 1 on — both streams and the
|
|
321
|
+
// exit code agreeing that it failed and naming what it destroyed on the way.
|
|
322
|
+
const partial = destroyedBeforeFailure(error);
|
|
323
|
+
if (partial) {
|
|
324
|
+
if (args.json) {
|
|
325
|
+
const { command, deleted: deletedResources, ...rest } = partial;
|
|
326
|
+
process.stdout.write(`${formatJsonLine({ command, deletedResources, ...rest, interrupted: true })}\n`);
|
|
327
|
+
} else {
|
|
328
|
+
for (const resource of partial.deleted) process.stdout.write(`Deleted ${resource.name}.\n`);
|
|
329
|
+
process.stdout.write("Teardown stopped there. The rest is still in the account, and in the manifest.\n");
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
throw error;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (args.json) {
|
|
336
|
+
// **`deleted` is published as `deletedResources` (#235).** `pithy vector reset` emits a `deleted`
|
|
337
|
+
// too, and its is a `string[]` of index names where this one is `{kind,name,id}[]`. Two
|
|
338
|
+
// collections under one name is the harder half of the collision to notice — both are truthy,
|
|
339
|
+
// both have a `.length` — so the fix is to say what each holds. This is the record-shaped one,
|
|
340
|
+
// and `deletedResources` also puts it opposite provisioning's `resources`, which is the
|
|
341
|
+
// list it undoes. `DestroyReport` keeps `deleted`: it is teardown's own vocabulary, and the
|
|
342
|
+
// published name is decided here, where the payload is.
|
|
343
|
+
const { command, deleted: deletedResources, ...rest } = report;
|
|
344
|
+
process.stdout.write(`${formatJsonLine({ command, deletedResources, ...rest })}\n`);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
for (const resource of report.deleted) {
|
|
348
|
+
process.stdout.write(`Deleted ${resource.name}.\n`);
|
|
349
|
+
}
|
|
350
|
+
if (!report.remote) process.stdout.write("Remote teardown skipped. Cloudflare resources were left in place.\n");
|
|
351
|
+
if (report.worktreePruned) process.stdout.write("Worktree pruned.\n");
|
|
352
|
+
process.stdout.write(`${formatDone()}\n`);
|
|
353
|
+
}),
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
export default defineCommand({
|
|
357
|
+
meta: { name: "feature", description: "Set up and tear down an isolated, fully-provisioned feature environment" },
|
|
358
|
+
subCommands: { create, sync, destroy },
|
|
359
|
+
});
|