@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
package/src/dev/ports.ts
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import net from "node:net";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { ConflictError } from "@pithy-sh/core/src/error/pithyError";
|
|
8
|
+
|
|
9
|
+
const run = promisify(execFile);
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Try to bind `port` on `host`, resolving `true` when it is free and `false` when it is taken.
|
|
13
|
+
* The dual-stack primitive: a port is only usable if this succeeds on **both** `127.0.0.1` and `::1`
|
|
14
|
+
* (Vite binds IPv6-only, wrangler binds both families), so a loopback check on one family is not enough.
|
|
15
|
+
*/
|
|
16
|
+
export function tryBind(port: number, host: string): Promise<boolean> {
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
const srv = net.createServer();
|
|
19
|
+
srv.once("error", () => resolve(false));
|
|
20
|
+
srv.once("listening", () => srv.close(() => resolve(true)));
|
|
21
|
+
srv.listen(port, host);
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The bind seam — real {@link tryBind} in production, a stub in tests. */
|
|
26
|
+
export type TryBind = (port: number, host: string) => Promise<boolean>;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Assert a worker's **pinned** port is free — the deliberate divergence from CMS `pickPort`.
|
|
30
|
+
*
|
|
31
|
+
* The port is authoritative: it comes from the feature's `.dev.config.json`, assigned once at feature
|
|
32
|
+
* creation and held for the feature's life. So this only verifies; it never scans forward to another
|
|
33
|
+
* port. A worker that silently drifts off its pinned port breaks every sibling that was told its address
|
|
34
|
+
* ahead of time, so a conflict is reported, not worked around. The port must be free on **both** loopback
|
|
35
|
+
* families; a failure on either aborts the whole `pithy dev` session with one actionable error.
|
|
36
|
+
*/
|
|
37
|
+
export async function verifyPinnedPort(name: string, port: number, bind: TryBind = tryBind): Promise<void> {
|
|
38
|
+
const freeV4 = await bind(port, "127.0.0.1");
|
|
39
|
+
const freeV6 = await bind(port, "::1");
|
|
40
|
+
if (freeV4 && freeV6) return;
|
|
41
|
+
throw new ConflictError({
|
|
42
|
+
message: `Port ${port} for worker "${name}" is already in use.`,
|
|
43
|
+
action: "Stop whatever is holding it, then run pithy dev again.",
|
|
44
|
+
detail: `free on 127.0.0.1=${freeV4}, ::1=${freeV6}`,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Whether `pid` is a live process — `process.kill(pid, 0)` probes without signaling. */
|
|
49
|
+
export function isAlive(pid: number): boolean {
|
|
50
|
+
try {
|
|
51
|
+
process.kill(pid, 0);
|
|
52
|
+
return true;
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Sleep seam — real `setTimeout` in production, controllable in tests. */
|
|
59
|
+
export type Sleep = (ms: number) => Promise<void>;
|
|
60
|
+
const realSleep: Sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
61
|
+
|
|
62
|
+
/** Poll until `pid` is dead or `timeoutMs` elapses; resolves `true` if it exited, `false` on timeout. */
|
|
63
|
+
export async function waitForExit(
|
|
64
|
+
pid: number,
|
|
65
|
+
timeoutMs: number,
|
|
66
|
+
deps: { isAlive?: (pid: number) => boolean; sleep?: Sleep } = {},
|
|
67
|
+
): Promise<boolean> {
|
|
68
|
+
const alive = deps.isAlive ?? isAlive;
|
|
69
|
+
const sleep = deps.sleep ?? realSleep;
|
|
70
|
+
const start = Date.now();
|
|
71
|
+
for (;;) {
|
|
72
|
+
if (!alive(pid)) return true;
|
|
73
|
+
if (Date.now() - start > timeoutMs) return false;
|
|
74
|
+
await sleep(100);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Look up the pids listening on a TCP `port` — the real one shells out to `lsof`. */
|
|
79
|
+
export type LsofPort = (port: number) => Promise<number[]>;
|
|
80
|
+
|
|
81
|
+
/** `lsof -ti tcp:<port>` → the pids holding it, or `[]` when lsof is missing or errors. */
|
|
82
|
+
export const lsofPort: LsofPort = async (port) => {
|
|
83
|
+
try {
|
|
84
|
+
const { stdout } = await run("lsof", ["-ti", `tcp:${port}`]);
|
|
85
|
+
return stdout
|
|
86
|
+
.trim()
|
|
87
|
+
.split(/\s+/)
|
|
88
|
+
.map(Number)
|
|
89
|
+
.filter((n) => Number.isInteger(n) && n > 0);
|
|
90
|
+
} catch {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** Look up a pid's full command line — the sweep's scope filter. */
|
|
96
|
+
export type ProcessCommand = (pid: number) => Promise<string | null>;
|
|
97
|
+
|
|
98
|
+
/** `ps -o args= -p <pid>` → the pid's command line, or `null` when it is gone or `ps` is unavailable. */
|
|
99
|
+
export const psCommand: ProcessCommand = async (pid) => {
|
|
100
|
+
try {
|
|
101
|
+
const { stdout } = await run("ps", ["-o", "args=", "-p", String(pid)]);
|
|
102
|
+
const line = stdout.trim();
|
|
103
|
+
return line === "" ? null : line;
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/** Look up a pid's parent — walked upward so the sweep never signals its own ancestry. */
|
|
110
|
+
export type ParentPid = (pid: number) => Promise<number | null>;
|
|
111
|
+
|
|
112
|
+
/** `ps -o ppid= -p <pid>` → the parent pid, or `null` when it is gone or `ps` is unavailable. */
|
|
113
|
+
export const psParent: ParentPid = async (pid) => {
|
|
114
|
+
try {
|
|
115
|
+
const { stdout } = await run("ps", ["-o", "ppid=", "-p", String(pid)]);
|
|
116
|
+
const ppid = Number(stdout.trim());
|
|
117
|
+
return Number.isInteger(ppid) && ppid > 0 ? ppid : null;
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/** The binaries the sweep owns: the Workers runtime and its supervisor. */
|
|
124
|
+
const WORKER_BINARY = /^(workerd|wrangler)(\.[cm]?js)?$/;
|
|
125
|
+
/** Launchers that run a script — the script name, not the launcher, says what the process is. */
|
|
126
|
+
const SCRIPT_RUNNER = /^(node|bun|deno|npx|bunx)$/;
|
|
127
|
+
|
|
128
|
+
/** The last path segment of `token`, stripped of a Windows `.exe` suffix. */
|
|
129
|
+
function commandBase(token: string): string {
|
|
130
|
+
return (token.split("/").pop() ?? "").replace(/\.exe$/, "");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Whether a command line is one of ours to reap — `workerd` itself, or a `wrangler` launched directly or
|
|
135
|
+
* through a script runner (`node …/.bin/wrangler dev`, `bun x wrangler dev`).
|
|
136
|
+
*
|
|
137
|
+
* The sweep exists to clear a crashed session's orphans, not to clear the port. Anything else holding a
|
|
138
|
+
* pinned port is a genuine external conflict and must reach `verifyPinnedPort`, which reports it.
|
|
139
|
+
*/
|
|
140
|
+
export function isReapableDevCommand(command: string | null): boolean {
|
|
141
|
+
if (!command) return false;
|
|
142
|
+
const tokens = command.trim().split(/\s+/);
|
|
143
|
+
const exe = commandBase(tokens[0] ?? "");
|
|
144
|
+
if (WORKER_BINARY.test(exe)) return true;
|
|
145
|
+
if (!SCRIPT_RUNNER.test(exe)) return false;
|
|
146
|
+
return tokens.slice(1).some((token) => WORKER_BINARY.test(commandBase(token)));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** This process and every ancestor of it — never signaled, whatever is holding a port. */
|
|
150
|
+
async function ancestorPids(selfPid: number, parentOf: ParentPid): Promise<Set<number>> {
|
|
151
|
+
const chain = new Set<number>([selfPid]);
|
|
152
|
+
let pid = selfPid;
|
|
153
|
+
for (let depth = 0; depth < 64; depth += 1) {
|
|
154
|
+
const parent = await parentOf(pid);
|
|
155
|
+
if (parent === null || parent <= 1 || chain.has(parent)) break;
|
|
156
|
+
chain.add(parent);
|
|
157
|
+
pid = parent;
|
|
158
|
+
}
|
|
159
|
+
return chain;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Seams for {@link sweepStaleDevPorts}, so it can be driven without real `lsof`/`ps`/`kill`. */
|
|
163
|
+
export interface SweepDeps {
|
|
164
|
+
lsof?: LsofPort;
|
|
165
|
+
isAlive?: (pid: number) => boolean;
|
|
166
|
+
kill?: (pid: number, signal: NodeJS.Signals) => void;
|
|
167
|
+
log?: (message: string) => void;
|
|
168
|
+
/** Command-line lookup, the scope filter (default: {@link psCommand}). */
|
|
169
|
+
commandOf?: ProcessCommand;
|
|
170
|
+
/** Parent lookup, for the ancestry guard (default: {@link psParent}). */
|
|
171
|
+
parentOf?: ParentPid;
|
|
172
|
+
/** Pids the previous session recorded in `.dev-state.json` — ours by construction, reaped whatever they run. */
|
|
173
|
+
knownPids?: readonly number[];
|
|
174
|
+
/** This process's pid (default: `process.pid`). */
|
|
175
|
+
selfPid?: number;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Reap orphaned `workerd`/`wrangler` processes still holding the pinned dev ports — a crashed previous
|
|
180
|
+
* session must not block startup. Mirrors the CMS zombie-workerd sweep, with the scope its name promises:
|
|
181
|
+
* a pid is reaped only when it is one of ours — a `workerd`/`wrangler`-shaped command, or a pid the
|
|
182
|
+
* previous session recorded — and never when it is this process or one of its ancestors. Anything else
|
|
183
|
+
* is left alone and falls through to {@link verifyPinnedPort}, which reports an actionable conflict
|
|
184
|
+
* instead of silently SIGKILLing an unrelated dev server, database, or editor.
|
|
185
|
+
*
|
|
186
|
+
* Returns the pids reaped, so a re-run can report what it cleaned up.
|
|
187
|
+
*/
|
|
188
|
+
export async function sweepStaleDevPorts(ports: number[], deps: SweepDeps = {}): Promise<number[]> {
|
|
189
|
+
const lsof = deps.lsof ?? lsofPort;
|
|
190
|
+
const alive = deps.isAlive ?? isAlive;
|
|
191
|
+
const kill = deps.kill ?? ((pid, signal) => process.kill(pid, signal));
|
|
192
|
+
const commandOf = deps.commandOf ?? psCommand;
|
|
193
|
+
const parentOf = deps.parentOf ?? psParent;
|
|
194
|
+
const selfPid = deps.selfPid ?? process.pid;
|
|
195
|
+
const known = new Set(deps.knownPids ?? []);
|
|
196
|
+
const reaped: number[] = [];
|
|
197
|
+
// Resolved once, and only when something is actually about to be signaled.
|
|
198
|
+
let protectedPids: Set<number> | null = null;
|
|
199
|
+
|
|
200
|
+
for (const port of ports) {
|
|
201
|
+
for (const pid of await lsof(port)) {
|
|
202
|
+
if (pid === selfPid || !alive(pid)) continue;
|
|
203
|
+
const ours = known.has(pid);
|
|
204
|
+
if (!ours && !isReapableDevCommand(await commandOf(pid))) {
|
|
205
|
+
deps.log?.(`port ${port} is held by pid ${pid}, which is not a stale worker — leaving it`);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
protectedPids ??= await ancestorPids(selfPid, parentOf);
|
|
209
|
+
if (protectedPids.has(pid)) continue;
|
|
210
|
+
deps.log?.(`reaping stale process on port ${port} (pid ${pid})`);
|
|
211
|
+
try {
|
|
212
|
+
kill(pid, "SIGKILL");
|
|
213
|
+
reaped.push(pid);
|
|
214
|
+
} catch {
|
|
215
|
+
// Already gone between the lsof and the kill — nothing to reap.
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return reaped;
|
|
220
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { dim } from "../terminal/style";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* How long a worker gets to match its ready signal before `pithy dev` names it (pithy-sh/pithy#429).
|
|
8
|
+
*
|
|
9
|
+
* Generous on purpose. The first `wrangler dev` of a session pays for a cold bundle, and a slow worker is
|
|
10
|
+
* not a broken one — a deadline that cries wolf teaches a developer to scroll past the one line that was
|
|
11
|
+
* ever going to reach them. The value below is longer than any healthy cold start we have measured and
|
|
12
|
+
* still short enough to arrive while the session is being watched.
|
|
13
|
+
*
|
|
14
|
+
* **It is measured from the spawn, not from the command.** The watch starts after the last child is
|
|
15
|
+
* running, so none of what `pithy dev` does first — `.dev.vars`, the host configs, stopping the previous
|
|
16
|
+
* session, the orphan sweep, both loopback families of every pinned port, the dev secrets — is on this
|
|
17
|
+
* clock. On a cold project that is tens of seconds, and charging it to a worker would make the budget a
|
|
18
|
+
* worker actually gets vary with how much housekeeping the run happened to need.
|
|
19
|
+
*
|
|
20
|
+
* `docs/commands/dev.md` states it in seconds, and says what it is measured from, in both places it
|
|
21
|
+
* appears; `readyWatchDocs.test.ts` pins both sentences to this constant.
|
|
22
|
+
*/
|
|
23
|
+
export const READY_DEADLINE_MS = 90_000;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* How often the still-waiting report repeats while at least one worker has not arrived.
|
|
27
|
+
*
|
|
28
|
+
* A single line at the deadline scrolls away exactly like the build error did — the other workers keep
|
|
29
|
+
* printing. Repeating is what makes it findable, and the value below is often enough to stay in view
|
|
30
|
+
* without becoming the noise it is trying to cut through.
|
|
31
|
+
*/
|
|
32
|
+
export const READY_REMINDER_MS = 30_000;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Schedule `run` after `ms`; the returned function cancels it. The timer seam — real `setTimeout` in
|
|
36
|
+
* production, a hand-driven clock in tests, so nothing here waits ninety real seconds to be proven.
|
|
37
|
+
*/
|
|
38
|
+
export type Schedule = (ms: number, run: () => void) => () => void;
|
|
39
|
+
|
|
40
|
+
/** The real timer. Unreferenced: the supervisor's lifetime is its children's, never a pending report's. */
|
|
41
|
+
export const scheduleTimeout: Schedule = (ms, run) => {
|
|
42
|
+
const timer = setTimeout(run, ms);
|
|
43
|
+
timer.unref();
|
|
44
|
+
return () => clearTimeout(timer);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The report rendered for a person: who has not arrived, by name.
|
|
49
|
+
*
|
|
50
|
+
* *Still waiting* rather than *failed*, because the orchestrator does not know which it is — a worker
|
|
51
|
+
* this line names may be one bundle away from the banner. The reason it had to be a deadline that told
|
|
52
|
+
* you is said once, with the first report; the repeats are the short line alone.
|
|
53
|
+
*
|
|
54
|
+
* **The action line names the mechanism, never a cause.** It used to say `wrangler dev keeps running
|
|
55
|
+
* after a build error`, which is true of the case that prompted #429 and of nothing else the same
|
|
56
|
+
* deadline catches: a startup that hangs, a port that never binds, a binding that never resolves, and a
|
|
57
|
+
* `dev.command` worker — a Vite front end — where wrangler is not in the picture at all. What every one
|
|
58
|
+
* of them shares is the property that made the session look healthy: the child is *alive*, so no exit
|
|
59
|
+
* handler fires and nothing else in the run was ever going to mention it. That is what the line says,
|
|
60
|
+
* and it points at the worker's own output for the reason, which is the only place the reason exists.
|
|
61
|
+
*
|
|
62
|
+
* It names a restart because for the case that prompted this, a restart is not optional: a `wrangler dev`
|
|
63
|
+
* whose **first** build fails prints the error, keeps running, and does not rebuild when the file is fixed
|
|
64
|
+
* — measured against wrangler 4.123 on a Worker with an unresolvable import. Editing the file and waiting
|
|
65
|
+
* is the thing a developer would otherwise try, and it is the one thing that cannot work.
|
|
66
|
+
*/
|
|
67
|
+
export function stillWaitingLines(waiting: readonly string[], first: boolean): string[] {
|
|
68
|
+
const line = `Still waiting on: ${waiting.join(", ")}.`;
|
|
69
|
+
if (!first) return [line];
|
|
70
|
+
return [
|
|
71
|
+
line,
|
|
72
|
+
dim(" A worker that never becomes ready keeps running, so nothing else reports it."),
|
|
73
|
+
dim(" Each one's own output above says why. Fix it and run pithy dev again."),
|
|
74
|
+
];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** What {@link watchReady} needs: the set that has not arrived, somewhere to say it, and a clock. */
|
|
78
|
+
export interface ReadyWatchOptions {
|
|
79
|
+
/** The workers yet to match their ready signal, in start order. Read afresh at every tick. */
|
|
80
|
+
pending: () => readonly string[];
|
|
81
|
+
/**
|
|
82
|
+
* Say it. Handed the set rather than a rendered line, because the two audiences render it differently:
|
|
83
|
+
* a person gets {@link stillWaitingLines}, and an agent driving `pithy dev --json` gets a record it can
|
|
84
|
+
* read. A seam that emitted prose would have made the machine-readable half unbuildable without parsing
|
|
85
|
+
* our own sentence back out of it.
|
|
86
|
+
*
|
|
87
|
+
* `first` is true for the report at the deadline and false for every repeat.
|
|
88
|
+
*/
|
|
89
|
+
report: (waiting: readonly string[], first: boolean) => void;
|
|
90
|
+
/** Timer seam (default {@link scheduleTimeout}). */
|
|
91
|
+
schedule?: Schedule;
|
|
92
|
+
/** How long before the first report (default {@link READY_DEADLINE_MS}). */
|
|
93
|
+
deadlineMs?: number;
|
|
94
|
+
/** How long between repeats while the set stays non-empty (default {@link READY_REMINDER_MS}). */
|
|
95
|
+
reminderMs?: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A running watch. `stop()` cancels the pending timer and is safe to call more than once. */
|
|
99
|
+
export interface ReadyWatch {
|
|
100
|
+
stop: () => void;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Watch for the workers that start and never become ready, and name them.
|
|
105
|
+
*
|
|
106
|
+
* `wrangler dev` does not exit when a build fails — it prints the error and keeps running — so a broken
|
|
107
|
+
* worker is a live child that never matches its ready signal. The banner waits on the whole set, so it
|
|
108
|
+
* never fires, and until this the session simply proceeded looking healthy with the error forty lines up
|
|
109
|
+
* the scrollback. This is the deadline that names it, and keeps naming it while it stays true.
|
|
110
|
+
*
|
|
111
|
+
* The set is read at every tick rather than captured, so a worker that arrives late drops out of the next
|
|
112
|
+
* report on its own. An empty set ends the watch: everything arrived, and the banner says the rest.
|
|
113
|
+
*/
|
|
114
|
+
export function watchReady(options: ReadyWatchOptions): ReadyWatch {
|
|
115
|
+
const schedule = options.schedule ?? scheduleTimeout;
|
|
116
|
+
const deadlineMs = options.deadlineMs ?? READY_DEADLINE_MS;
|
|
117
|
+
const reminderMs = options.reminderMs ?? READY_REMINDER_MS;
|
|
118
|
+
|
|
119
|
+
let cancel: (() => void) | null = null;
|
|
120
|
+
let stopped = false;
|
|
121
|
+
let reported = false;
|
|
122
|
+
|
|
123
|
+
const tick = () => {
|
|
124
|
+
cancel = null;
|
|
125
|
+
if (stopped) return;
|
|
126
|
+
const waiting = options.pending();
|
|
127
|
+
if (waiting.length === 0) return;
|
|
128
|
+
options.report(waiting, !reported);
|
|
129
|
+
reported = true;
|
|
130
|
+
cancel = schedule(reminderMs, tick);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
cancel = schedule(deadlineMs, tick);
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
stop: () => {
|
|
137
|
+
stopped = true;
|
|
138
|
+
cancel?.();
|
|
139
|
+
cancel = null;
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
package/src/dev/state.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { readFileSync, unlinkSync } from "node:fs";
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { writeFileAtomic } from "../project/atomic";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `.dev-state.json` — the running `pithy dev` session's live state, git-ignored, at the worktree root.
|
|
12
|
+
*
|
|
13
|
+
* Distinct from `.dev.config.json` (the feature's fixed dev config: reserved ports, per-worker origins,
|
|
14
|
+
* written once at feature creation). This file is ephemeral: the supervising pid, when it started, its
|
|
15
|
+
* child pids, and each worker's port + pid. A re-run reads it to stop the previous session before it
|
|
16
|
+
* spawns; shutdown removes it. It is rewritten fresh every session, so a corrupt file is discarded, not
|
|
17
|
+
* an error — there is nothing to recover.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** One worker's live entry in the running session — the port it holds and the pid serving it. */
|
|
21
|
+
export const DevWorkerState = z
|
|
22
|
+
.object({
|
|
23
|
+
port: z.number().int().positive().describe("The local port this worker is bound to for the session."),
|
|
24
|
+
pid: z.number().int().describe("The spawned child process id serving this worker (its process-group leader)."),
|
|
25
|
+
})
|
|
26
|
+
.describe("A single worker's live process state within a running pithy dev session.");
|
|
27
|
+
export type DevWorkerState = z.output<typeof DevWorkerState>;
|
|
28
|
+
|
|
29
|
+
/** The `.dev-state.json` document: the supervising session and every worker it started. */
|
|
30
|
+
export const DevState = z
|
|
31
|
+
.object({
|
|
32
|
+
pid: z.number().int().describe("The pid of the supervising pithy dev process that owns this session."),
|
|
33
|
+
startedAt: z.string().describe("When the session started, ISO-8601 — for operator diagnostics."),
|
|
34
|
+
childPids: z.array(z.number().int()).describe("Every spawned child pid, so a crashed session can be reaped."),
|
|
35
|
+
workers: z
|
|
36
|
+
.record(z.string(), DevWorkerState)
|
|
37
|
+
.describe("Each started worker's live port and pid, keyed by worker name."),
|
|
38
|
+
})
|
|
39
|
+
.describe("The live state of a running pithy dev session (git-ignored .dev-state.json).");
|
|
40
|
+
export type DevState = z.output<typeof DevState>;
|
|
41
|
+
|
|
42
|
+
/** The dev-state path for a project/worktree root: `<projectDir>/.dev-state.json`. */
|
|
43
|
+
export function devStatePath(projectDir: string): string {
|
|
44
|
+
return join(projectDir, ".dev-state.json");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Read the previous session's state, or `null` when the file is absent, corrupt, or invalid. Unlike the
|
|
49
|
+
* feature dev config, a bad `.dev-state.json` never throws: it is a disposable session artifact this run
|
|
50
|
+
* overwrites, so an unreadable one is simply treated as "no previous session".
|
|
51
|
+
*/
|
|
52
|
+
export async function readDevState(path: string): Promise<DevState | null> {
|
|
53
|
+
let raw: string;
|
|
54
|
+
try {
|
|
55
|
+
raw = await readFile(path, "utf8");
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
const parsed = DevState.safeParse(safeJson(raw));
|
|
60
|
+
return parsed.success ? parsed.data : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Zod-validate then write the session state atomically (pretty JSON, trailing newline). */
|
|
64
|
+
export async function writeDevState(path: string, state: DevState): Promise<void> {
|
|
65
|
+
const validated = DevState.parse(state);
|
|
66
|
+
await writeFileAtomic(path, `${JSON.stringify(validated, null, 2)}\n`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Remove the state file **only when it still points at us** — race-safe teardown. A newer orchestrator
|
|
71
|
+
* (a re-run that already replaced the file) owns it now, so an older shutting-down process must not delete
|
|
72
|
+
* the newer session's state. Synchronous so it runs reliably inside a shutdown/exit path.
|
|
73
|
+
*/
|
|
74
|
+
export function removeDevState(path: string, ownPid: number): void {
|
|
75
|
+
try {
|
|
76
|
+
const current = DevState.safeParse(safeJson(readFileSync(path, "utf8")));
|
|
77
|
+
if (current.success && current.data.pid === ownPid) unlinkSync(path);
|
|
78
|
+
} catch {
|
|
79
|
+
// Already gone, or never ours — nothing to remove.
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Parse JSON, returning `undefined` on failure so the caller's Zod parse decides validity. */
|
|
84
|
+
function safeJson(raw: string): unknown {
|
|
85
|
+
try {
|
|
86
|
+
return JSON.parse(raw);
|
|
87
|
+
} catch {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
}
|