@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,174 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { basename } from "node:path";
|
|
5
|
+
import { kebab } from "@pithy-sh/core/src/naming/resource";
|
|
6
|
+
import { loadProject } from "../project/config";
|
|
7
|
+
import { discoverWorkers, type WorkerTarget } from "../project/workers";
|
|
8
|
+
import { readWranglerConfig } from "../project/wrangler";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Whether a Worker's three names still agree.
|
|
12
|
+
*
|
|
13
|
+
* **A Worker's name is not one string.** It is stamped in three places, and every one of them is read by
|
|
14
|
+
* something different:
|
|
15
|
+
*
|
|
16
|
+
* - the directory, `apps/<name>/`, which tsconfig references and CI working-directories point at;
|
|
17
|
+
* - the deployed script name in `wrangler.jsonc`, which is what Cloudflare serves it under;
|
|
18
|
+
* - `vars.WORKER`, which is what separates two Workers' audit events when they share a database.
|
|
19
|
+
*
|
|
20
|
+
* `pithy worker rename` moves all three together. A rename done by hand — `git mv`, then the edits —
|
|
21
|
+
* moves whichever the person remembered, and nothing until now noticed the rest. The failure is quiet and
|
|
22
|
+
* it is the worst shape a failure can take: the Worker deploys under one name and stamps its events with
|
|
23
|
+
* another, so the audit trail names a Worker that is not the one that acted.
|
|
24
|
+
*
|
|
25
|
+
* This is the counterpart to {@link checkProjectName}, one level down, and it is held to the same
|
|
26
|
+
* evidence discipline: a name is only a fault when this repo's own files positively contradict each
|
|
27
|
+
* other. An adopter who migrated an existing Worker in has a script name Pithy never composed, and that
|
|
28
|
+
* is theirs.
|
|
29
|
+
*/
|
|
30
|
+
export type WorkerNameState =
|
|
31
|
+
/** Every stamp this project declares agrees with the directory it sits in. */
|
|
32
|
+
| "ok"
|
|
33
|
+
/** A `wrangler.jsonc` would not parse, or the worker set would not enumerate. Never fails the exit. */
|
|
34
|
+
| "could-not-check"
|
|
35
|
+
/** A stamp contradicts the directory. Established from local files alone, and it fails the exit. */
|
|
36
|
+
| "drifted";
|
|
37
|
+
|
|
38
|
+
/** One stamp that disagrees with the directory holding it. */
|
|
39
|
+
export interface WorkerNameMismatch {
|
|
40
|
+
/** The `apps/<dir>` basename — the anchor, since tsconfig and CI point at the path, not at a config key. */
|
|
41
|
+
worker: string;
|
|
42
|
+
/** Which stamp disagrees: the deployed script name, or the `WORKER` var. */
|
|
43
|
+
stamp: "name" | "vars.WORKER";
|
|
44
|
+
/** The value the file declares. */
|
|
45
|
+
declared: string;
|
|
46
|
+
/** The value the directory implies. */
|
|
47
|
+
expected: string;
|
|
48
|
+
/**
|
|
49
|
+
* The environment stanzas declaring it — `dev` for the top-level one. Empty for the script name, which
|
|
50
|
+
* is one top-level key rather than a per-environment one.
|
|
51
|
+
*/
|
|
52
|
+
envs: string[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** What `doctor` learned about this project's worker names. */
|
|
56
|
+
export interface WorkerNameCheck {
|
|
57
|
+
state: WorkerNameState;
|
|
58
|
+
mismatches: WorkerNameMismatch[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The `wrangler.jsonc` keys this reads: the script name, and the `WORKER` var in every stanza. */
|
|
62
|
+
interface NamedWorkerConfig {
|
|
63
|
+
name?: string;
|
|
64
|
+
vars?: Record<string, string | undefined>;
|
|
65
|
+
env?: Record<string, NamedWorkerConfig | undefined>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Every environment's stanza: the top-level one (the dev environment) plus each `env.<name>`. */
|
|
69
|
+
function envStanzas(config: NamedWorkerConfig): { env: string; stanza: NamedWorkerConfig }[] {
|
|
70
|
+
const list = [{ env: "dev", stanza: config }];
|
|
71
|
+
for (const [env, stanza] of Object.entries(config.env ?? {})) {
|
|
72
|
+
if (stanza) list.push({ env, stanza });
|
|
73
|
+
}
|
|
74
|
+
return list;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The script-name mismatch for one Worker, or `null` when there is nothing to say.
|
|
79
|
+
*
|
|
80
|
+
* **Shape is the filter, exactly as it is for the project name.** `<project>-<worker>` is what
|
|
81
|
+
* `scaffoldWorker` writes, so a declared name leading with this project's own segment was composed by
|
|
82
|
+
* Pithy's rule and its tail is a worker segment — a tail that is not the directory is this repo
|
|
83
|
+
* contradicting itself. A name that does not lead with the project (`my-service`, brought in from a
|
|
84
|
+
* Worker that predates the project) was never composed from anything, so nothing local establishes what
|
|
85
|
+
* it ought to be, and a check that renamed it would be inventing a fault on the adoption path.
|
|
86
|
+
*/
|
|
87
|
+
function scriptNameMismatch(worker: string, declared: string | undefined, project: string | null): boolean {
|
|
88
|
+
if (declared === undefined || project === null) return false;
|
|
89
|
+
return declared.startsWith(`${project}-`) && declared !== `${project}-${worker}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Group the per-environment `WORKER` disagreements by the value they declare — one line per wrong value. */
|
|
93
|
+
function workerVarMismatches(worker: string, config: NamedWorkerConfig): WorkerNameMismatch[] {
|
|
94
|
+
const byValue = new Map<string, string[]>();
|
|
95
|
+
for (const { env, stanza } of envStanzas(config)) {
|
|
96
|
+
const declared = stanza.vars?.WORKER;
|
|
97
|
+
// A stanza that declares no `WORKER` declares nothing to contradict. Adding one is `pithy worker`'s
|
|
98
|
+
// job, not a diagnostic's, and reporting its absence would fail every Worker written before the var.
|
|
99
|
+
if (declared === undefined || declared === worker) continue;
|
|
100
|
+
byValue.set(declared, [...(byValue.get(declared) ?? []), env]);
|
|
101
|
+
}
|
|
102
|
+
return [...byValue].map(([declared, envs]) => ({
|
|
103
|
+
worker,
|
|
104
|
+
stamp: "vars.WORKER" as const,
|
|
105
|
+
declared,
|
|
106
|
+
expected: worker,
|
|
107
|
+
envs,
|
|
108
|
+
}));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The configured project name, or `null` when the root config would not read or carries none. */
|
|
112
|
+
async function configuredProject(projectDir: string): Promise<string | null> {
|
|
113
|
+
try {
|
|
114
|
+
const config = await loadProject(projectDir);
|
|
115
|
+
// Kebabed through core's own helper, because that is the form `scaffoldWorker` stamped into the
|
|
116
|
+
// script name. An illegal name is not refused here: `checkProjectName` is the check that owns that
|
|
117
|
+
// verdict, and two blocks reporting one fault is how a report starts contradicting itself.
|
|
118
|
+
return config.name ? kebab(config.name) : null;
|
|
119
|
+
} catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Check every Worker under `apps/` for the three-way disagreement a hand-rename leaves.
|
|
126
|
+
*
|
|
127
|
+
* Never throws. A diagnostic has to work in the broken project it exists to diagnose, so an unreadable
|
|
128
|
+
* `wrangler.jsonc` becomes `could-not-check` rather than an exception — and only when nothing else was
|
|
129
|
+
* found. A mismatch this read is a fault whether or not the next file parsed; degrading a positive
|
|
130
|
+
* finding into "I could not check" would hide the fault behind the noise.
|
|
131
|
+
*/
|
|
132
|
+
export async function checkWorkerNames(projectDir: string): Promise<WorkerNameCheck> {
|
|
133
|
+
let workers: WorkerTarget[];
|
|
134
|
+
try {
|
|
135
|
+
workers = await discoverWorkers(projectDir);
|
|
136
|
+
} catch {
|
|
137
|
+
return { state: "could-not-check", mismatches: [] };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const project = await configuredProject(projectDir);
|
|
141
|
+
const mismatches: WorkerNameMismatch[] = [];
|
|
142
|
+
let unreadable = false;
|
|
143
|
+
for (const target of workers) {
|
|
144
|
+
if (!target.hasWrangler) continue; // a non-Worker process in the dev set has no script name at all
|
|
145
|
+
let config: NamedWorkerConfig;
|
|
146
|
+
try {
|
|
147
|
+
config = (await readWranglerConfig(target.dir)) as NamedWorkerConfig;
|
|
148
|
+
} catch {
|
|
149
|
+
unreadable = true;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const worker = basename(target.dir);
|
|
153
|
+
if (scriptNameMismatch(worker, config.name, project) && config.name !== undefined) {
|
|
154
|
+
mismatches.push({
|
|
155
|
+
worker,
|
|
156
|
+
stamp: "name",
|
|
157
|
+
declared: config.name,
|
|
158
|
+
expected: `${project}-${worker}`,
|
|
159
|
+
envs: [],
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
mismatches.push(...workerVarMismatches(worker, config));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (mismatches.length > 0) return { state: "drifted", mismatches };
|
|
166
|
+
return { state: unreadable ? "could-not-check" : "ok", mismatches: [] };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** One mismatch in a sentence — what the stamp does with the wrong name, and what the directory says. */
|
|
170
|
+
export function describeWorkerName(mismatch: WorkerNameMismatch): string {
|
|
171
|
+
return mismatch.stamp === "name"
|
|
172
|
+
? `deploys as ${mismatch.declared}, not ${mismatch.expected}`
|
|
173
|
+
: `stamps events as ${mismatch.declared}, not ${mismatch.expected}`;
|
|
174
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { readWranglerConfig } from "../project/wrangler";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Every variable name one Worker's `wrangler.jsonc` declares — the top-level `vars` block **and** every
|
|
8
|
+
* `env.<name>.vars`.
|
|
9
|
+
*
|
|
10
|
+
* **`env.<name>.vars` REPLACES the top-level block rather than merging it**, which is the gotcha the
|
|
11
|
+
* starter's own `wrangler.jsonc` comment warns about: every environment repeats every variable. So a
|
|
12
|
+
* name counts as declared if it appears at the top level **or** in any environment — reading only the
|
|
13
|
+
* top level names a staging-only variable as undeclared, and reading only one environment names every
|
|
14
|
+
* ordinary variable as undeclared.
|
|
15
|
+
*
|
|
16
|
+
* **It lives here rather than in either caller.** Two doctor checks ask this question — what a
|
|
17
|
+
* `.dev.vars.local` key has behind it, and what a root `.dev.vars` key has behind it — and a rule this
|
|
18
|
+
* easy to get half-right is a rule that must exist once. An unreadable or absent config declares
|
|
19
|
+
* nothing, which is the honest answer: the health block is where a `wrangler.jsonc` that will not parse
|
|
20
|
+
* gets said, and louder.
|
|
21
|
+
*/
|
|
22
|
+
export async function declaredVars(workerDir: string): Promise<Set<string>> {
|
|
23
|
+
const config = (await readWranglerConfig(workerDir).catch(() => null)) as {
|
|
24
|
+
vars?: Record<string, unknown>;
|
|
25
|
+
env?: Record<string, { vars?: Record<string, unknown> } | undefined>;
|
|
26
|
+
} | null;
|
|
27
|
+
const keys = new Set<string>();
|
|
28
|
+
for (const key of Object.keys(config?.vars ?? {})) keys.add(key);
|
|
29
|
+
for (const environment of Object.values(config?.env ?? {})) {
|
|
30
|
+
for (const key of Object.keys(environment?.vars ?? {})) keys.add(key);
|
|
31
|
+
}
|
|
32
|
+
return keys;
|
|
33
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { BindingType } from "@pithy-sh/core/src/capability/bindings";
|
|
5
|
+
import type { Capability } from "@pithy-sh/core/src/capability/capability";
|
|
6
|
+
import type { FeatureResourceKind } from "@pithy-sh/core/src/naming/feature";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A single Cloudflare resource `pithy provision --feature` stands up: the Worker binding it backs and the
|
|
10
|
+
* kind of resource that binding needs. Derived purely from the composed capabilities' `requiredBindings`
|
|
11
|
+
* — no hand-maintained list — so enabling a capability automatically brings its resources into the set.
|
|
12
|
+
*/
|
|
13
|
+
export interface ProvisionableBinding {
|
|
14
|
+
/** The Worker binding name (e.g. "DB", "SESSIONS", "ASSETS"). */
|
|
15
|
+
binding: string;
|
|
16
|
+
/** The provisionable resource kind this binding needs. */
|
|
17
|
+
kind: FeatureResourceKind;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The subset of {@link BindingType}s `pithy feature` provisions as standalone, per-feature resources.
|
|
22
|
+
* D1 databases, KV namespaces, and R2 buckets each get their own ephemeral resource. Every other
|
|
23
|
+
* binding kind is either account-scoped (`ai`), wired in config rather than provisioned (`durable_object`,
|
|
24
|
+
* `service`, `workflow`, `queue`), or owned by another capability's provisioner (`secret`, `email`) — so
|
|
25
|
+
* `provision` skips them. Extend this map (and the CF provisioners) to grow the provisionable set.
|
|
26
|
+
*/
|
|
27
|
+
const PROVISIONABLE: Partial<Record<BindingType, FeatureResourceKind>> = {
|
|
28
|
+
d1: "d1",
|
|
29
|
+
kv: "kv",
|
|
30
|
+
r2: "r2",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The provisionable resources for a set of enabled capabilities: every `d1`/`kv`/`r2` binding they
|
|
35
|
+
* declare, deduplicated by binding name (two capabilities may share one binding — one resource backs it),
|
|
36
|
+
* in a stable order. Non-provisionable binding kinds are dropped. This is the auto-discovered set
|
|
37
|
+
* `provision` creates one CF resource per and `destroy` reconciles against.
|
|
38
|
+
*/
|
|
39
|
+
/** A worker-to-worker RPC binding: the env name it appears under, and the Worker it calls. */
|
|
40
|
+
export interface ServiceBinding {
|
|
41
|
+
/** The binding name in the Worker env (e.g. "API"). */
|
|
42
|
+
binding: string;
|
|
43
|
+
/** The target Worker, as named in `apps/<name>/`. */
|
|
44
|
+
target: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Every `service` binding the enabled capabilities declare, deduplicated by binding name. These are not
|
|
49
|
+
* provisioned — the target Worker is deployed, not created — but each one must be *rewritten per
|
|
50
|
+
* environment*: in a feature environment the binding has to point at that feature's own deployment of the
|
|
51
|
+
* target, not at production's. `provision` resolves each target to its environment-scoped script name.
|
|
52
|
+
*/
|
|
53
|
+
export function serviceBindings(capabilities: Capability[]): ServiceBinding[] {
|
|
54
|
+
const seen = new Set<string>();
|
|
55
|
+
const result: ServiceBinding[] = [];
|
|
56
|
+
for (const capability of capabilities) {
|
|
57
|
+
for (const spec of capability.requiredBindings) {
|
|
58
|
+
// `service` is guaranteed by BindingSpec's check; the guard keeps this total for a hand-built spec.
|
|
59
|
+
if (spec.type !== "service" || !spec.service || seen.has(spec.name)) continue;
|
|
60
|
+
seen.add(spec.name);
|
|
61
|
+
result.push({ binding: spec.name, target: spec.service });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function provisionableBindings(
|
|
68
|
+
capabilities: readonly Capability[],
|
|
69
|
+
declined: ReadonlySet<string> = new Set(),
|
|
70
|
+
): ProvisionableBinding[] {
|
|
71
|
+
const seen = new Set<string>();
|
|
72
|
+
const result: ProvisionableBinding[] = [];
|
|
73
|
+
for (const capability of capabilities) {
|
|
74
|
+
for (const spec of capability.requiredBindings) {
|
|
75
|
+
const kind = PROVISIONABLE[spec.type];
|
|
76
|
+
if (!kind || seen.has(spec.name)) continue;
|
|
77
|
+
// **A declined binding gets no resource.** Stopping `pithy upgrade` writing the binding while
|
|
78
|
+
// provisioning still created the bucket behind it handed the adopter exactly the resource they had
|
|
79
|
+
// declined (#440). `declined` is resolved once, by `honoredDeclines` in the reconcile engine, so
|
|
80
|
+
// this reads an answer rather than deciding one — a second expression of that rule is how the two
|
|
81
|
+
// would come to disagree.
|
|
82
|
+
//
|
|
83
|
+
// `spec.optional` is asked again anyway, and deliberately. `resolveDeclines` already refuses a
|
|
84
|
+
// decline of a required binding, but this function is reached by a different command through a
|
|
85
|
+
// different path, and a provisioning step that could skip a required resource on a name alone is
|
|
86
|
+
// one bad set away from a project whose database was never created.
|
|
87
|
+
if (spec.optional && declined.has(spec.name)) continue;
|
|
88
|
+
seen.add(spec.name);
|
|
89
|
+
result.push({ binding: spec.name, kind });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { migrateProject } from "../migrations/run";
|
|
5
|
+
import { loadProject, loadProjectCloudflare, requireProjectName } from "../project/config";
|
|
6
|
+
import { detectPackageManager, type InstallRunner } from "../project/packageManager";
|
|
7
|
+
import type { WorkerTarget } from "../project/workers";
|
|
8
|
+
import { seedProject } from "../seed/run";
|
|
9
|
+
import type { DevConfig } from "./devConfig";
|
|
10
|
+
import { syncFeatureDevConfig } from "./sync";
|
|
11
|
+
import { createWorktree, defaultGit, type GitRunner } from "./worktree";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `pithy feature create` — the local, automatic half of the lifecycle, run from the main checkout. It
|
|
15
|
+
* creates the branch + worktree (the proven core), reserves a non-overlapping port block and pins one port
|
|
16
|
+
* per worker into the worktree's `.dev.config.json`, installs deps with the adopter's package manager, then
|
|
17
|
+
* migrates and seeds the local Miniflare backend. No Cloudflare spend — a working local env, ready for
|
|
18
|
+
* `pithy dev`, from one command.
|
|
19
|
+
*
|
|
20
|
+
* No `.dev.vars` is wired here, and none is copied. Each Worker's is generated by `pithy dev` from this
|
|
21
|
+
* machine's own sources (#154), so a fresh worktree needs nothing carried into it.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** A local/dev backend step (migrate or seed) — a seam so create is testable without Miniflare. */
|
|
25
|
+
export type LocalRunner = (args: { projectDir: string }) => Promise<void>;
|
|
26
|
+
|
|
27
|
+
// A `dev` migrate names its project too: the local Miniflare store lives at the project root and is
|
|
28
|
+
// shared with `wrangler dev`, and this is usually the run that first stamps it. An unstamped database is
|
|
29
|
+
// one any project can later claim, so the first write is the only chance to record the owner.
|
|
30
|
+
const localMigrate: LocalRunner = async ({ projectDir }) => {
|
|
31
|
+
// One config load, two facts: the name the database is stamped with, and the account it is reached in.
|
|
32
|
+
// Both are read from the **worktree's** own `pithy.config.ts`, because the branch being created is what
|
|
33
|
+
// decides them and it may already differ from the checkout this command was run in (#234).
|
|
34
|
+
const config = await loadProject(projectDir);
|
|
35
|
+
await migrateProject({
|
|
36
|
+
env: "dev",
|
|
37
|
+
projectDir,
|
|
38
|
+
project: requireProjectName(config),
|
|
39
|
+
account: loadProjectCloudflare(config) ?? null,
|
|
40
|
+
});
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Even a `dev` seed names its project: Cloudflare Images and Stream have no local emulation, so a media
|
|
44
|
+
// fixture here writes into the same account-wide store production shares, and only the ownership metadata
|
|
45
|
+
// says who put it there.
|
|
46
|
+
const localSeed: LocalRunner = async ({ projectDir }) => {
|
|
47
|
+
const config = await loadProject(projectDir);
|
|
48
|
+
await seedProject({
|
|
49
|
+
env: "dev",
|
|
50
|
+
projectDir,
|
|
51
|
+
project: requireProjectName(config),
|
|
52
|
+
account: loadProjectCloudflare(config) ?? null,
|
|
53
|
+
json: true,
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The structured outcome of `pithy feature create` — the `--json` payload and the human summary source.
|
|
59
|
+
*
|
|
60
|
+
* **It says nothing about migrate and seed, and that is the change (#231).** It used to carry
|
|
61
|
+
* `migrated: true, seeded: true`, both written as literals a few lines below the two `await`s. Neither
|
|
62
|
+
* step has a flag here and both throw on failure, so a report that exists at all is the proof they ran:
|
|
63
|
+
* the pair was a constant with the grammar of a fact, and the only branch a consumer could write on it
|
|
64
|
+
* — `if (migrated && !seeded)` — is one that can never fire and can never be tested. If create ever
|
|
65
|
+
* grows a `--skip-data` of its own, `feature sync`'s `data` is the field to copy, because a value that
|
|
66
|
+
* can be `false` is the only kind worth emitting.
|
|
67
|
+
*/
|
|
68
|
+
export interface CreateReport {
|
|
69
|
+
/** The command that produced the report. */
|
|
70
|
+
command: "feature.create";
|
|
71
|
+
/** The feature branch created or attached. */
|
|
72
|
+
branch: string;
|
|
73
|
+
/** The absolute worktree path. */
|
|
74
|
+
worktree: string;
|
|
75
|
+
/**
|
|
76
|
+
* Whether a new worktree was created (false on an idempotent re-run over an existing one).
|
|
77
|
+
*
|
|
78
|
+
* **Named for what it created (#235).** It was `created`, and `pithy ui add` emits a `created` of its
|
|
79
|
+
* own that is a `string[]` of files — one name, two types, so `if (result.created)` is true for a
|
|
80
|
+
* non-empty file list and true for this boolean, and a consumer that guessed wrong got no error. The
|
|
81
|
+
* subject is the outlier here: `ui`'s says *what* was created, this one says *whether*, and the thing it
|
|
82
|
+
* says it about is already in the payload beside it. `feature.destroy` had settled the convention
|
|
83
|
+
* anyway — `portsFreed`, `worktreePruned`, `branchDeleted` — so a bare `created` was the odd one out
|
|
84
|
+
* inside its own command before it was one across the CLI.
|
|
85
|
+
*/
|
|
86
|
+
worktreeCreated: boolean;
|
|
87
|
+
/**
|
|
88
|
+
* The ref the branch was cut from — the trunk's name, or `"HEAD"`. **Null when nothing was cut**: an
|
|
89
|
+
* already-registered worktree, or a branch that already existed and was attached to.
|
|
90
|
+
*
|
|
91
|
+
* The command reports how far the trunk is behind its remote, and only when this is non-null. On the
|
|
92
|
+
* attach path that sentence would be about a base somebody else chose months ago — a false sentence
|
|
93
|
+
* about a base, which is the defect `#454` is about.
|
|
94
|
+
*/
|
|
95
|
+
base: string | null;
|
|
96
|
+
/** The feature's dev config — its reserved port block and each worker's pinned endpoint. */
|
|
97
|
+
dev: DevConfig;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Options for {@link createFeature}. */
|
|
101
|
+
export interface CreateFeatureOptions {
|
|
102
|
+
/** The main checkout root — the port registry's key, and where the source `.dev.vars` lives. */
|
|
103
|
+
projectDir: string;
|
|
104
|
+
/** The issue number. */
|
|
105
|
+
issue: string;
|
|
106
|
+
/** The kebab-case slug. */
|
|
107
|
+
slug: string;
|
|
108
|
+
/** Skip the dependency install (tests set this; a real run installs so the gates run in the tree). */
|
|
109
|
+
skipInstall?: boolean;
|
|
110
|
+
/** Ports per feature block (defaults to the registry's block size). */
|
|
111
|
+
blockSize?: number;
|
|
112
|
+
/** The registry file (default: `<config>/dev-ports.json`). A seam, so a test never writes the real one. */
|
|
113
|
+
registryPath?: string;
|
|
114
|
+
/** git runner seam. */
|
|
115
|
+
git?: GitRunner;
|
|
116
|
+
/** Install runner seam. */
|
|
117
|
+
install?: InstallRunner;
|
|
118
|
+
/** Local migrate seam (default: `migrateProject` against dev). */
|
|
119
|
+
migrate?: LocalRunner;
|
|
120
|
+
/** Local seed seam (default: `seedProject` against dev). */
|
|
121
|
+
seed?: LocalRunner;
|
|
122
|
+
/** Worker-discovery seam (default: `discoverWorkers`), so tests fix the worker set. */
|
|
123
|
+
discoverWorkers?: (projectDir: string) => Promise<WorkerTarget[]>;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Stand up a feature's local environment. Creates the worktree, reserves its port block and pins one port
|
|
128
|
+
* per worker into `.dev.config.json`, installs deps (unless skipped),
|
|
129
|
+
* then migrates and seeds the local backend. Idempotent: a re-run reuses the existing worktree, keeps its
|
|
130
|
+
* port block (so every worker's address is stable for the life of the feature), and re-runs migrate/seed
|
|
131
|
+
* harmlessly.
|
|
132
|
+
*/
|
|
133
|
+
export async function createFeature(options: CreateFeatureOptions): Promise<CreateReport> {
|
|
134
|
+
const git = options.git ?? defaultGit;
|
|
135
|
+
const worktree = await createWorktree({ issue: options.issue, slug: options.slug, git });
|
|
136
|
+
|
|
137
|
+
// Install before discovering workers so a freshly-cut worktree has its dependencies in place.
|
|
138
|
+
if (!options.skipInstall) {
|
|
139
|
+
const pm = await detectPackageManager(worktree.wtPath);
|
|
140
|
+
const installer = options.install ?? defaultInstall;
|
|
141
|
+
await installer(pm, ["install"], worktree.wtPath);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Reserve the block and pin every worker's port — the same reconciliation `pithy feature sync` runs, so
|
|
145
|
+
// creating and later adding a worker go through one implementation.
|
|
146
|
+
const { dev } = await syncFeatureDevConfig({
|
|
147
|
+
mainRoot: worktree.root,
|
|
148
|
+
worktreePath: worktree.wtPath,
|
|
149
|
+
branch: worktree.branch,
|
|
150
|
+
...(options.registryPath !== undefined ? { registryPath: options.registryPath } : {}),
|
|
151
|
+
...(options.blockSize !== undefined ? { blockSize: options.blockSize } : {}),
|
|
152
|
+
...(options.discoverWorkers !== undefined ? { discoverWorkers: options.discoverWorkers } : {}),
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// Migrate and seed run against the **worktree**, and discover its Workers themselves: the branch being
|
|
156
|
+
// created is what decides which Workers exist and what each composes, and it may already differ from the
|
|
157
|
+
// main checkout this command was run in.
|
|
158
|
+
const migrate = options.migrate ?? localMigrate;
|
|
159
|
+
const seed = options.seed ?? localSeed;
|
|
160
|
+
// Both throw on failure, so reaching the return is what says they ran. Nothing in the report repeats it.
|
|
161
|
+
await migrate({ projectDir: worktree.wtPath });
|
|
162
|
+
await seed({ projectDir: worktree.wtPath });
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
command: "feature.create",
|
|
166
|
+
branch: worktree.branch,
|
|
167
|
+
worktree: worktree.wtPath,
|
|
168
|
+
worktreeCreated: worktree.created,
|
|
169
|
+
base: worktree.base,
|
|
170
|
+
dev,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The default install runner — spawn the detected package manager. Lazy import keeps it out of the seam type. */
|
|
175
|
+
const defaultInstall: InstallRunner = async (command, args, cwd) => {
|
|
176
|
+
const { execFile } = await import("node:child_process");
|
|
177
|
+
const { promisify } = await import("node:util");
|
|
178
|
+
await promisify(execFile)(command, args, { cwd });
|
|
179
|
+
};
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { rm } from "node:fs/promises";
|
|
5
|
+
import type { Capability } from "@pithy-sh/core/src/capability/capability";
|
|
6
|
+
import type { FeatureIdentity } from "@pithy-sh/core/src/naming/feature";
|
|
7
|
+
import { partialWriteReport } from "@pithy-sh/secrets/src/cli/partialWrite";
|
|
8
|
+
import type { CliAuditEmit } from "../audit/cliAudit";
|
|
9
|
+
import type { ResourceProvisioners } from "../provision/resources";
|
|
10
|
+
import type { SecretsStore } from "../provision/store";
|
|
11
|
+
import { devConfigPath } from "./devConfig";
|
|
12
|
+
import { freePortBlock, portsRegistryPath, resolveMainRepoRoot } from "./ports";
|
|
13
|
+
import { type DeprovisionedResource, deletedBeforeFailure, deprovisionFeature } from "./provision";
|
|
14
|
+
import { defaultGit, type GitRunner, teardownWorktree } from "./worktree";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `pithy feature destroy` — the teardown half, run from within the worktree. It reverses both remote and
|
|
18
|
+
* local, in order: delete the manifest's Cloudflare resources then reconcile by prefix-scan, free the
|
|
19
|
+
* feature's port block, and finally prune the worktree the Linux-safe way. Every step is idempotent, so a
|
|
20
|
+
* partial-failed provision or a half-torn-down feature still tears down to zero, exiting 0. It is exactly
|
|
21
|
+
* what the merge-to-main CI job runs headlessly.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** The structured outcome of `pithy feature destroy` — the `--json` payload and the human summary source. */
|
|
25
|
+
export interface DestroyReport {
|
|
26
|
+
/** The command that produced the report. */
|
|
27
|
+
command: "feature.destroy";
|
|
28
|
+
/** Every Cloudflare resource deleted (manifest + reconcile). Empty when nothing remained or remote was skipped. */
|
|
29
|
+
deleted: DeprovisionedResource[];
|
|
30
|
+
/** Whether the remote teardown ran (false when no provisioners were available, e.g. no CF credentials). */
|
|
31
|
+
remote: boolean;
|
|
32
|
+
/** Whether the feature's port block was freed. */
|
|
33
|
+
portsFreed: boolean;
|
|
34
|
+
/** Whether a registered worktree was pruned. */
|
|
35
|
+
worktreePruned: boolean;
|
|
36
|
+
/** Whether the feature branch was deleted (only when merged). */
|
|
37
|
+
branchDeleted: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A carried value arrives as `unknown`; this is the narrowing, never a cast. */
|
|
41
|
+
function isDestroyReport(value: unknown): value is DestroyReport {
|
|
42
|
+
if (typeof value !== "object" || value === null) return false;
|
|
43
|
+
const candidate = value as Partial<DestroyReport>;
|
|
44
|
+
return candidate.command === "feature.destroy" && Array.isArray(candidate.deleted);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* **Where the record of a teardown that failed partway rides out of it (#380).**
|
|
49
|
+
*
|
|
50
|
+
* A teardown destroys infrastructure and reports what it destroyed. Until now a throw from the remote
|
|
51
|
+
* half took that report with it, so an operator whose token expired on the fourth of five deletes was
|
|
52
|
+
* told only that it failed — and the three databases that were already gone were gone unrecorded. The
|
|
53
|
+
* report is the whole product of this command, so it survives the failure the same way a partial mint
|
|
54
|
+
* does (#324).
|
|
55
|
+
*/
|
|
56
|
+
const deprovisionReport = partialWriteReport<DestroyReport>("pithy.cli.destroyReport", isDestroyReport);
|
|
57
|
+
|
|
58
|
+
/** What a failed {@link destroyFeature} run tore down before it failed, or `undefined` for any other throw. */
|
|
59
|
+
export function destroyedBeforeFailure(error: unknown): DestroyReport | undefined {
|
|
60
|
+
return deprovisionReport.read(error);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Options for {@link destroyFeature}. */
|
|
64
|
+
export interface DestroyFeatureOptions {
|
|
65
|
+
/** The worktree root — where the manifest lives and the branch is checked out. */
|
|
66
|
+
projectDir: string;
|
|
67
|
+
/** The feature identity — project/issue/slug — for recomputing resource names and the branch name. */
|
|
68
|
+
identity: FeatureIdentity;
|
|
69
|
+
/**
|
|
70
|
+
* Every capability the feature spans — the union of its Workers' own configs, the same one `provision`
|
|
71
|
+
* derived resource names from. The remote reconcile recomputes those exact names from it.
|
|
72
|
+
*/
|
|
73
|
+
capabilities: Capability[];
|
|
74
|
+
/** The environment being torn down. Recorded on each audit event. */
|
|
75
|
+
env: string;
|
|
76
|
+
/** The provisioners to delete through, or undefined to skip remote teardown (e.g. no CF credentials). */
|
|
77
|
+
provisioners?: ResourceProvisioners;
|
|
78
|
+
/**
|
|
79
|
+
* The account's Secrets Store, when one is reachable. Teardown removes the entries this feature
|
|
80
|
+
* created; a store entry left behind is a live credential in a flat namespace with nothing pointing
|
|
81
|
+
* at it.
|
|
82
|
+
*/
|
|
83
|
+
store?: SecretsStore;
|
|
84
|
+
/** Audit emitter, so every deletion leaves a record. Defaults to recording nothing. */
|
|
85
|
+
audit?: CliAuditEmit;
|
|
86
|
+
/** git runner seam. */
|
|
87
|
+
git?: GitRunner;
|
|
88
|
+
/** Override the registry file (tests inject; a real run resolves `<config>/dev-ports.json`). */
|
|
89
|
+
registryPath?: string;
|
|
90
|
+
/** Override the main checkout root, the registry's key (tests inject; a real run resolves it via git-common-dir). */
|
|
91
|
+
root?: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Tear a feature down. Delete its Cloudflare resources (manifest ids, then prefix-scan reconcile) when
|
|
96
|
+
* provisioners are available, free its port block, and prune its worktree + branch — in that order.
|
|
97
|
+
* Idempotent end to end: already-gone resources, an unallocated port block, and an absent worktree are all
|
|
98
|
+
* clean no-ops, so re-running (or running on a never-provisioned feature) exits without error.
|
|
99
|
+
*/
|
|
100
|
+
export async function destroyFeature(options: DestroyFeatureOptions): Promise<DestroyReport> {
|
|
101
|
+
const git = options.git ?? defaultGit;
|
|
102
|
+
|
|
103
|
+
let deleted: DeprovisionedResource[] = [];
|
|
104
|
+
const remote = options.provisioners !== undefined;
|
|
105
|
+
if (options.provisioners) {
|
|
106
|
+
try {
|
|
107
|
+
const report = await deprovisionFeature({
|
|
108
|
+
projectDir: options.projectDir,
|
|
109
|
+
identity: options.identity,
|
|
110
|
+
capabilities: options.capabilities,
|
|
111
|
+
env: options.env,
|
|
112
|
+
provisioners: options.provisioners,
|
|
113
|
+
...(options.store !== undefined ? { store: options.store } : {}),
|
|
114
|
+
...(options.audit !== undefined ? { audit: options.audit } : {}),
|
|
115
|
+
});
|
|
116
|
+
deleted = report.deleted;
|
|
117
|
+
} catch (error) {
|
|
118
|
+
// What the remote half destroyed before it failed, moved onto this report and carried on again so
|
|
119
|
+
// the command can print it beside the failure (#380). The local half below deliberately does not
|
|
120
|
+
// run: pruning the worktree would remove the checkout the re-run has to happen from, and the
|
|
121
|
+
// feature manifest that says what is left to delete lives in it.
|
|
122
|
+
throw deprovisionReport.carry(error, {
|
|
123
|
+
command: "feature.destroy",
|
|
124
|
+
deleted: deletedBeforeFailure(error),
|
|
125
|
+
remote,
|
|
126
|
+
portsFreed: false,
|
|
127
|
+
worktreePruned: false,
|
|
128
|
+
branchDeleted: false,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const registryPath = options.registryPath ?? portsRegistryPath();
|
|
134
|
+
// The same git-common-dir derivation the registry key was always freed by — `projectDir` is the
|
|
135
|
+
// worktree, and this is the main checkout it belongs to. `ports.test.ts` pins it against the
|
|
136
|
+
// `git worktree list` derivation `feature create` reserves under, because a key freed under a root
|
|
137
|
+
// create never wrote is a no-op that still reports `portsFreed: true` (#435).
|
|
138
|
+
const root = options.root ?? (await resolveMainRepoRoot(options.projectDir));
|
|
139
|
+
const branch = `feature/${options.identity.issue}-${options.identity.slug}`;
|
|
140
|
+
// Drop the feature's pinned ports **before** freeing its registry key, and in that order. Teardown leaves
|
|
141
|
+
// the worktree's files on disk by design (recursive deletion is what we must never do on Linux), and
|
|
142
|
+
// `.dev.config.json` is a port claim: every later `feature create`/`sync` rebuilds the registry from the
|
|
143
|
+
// pinned blocks it finds under `.worktrees`, so a surviving one hands this branch its block straight back —
|
|
144
|
+
// permanently, to a feature that no longer exists. Removing one file is not a recursive delete. If the run
|
|
145
|
+
// dies between the two steps, the registry is the only claim left and a re-run clears it; the reverse order
|
|
146
|
+
// would leave the stale claim to be reclaimed.
|
|
147
|
+
await rm(devConfigPath(options.projectDir), { force: true });
|
|
148
|
+
await freePortBlock({ registryPath, root, branch });
|
|
149
|
+
|
|
150
|
+
const teardown = await teardownWorktree({ issue: options.identity.issue, slug: options.identity.slug, git });
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
command: "feature.destroy",
|
|
154
|
+
deleted,
|
|
155
|
+
remote,
|
|
156
|
+
portsFreed: true,
|
|
157
|
+
worktreePruned: teardown.pruned,
|
|
158
|
+
branchDeleted: teardown.branchDeleted,
|
|
159
|
+
};
|
|
160
|
+
}
|