@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,201 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { fromZodError, InternalError } from "@pithy-sh/core/src/error/pithyError";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { writeFileAtomic } from "../project/atomic";
|
|
9
|
+
import type { WorkerTarget } from "../project/workers";
|
|
10
|
+
import type { PortBlock } from "./ports";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The per-feature dev config — a git-ignored `.dev.config.json` at the worktree root, written once by
|
|
14
|
+
* `pithy feature create` and fixed for the life of the feature.
|
|
15
|
+
*
|
|
16
|
+
* It exists because `.dev.vars` is a single file shared repo-wide across every worktree (each worktree's
|
|
17
|
+
* copy is a symlink to the main checkout's), so it cannot carry per-feature values without one feature
|
|
18
|
+
* clobbering another. This file is that per-feature surface.
|
|
19
|
+
*
|
|
20
|
+
* Ports are **assigned here at creation time, not discovered at startup** — that is the whole point.
|
|
21
|
+
* Probing for a free port when a worker boots is a time-of-check/time-of-use race: two `pithy dev`
|
|
22
|
+
* processes in two worktrees can both see the same port free and both try to bind it. Pre-assigning every
|
|
23
|
+
* worker its own port from the feature's reserved block makes N features run simultaneously with no
|
|
24
|
+
* probing, no race, and stable addresses each worker can use to auto-wire to its siblings.
|
|
25
|
+
*
|
|
26
|
+
* It is named for what it is — the feature's dev config — not for ports alone, so later per-feature dev
|
|
27
|
+
* settings land here too without a rename.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** One worker's fixed local endpoint for the life of the feature. */
|
|
31
|
+
export const DevWorkerConfig = z
|
|
32
|
+
.object({
|
|
33
|
+
port: z.number().int().positive().describe("The port this worker binds locally, reserved at feature creation."),
|
|
34
|
+
origin: z.string().describe("The origin sibling workers reach this one at, e.g. http://localhost:8787."),
|
|
35
|
+
})
|
|
36
|
+
.describe("One worker's fixed local endpoint within its feature's reserved port block.");
|
|
37
|
+
export type DevWorkerConfig = z.output<typeof DevWorkerConfig>;
|
|
38
|
+
|
|
39
|
+
/** The feature's reserved slice of the central port registry. */
|
|
40
|
+
export const DevPortBlock = z
|
|
41
|
+
.object({
|
|
42
|
+
index: z
|
|
43
|
+
.number()
|
|
44
|
+
.int()
|
|
45
|
+
.nonnegative()
|
|
46
|
+
.describe("The block's index in the machine's <config>/dev-ports.json registry."),
|
|
47
|
+
base: z.number().int().positive().describe("The first port in the block."),
|
|
48
|
+
size: z.number().int().positive().describe("How many ports the block spans — its worker capacity."),
|
|
49
|
+
})
|
|
50
|
+
.describe("The contiguous port block this feature owns, allocated from the central registry.");
|
|
51
|
+
export type DevPortBlock = z.output<typeof DevPortBlock>;
|
|
52
|
+
|
|
53
|
+
/** The `.dev.config.json` document. */
|
|
54
|
+
export const DevConfig = z
|
|
55
|
+
.object({
|
|
56
|
+
version: z.literal(1).describe("Dev-config schema version."),
|
|
57
|
+
branch: z.string().describe("The feature branch this config belongs to, e.g. feature/69-media-cli."),
|
|
58
|
+
ports: DevPortBlock.describe("The feature's reserved port block."),
|
|
59
|
+
workers: z
|
|
60
|
+
.record(z.string(), DevWorkerConfig)
|
|
61
|
+
.describe("Each worker's fixed endpoint, keyed by worker name — the map pithy dev starts from."),
|
|
62
|
+
})
|
|
63
|
+
.describe("A feature's per-worktree dev configuration (git-ignored): its reserved ports and per-worker endpoints.");
|
|
64
|
+
export type DevConfig = z.output<typeof DevConfig>;
|
|
65
|
+
|
|
66
|
+
/** The dev-config path for a worktree: `<worktreeDir>/.dev.config.json`. */
|
|
67
|
+
export function devConfigPath(worktreeDir: string): string {
|
|
68
|
+
return join(worktreeDir, ".dev.config.json");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Build the feature's dev config: one port per worker, drawn from the reserved block.
|
|
73
|
+
*
|
|
74
|
+
* **Assignment is sticky.** A worker that already has a port in `previous` keeps it, whatever the current
|
|
75
|
+
* discovery order — worker discovery is alphabetical, so a purely positional assignment would renumber every
|
|
76
|
+
* later worker the moment someone adds one that sorts earlier, moving addresses out from under a running dev
|
|
77
|
+
* session. Only genuinely new workers are assigned, each taking the lowest port in the block that nothing
|
|
78
|
+
* else holds; a worker that goes away releases its port back to the block for reuse.
|
|
79
|
+
*
|
|
80
|
+
* Fails when the feature has more workers than its block holds rather than spilling into the next feature's
|
|
81
|
+
* ports — a silent overlap is exactly the collision the block allocation exists to prevent.
|
|
82
|
+
*/
|
|
83
|
+
export function buildDevConfig(args: {
|
|
84
|
+
branch: string;
|
|
85
|
+
block: PortBlock;
|
|
86
|
+
workers: WorkerTarget[];
|
|
87
|
+
/** The feature's current dev config, whose worker→port pairs are preserved. Omit on first creation. */
|
|
88
|
+
previous?: DevConfig | null;
|
|
89
|
+
}): DevConfig {
|
|
90
|
+
if (args.workers.length > args.block.size) {
|
|
91
|
+
throw new InternalError({
|
|
92
|
+
message: `This feature has ${args.workers.length} workers but its port block holds only ${args.block.size}.`,
|
|
93
|
+
// The count includes each composed capability's host Worker, which `pithy dev` starts beside the
|
|
94
|
+
// `apps/*` set — so removing a capability is as much a fix here as removing a Worker.
|
|
95
|
+
action: "Remove a worker or a capability, or widen the feature port-block size.",
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const inBlock = (port: number): boolean => port >= args.block.base && port < args.block.base + args.block.size;
|
|
100
|
+
const kept = new Map<string, number>();
|
|
101
|
+
for (const worker of args.workers) {
|
|
102
|
+
// Carry a port over only when it still falls inside the block (a resized or reallocated block invalidates it).
|
|
103
|
+
const port = args.previous?.workers[worker.name]?.port;
|
|
104
|
+
if (port !== undefined && inBlock(port)) kept.set(worker.name, port);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const taken = new Set(kept.values());
|
|
108
|
+
const nextFree = (): number => {
|
|
109
|
+
for (let port = args.block.base; port < args.block.base + args.block.size; port += 1) {
|
|
110
|
+
if (!taken.has(port)) return port;
|
|
111
|
+
}
|
|
112
|
+
// Unreachable: the worker-count guard above already bounds demand to the block's size.
|
|
113
|
+
throw new InternalError({ detail: `No free port left in block ${args.block.block}.` });
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const workers: Record<string, DevWorkerConfig> = {};
|
|
117
|
+
for (const worker of args.workers) {
|
|
118
|
+
let port = kept.get(worker.name);
|
|
119
|
+
if (port === undefined) {
|
|
120
|
+
port = nextFree();
|
|
121
|
+
taken.add(port);
|
|
122
|
+
}
|
|
123
|
+
workers[worker.name] = { port, origin: `http://localhost:${port}` };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
version: 1,
|
|
128
|
+
branch: args.branch,
|
|
129
|
+
ports: { index: args.block.block, base: args.block.base, size: args.block.size },
|
|
130
|
+
workers,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Zod-validate then write the dev config atomically (pretty JSON, trailing newline). */
|
|
135
|
+
export async function writeDevConfig(path: string, config: DevConfig): Promise<void> {
|
|
136
|
+
const parsed = DevConfig.safeParse(config);
|
|
137
|
+
if (!parsed.success) {
|
|
138
|
+
throw fromZodError(parsed.error, {
|
|
139
|
+
message: "The feature dev config is invalid.",
|
|
140
|
+
action: "Re-run pithy feature create to rebuild .dev.config.json.",
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
await writeFileAtomic(path, `${JSON.stringify(parsed.data, null, 2)}\n`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Every port block currently pinned on disk, read from each worktree's own `.dev.config.json` under
|
|
148
|
+
* `<mainRoot>/.worktrees`. This is what lets a lost port registry be rebuilt from the worktrees that
|
|
149
|
+
* outlived it (see `reclaimPortBlocks`).
|
|
150
|
+
* Unreadable or malformed configs are skipped rather than failing the scan — one bad worktree must not block
|
|
151
|
+
* creating a new feature.
|
|
152
|
+
*/
|
|
153
|
+
export async function scanPinnedBlocks(mainRoot: string): Promise<{ branch: string; block: PortBlock }[]> {
|
|
154
|
+
let entries: string[];
|
|
155
|
+
try {
|
|
156
|
+
entries = await readdir(join(mainRoot, ".worktrees"));
|
|
157
|
+
} catch {
|
|
158
|
+
return []; // no .worktrees yet — nothing to reclaim.
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const found: { branch: string; block: PortBlock }[] = [];
|
|
162
|
+
for (const entry of entries) {
|
|
163
|
+
const config = await readDevConfig(devConfigPath(join(mainRoot, ".worktrees", entry))).catch(() => null);
|
|
164
|
+
if (!config) continue;
|
|
165
|
+
found.push({
|
|
166
|
+
branch: config.branch,
|
|
167
|
+
block: { block: config.ports.index, base: config.ports.base, size: config.ports.size },
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
return found;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Read and validate the dev config, or `null` when the file does not exist. */
|
|
174
|
+
export async function readDevConfig(path: string): Promise<DevConfig | null> {
|
|
175
|
+
let raw: string;
|
|
176
|
+
try {
|
|
177
|
+
raw = await readFile(path, "utf8");
|
|
178
|
+
} catch {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
let value: unknown;
|
|
183
|
+
try {
|
|
184
|
+
value = JSON.parse(raw);
|
|
185
|
+
} catch (cause) {
|
|
186
|
+
throw new InternalError({
|
|
187
|
+
message: "The feature dev config is corrupt.",
|
|
188
|
+
action: "Delete .dev.config.json and re-run pithy feature create.",
|
|
189
|
+
detail: cause instanceof Error ? cause.message : String(cause),
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const parsed = DevConfig.safeParse(value);
|
|
194
|
+
if (!parsed.success) {
|
|
195
|
+
throw fromZodError(parsed.error, {
|
|
196
|
+
message: "The feature dev config is invalid.",
|
|
197
|
+
action: "Delete .dev.config.json and re-run pithy feature create.",
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
return parsed.data;
|
|
201
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { Capability } from "@pithy-sh/core/src/capability/capability";
|
|
5
|
+
import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
6
|
+
import type { FeatureIdentity } from "@pithy-sh/core/src/naming/feature";
|
|
7
|
+
import { loadProject, requireProjectName } from "../project/config";
|
|
8
|
+
import { projectCapabilities, resolveWorkers } from "../project/workerScope";
|
|
9
|
+
import { defaultGit, type GitRunner } from "./worktree";
|
|
10
|
+
|
|
11
|
+
/** A feature's identity as read from its branch: the issue number, the slug, and the full branch name. */
|
|
12
|
+
export interface FeatureBranchIdentity {
|
|
13
|
+
/** The issue number as a string, e.g. "69". */
|
|
14
|
+
issue: string;
|
|
15
|
+
/** The kebab-case slug, e.g. "media-cli". */
|
|
16
|
+
slug: string;
|
|
17
|
+
/** The full branch, `feature/<issue>-<slug>`. */
|
|
18
|
+
branch: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** `feature/<digits>-<kebab-slug>` — the branch shape `pithy feature` owns. */
|
|
22
|
+
const FEATURE_BRANCH = /^feature\/(\d+)-([a-z0-9]+(?:-[a-z0-9]+)*)$/;
|
|
23
|
+
|
|
24
|
+
/** Parse a branch name into a feature identity, or null when it is not a `feature/<issue>-<slug>` branch. */
|
|
25
|
+
export function parseFeatureBranch(branch: string): FeatureBranchIdentity | null {
|
|
26
|
+
const match = FEATURE_BRANCH.exec(branch);
|
|
27
|
+
if (!match) return null;
|
|
28
|
+
const [, issue, slug] = match;
|
|
29
|
+
if (!issue || !slug) return null;
|
|
30
|
+
return { issue, slug, branch };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Derive the feature identity from the current git branch — the source of truth for `provision` and
|
|
35
|
+
* `destroy`, which take no positional args and run from within the worktree. Fails with an actionable
|
|
36
|
+
* error when the checkout is not on a `feature/<issue>-<slug>` branch.
|
|
37
|
+
*/
|
|
38
|
+
export async function deriveIdentityFromBranch(
|
|
39
|
+
cwd: string,
|
|
40
|
+
git: GitRunner = defaultGit,
|
|
41
|
+
): Promise<FeatureBranchIdentity> {
|
|
42
|
+
const branch = await git(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
|
|
43
|
+
const identity = parseFeatureBranch(branch);
|
|
44
|
+
if (!identity) {
|
|
45
|
+
throw new ValidationError({
|
|
46
|
+
message: `Not on a feature branch (${branch}).`,
|
|
47
|
+
action: "Run this from inside a feature worktree, or create one with pithy feature create.",
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return identity;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resolve the feature identity (project + issue + slug) from the current branch and the root config, plus
|
|
55
|
+
* the capabilities the feature spans.
|
|
56
|
+
*
|
|
57
|
+
* The two come from different places, deliberately. **Identity** is project-wide policy and lives in the
|
|
58
|
+
* root `pithy.config.ts`. **Capabilities** are per Worker (`apps/<name>/pithy.config.ts`), so they are
|
|
59
|
+
* unioned: a feature provisions one resource per binding name for the whole feature — two Workers that both
|
|
60
|
+
* declare `DB` deliberately share one database — and the migrate/seed it runs must cover every table any
|
|
61
|
+
* Worker owns.
|
|
62
|
+
*
|
|
63
|
+
* **Reached only once an operator has said `--feature` or run `pithy feature`.** The branch names a
|
|
64
|
+
* feature; it never decides that this run is one.
|
|
65
|
+
*/
|
|
66
|
+
export async function branchIdentity(
|
|
67
|
+
projectDir: string,
|
|
68
|
+
): Promise<{ identity: FeatureIdentity; capabilities: Capability[] }> {
|
|
69
|
+
const { issue, slug } = await deriveIdentityFromBranch(projectDir);
|
|
70
|
+
const config = await loadProject(projectDir);
|
|
71
|
+
// Never guessed: this name is the first segment of every resource name, and the only key teardown has
|
|
72
|
+
// to find them again. A fallback that differs between a worktree and a clone would make destroy
|
|
73
|
+
// recompute names that match nothing, delete nothing, and exit 0 — a silent leak.
|
|
74
|
+
const project = requireProjectName(config);
|
|
75
|
+
const capabilities = projectCapabilities(await resolveWorkers({ projectDir }));
|
|
76
|
+
return { identity: { project, issue, slug }, capabilities };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The feature's identity without loading a single Worker config — `#454`.
|
|
81
|
+
*
|
|
82
|
+
* {@link branchIdentity} answers identity *and* capabilities, and the capabilities come from every
|
|
83
|
+
* `apps/<name>/pithy.config.ts`. That is right for `provision`, which cannot act without knowing what it
|
|
84
|
+
* is acting on. It is wrong for `destroy`, whose local half — free the port block, prune the worktree —
|
|
85
|
+
* needs none of it, and which is most needed in exactly the state where a Worker config will not load.
|
|
86
|
+
*
|
|
87
|
+
* A `feature create` that failed partway used to leave a worktree whose config threw, and `destroy` threw
|
|
88
|
+
* on the same config before it reached the teardown. The one command that removes the worktree and frees
|
|
89
|
+
* the port block was unavailable in the state it exists for, and the block leaked: the registry kept a
|
|
90
|
+
* branch that no longer existed, and the way out was editing `<config>/dev-ports.json` by hand.
|
|
91
|
+
*
|
|
92
|
+
* The project name still comes from the **root** config, which is project identity and holds no
|
|
93
|
+
* capabilities — so it loads when a Worker's does not, and teardown keeps deriving resource names the same
|
|
94
|
+
* way it always did rather than guessing them.
|
|
95
|
+
*/
|
|
96
|
+
export async function branchIdentityWithoutWorkers(projectDir: string): Promise<FeatureIdentity> {
|
|
97
|
+
const { issue, slug } = await deriveIdentityFromBranch(projectDir);
|
|
98
|
+
const project = requireProjectName(await loadProject(projectDir));
|
|
99
|
+
return { project, issue, slug };
|
|
100
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { fromZodError, InternalError } from "@pithy-sh/core/src/error/pithyError";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { writeFileAtomic } from "../project/atomic";
|
|
8
|
+
import { readOptionalFile } from "../project/readOptionalFile";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The remedy for a file that is there and would not open, chosen from the errno (#217).
|
|
12
|
+
*
|
|
13
|
+
* `readOptionalFile`'s `unreadable` is **every errno but `ENOENT`**. *Check permissions on X* answers
|
|
14
|
+
* one of them. An adopter whose `.pithy-feature.json` is a directory, a symlink loop, or a bad sector
|
|
15
|
+
* reads a sentence about permissions, runs `chmod`, and learns nothing — which is the shape #217 is
|
|
16
|
+
* about: a `catch` reachable more than one way may not name a single remedy. Unrecognised errnos get no
|
|
17
|
+
* remedy at all, only the errno, because a wrong action is worse than no action.
|
|
18
|
+
*
|
|
19
|
+
* `ports.ts` carries its own copy for its own file. Two is under this repository's threshold for hoisting
|
|
20
|
+
* a rule out of its call sites (see `readOptionalFile.ts`: *three is this repository's count*). The home
|
|
21
|
+
* for a third is beside `readOptionalFile`, which already owns the absent/unreadable decision itself.
|
|
22
|
+
*/
|
|
23
|
+
function unreadableAction(code: string | undefined, name: string): string {
|
|
24
|
+
switch (code) {
|
|
25
|
+
case "EACCES":
|
|
26
|
+
case "EPERM":
|
|
27
|
+
return `Check permissions on ${name}.`;
|
|
28
|
+
case "EISDIR":
|
|
29
|
+
return `${name} is a directory, not a file. Remove it, then re-run.`;
|
|
30
|
+
case "ELOOP":
|
|
31
|
+
return `${name} is a symlink loop. Replace it with a regular file, then re-run.`;
|
|
32
|
+
default:
|
|
33
|
+
return `${name} is there and would not open (${code ?? "unknown error"}). Check that file, then re-run.`;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** One provisioned Cloudflare resource recorded for teardown. */
|
|
38
|
+
export const FeatureResource = z
|
|
39
|
+
.object({
|
|
40
|
+
kind: z.enum(["d1", "kv", "r2"]).describe("The Cloudflare resource type."),
|
|
41
|
+
binding: z.string().describe("The Worker binding name this resource backs, e.g. DB."),
|
|
42
|
+
name: z.string().describe("The full CF resource name, e.g. acme-f69-media-cli-db-d1."),
|
|
43
|
+
id: z.string().describe("The CF-assigned id (D1 uuid, KV namespace id) or the bucket name for r2."),
|
|
44
|
+
})
|
|
45
|
+
.describe("One provisioned Cloudflare resource recorded for teardown.");
|
|
46
|
+
export type FeatureResource = z.output<typeof FeatureResource>;
|
|
47
|
+
|
|
48
|
+
/** The per-feature record of provisioned Cloudflare resources (git-ignored, in the worktree). */
|
|
49
|
+
export const FeatureManifest = z
|
|
50
|
+
.object({
|
|
51
|
+
version: z.literal(1).describe("Manifest schema version."),
|
|
52
|
+
project: z.string().describe('The project name, from pithy.config (e.g. "acme").'),
|
|
53
|
+
issue: z.string().describe('The issue number this feature was created for (e.g. "69").'),
|
|
54
|
+
slug: z.string().describe('The kebab-case feature slug (e.g. "media-cli").'),
|
|
55
|
+
env: z.string().describe("The environment these resources belong to."),
|
|
56
|
+
resources: z.array(FeatureResource).describe("Every provisioned resource, for exact-id teardown."),
|
|
57
|
+
})
|
|
58
|
+
.describe("The per-feature record of provisioned Cloudflare resources (git-ignored, in the worktree).");
|
|
59
|
+
export type FeatureManifest = z.output<typeof FeatureManifest>;
|
|
60
|
+
|
|
61
|
+
/** The manifest path for a worktree dir: `<worktreeDir>/.pithy-feature.json`. */
|
|
62
|
+
export function manifestPath(worktreeDir: string): string {
|
|
63
|
+
return join(worktreeDir, ".pithy-feature.json");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Read + Zod-validate the manifest, or `null` if the file does not exist.
|
|
68
|
+
*
|
|
69
|
+
* "Does not exist" is {@link readOptionalFile}'s decision — a manifest that is there and will not open
|
|
70
|
+
* would otherwise read as "this worktree provisioned nothing", and `pithy feature destroy` tears down by
|
|
71
|
+
* exact id from this record. The words stay here; the errno does not.
|
|
72
|
+
*/
|
|
73
|
+
export async function readManifest(path: string): Promise<FeatureManifest | null> {
|
|
74
|
+
const raw = await readOptionalFile(path, {
|
|
75
|
+
unreadable: ({ code, cause }) =>
|
|
76
|
+
new InternalError({
|
|
77
|
+
message: "Could not read the feature manifest.",
|
|
78
|
+
action: unreadableAction(code, ".pithy-feature.json"),
|
|
79
|
+
detail: `${code ?? "unknown error"}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
80
|
+
}),
|
|
81
|
+
});
|
|
82
|
+
if (raw === null) return null;
|
|
83
|
+
|
|
84
|
+
let parsed: unknown;
|
|
85
|
+
try {
|
|
86
|
+
parsed = JSON.parse(raw);
|
|
87
|
+
} catch (err) {
|
|
88
|
+
throw new InternalError({
|
|
89
|
+
message: "The feature manifest is corrupt.",
|
|
90
|
+
action: "Delete .pithy-feature.json and re-run pithy provision --feature.",
|
|
91
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const result = FeatureManifest.safeParse(parsed);
|
|
96
|
+
if (!result.success) {
|
|
97
|
+
throw fromZodError(result.error, {
|
|
98
|
+
message: "The feature manifest is corrupt.",
|
|
99
|
+
action: "Delete .pithy-feature.json and re-run pithy provision --feature.",
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return result.data;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Zod-validate then write the manifest atomically (pretty JSON, 2-space indent, trailing newline). */
|
|
106
|
+
export async function writeManifest(path: string, manifest: FeatureManifest): Promise<void> {
|
|
107
|
+
const parsed = FeatureManifest.parse(manifest);
|
|
108
|
+
await writeFileAtomic(path, `${JSON.stringify(parsed, null, 2)}\n`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Return a NEW manifest with `resource` upserted — deduped by (kind, binding, name); a match is
|
|
113
|
+
* replaced in place, otherwise the resource is appended. Pure: never mutates `manifest`.
|
|
114
|
+
*/
|
|
115
|
+
export function upsertResource(manifest: FeatureManifest, resource: FeatureResource): FeatureManifest {
|
|
116
|
+
const index = manifest.resources.findIndex(
|
|
117
|
+
(existing) =>
|
|
118
|
+
existing.kind === resource.kind && existing.binding === resource.binding && existing.name === resource.name,
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const resources =
|
|
122
|
+
index === -1
|
|
123
|
+
? [...manifest.resources, resource]
|
|
124
|
+
: manifest.resources.map((existing, i) => (i === index ? resource : existing));
|
|
125
|
+
|
|
126
|
+
return { ...manifest, resources };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Build an empty manifest for an identity + environment. */
|
|
130
|
+
export function emptyManifest(args: { project: string; issue: string; slug: string; env: string }): FeatureManifest {
|
|
131
|
+
return { version: 1, ...args, resources: [] };
|
|
132
|
+
}
|