@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,1014 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { execFile, spawn as spawnChild } from "node:child_process";
|
|
5
|
+
import { createWriteStream, mkdirSync } from "node:fs";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
import { isContinuousIntegration } from "@pithy-sh/core/src/env/ci";
|
|
9
|
+
import { messageOf, ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
10
|
+
import type { DevLogin } from "@pithy-sh/core/src/seed/devLogin";
|
|
11
|
+
import { findEntitlementGap } from "../capabilities/entitlementGap";
|
|
12
|
+
import { type GenerateDevVarsResult, generateDevVars } from "../devSecrets/generate";
|
|
13
|
+
import { renderDevSecretsNotes, renderDevVarsNotes } from "../devSecrets/report";
|
|
14
|
+
import { type DevSecretsSeedReport, seedProjectDevSecrets } from "../devSecrets/seed";
|
|
15
|
+
import { localDevStateRoot } from "../devSecrets/store";
|
|
16
|
+
import {
|
|
17
|
+
buildDevConfig,
|
|
18
|
+
type DevConfig,
|
|
19
|
+
devConfigPath,
|
|
20
|
+
readDevConfig,
|
|
21
|
+
scanPinnedBlocks,
|
|
22
|
+
writeDevConfig,
|
|
23
|
+
} from "../feature/devConfig";
|
|
24
|
+
import {
|
|
25
|
+
allocatePortBlock,
|
|
26
|
+
type PortBlock,
|
|
27
|
+
portsRegistryPath,
|
|
28
|
+
reclaimPortBlocks,
|
|
29
|
+
registryRootFor,
|
|
30
|
+
} from "../feature/ports";
|
|
31
|
+
import { allCapabilities, loadProject, loadWorkerConfig, requireProjectName } from "../project/config";
|
|
32
|
+
import { detectPackageManager, execArgs } from "../project/packageManager";
|
|
33
|
+
import { defaultWorkerDev } from "../project/workerManifest";
|
|
34
|
+
import { discoverWorkers as discoverWorkersDefault, type WorkerTarget } from "../project/workers";
|
|
35
|
+
import { formatJsonLine } from "../terminal/output";
|
|
36
|
+
import { dim, workerColor } from "../terminal/style";
|
|
37
|
+
import { hasCloudflareLogin as defaultHasCloudflareLogin, deliveryFailureNote, deliveryPreflight } from "./delivery";
|
|
38
|
+
import { type DevLoginTarget, devLoginKeyAction, devLoginLines, readDevLogin as readDevLoginDefault } from "./devLogin";
|
|
39
|
+
import { devLoginTargets as devLoginTargetsDefault } from "./devLoginTargets";
|
|
40
|
+
import { buildWorkerEnv, childEnvFor, ownOriginFor, startCommand, type WranglerLauncher } from "./env";
|
|
41
|
+
import {
|
|
42
|
+
discoverHostWorkers as discoverHostWorkersDefault,
|
|
43
|
+
type HostMaterialization,
|
|
44
|
+
type HostWorker,
|
|
45
|
+
type HostWorkerDiscovery,
|
|
46
|
+
hostDeliveryIdentity,
|
|
47
|
+
type MaterializeHostConfigsOptions,
|
|
48
|
+
materializeHostConfigs as materializeHostConfigsDefault,
|
|
49
|
+
} from "./hostWorkers";
|
|
50
|
+
import { type KeyReader, readKeys as readKeysDefault } from "./keys";
|
|
51
|
+
import { type DataStream, stripAnsi, teeStream } from "./logging";
|
|
52
|
+
import { openUrl as openUrlDefault } from "./openUrl";
|
|
53
|
+
import {
|
|
54
|
+
isAlive as isAliveDefault,
|
|
55
|
+
type Sleep,
|
|
56
|
+
sweepStaleDevPorts,
|
|
57
|
+
type TryBind,
|
|
58
|
+
tryBind as tryBindDefault,
|
|
59
|
+
verifyPinnedPort,
|
|
60
|
+
} from "./ports";
|
|
61
|
+
import { type ReadyWatch, type Schedule, stillWaitingLines, watchReady } from "./readyWatch";
|
|
62
|
+
import { type DevState, devStatePath, readDevState, removeDevState, writeDevState } from "./state";
|
|
63
|
+
|
|
64
|
+
/** A spawned child, minimally what the orchestrator drives — satisfied by a real `ChildProcess` or a fake. */
|
|
65
|
+
export interface ChildLike {
|
|
66
|
+
pid?: number;
|
|
67
|
+
stdout: DataStream | null;
|
|
68
|
+
stderr: DataStream | null;
|
|
69
|
+
once(event: "exit", listener: (code: number | null) => void): unknown;
|
|
70
|
+
/** The spawn error channel (e.g. ENOENT when a `dev.command` binary is missing) — required so it is handled, not thrown. */
|
|
71
|
+
once(event: "error", listener: (error: Error) => void): unknown;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The spawn seam. Detached makes the child a process-group leader, so `kill(-pid)` tears down its subtree. */
|
|
75
|
+
export type SpawnDev = (
|
|
76
|
+
command: string,
|
|
77
|
+
args: string[],
|
|
78
|
+
options: { cwd: string; env: Record<string, string>; detached: boolean },
|
|
79
|
+
) => ChildLike;
|
|
80
|
+
|
|
81
|
+
/** A log destination — the terminal's tee'd copy in `logs/dev.log`, injectable so tests capture lines. */
|
|
82
|
+
export interface LogSink {
|
|
83
|
+
write: (line: string) => void;
|
|
84
|
+
end: () => Promise<void> | void;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Everything `startDev` needs, every dependency defaulted to its real implementation. */
|
|
88
|
+
/**
|
|
89
|
+
* One Worker's entitlement composition gap: the gating source files, or empty when there is none. A
|
|
90
|
+
* config that cannot be loaded yields no gap — `pithy dev` reports wiring, and a config that will not
|
|
91
|
+
* load is wrangler's error to raise, not a reason to invent an entitlement warning.
|
|
92
|
+
*/
|
|
93
|
+
const defaultCheckEntitlements = async (workerDir: string): Promise<string[]> => {
|
|
94
|
+
try {
|
|
95
|
+
return await findEntitlementGap(workerDir, allCapabilities(await loadWorkerConfig(workerDir)));
|
|
96
|
+
} catch {
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The project name every host's derived names lead with, or `null` when the project states none.
|
|
103
|
+
*
|
|
104
|
+
* `requireProjectName` rather than `resolveProjectName`: a guessed name differs between checkouts,
|
|
105
|
+
* and this one is stamped into a Worker script name. A project that states none gets no hosts and
|
|
106
|
+
* one line saying why — the alternative is a host running under a name nothing else in the project
|
|
107
|
+
* would reproduce.
|
|
108
|
+
*/
|
|
109
|
+
const defaultProjectName = async (projectDir: string): Promise<string | null> => {
|
|
110
|
+
try {
|
|
111
|
+
return requireProjectName(await loadProject(projectDir));
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
export interface StartDevOptions {
|
|
118
|
+
projectDir: string;
|
|
119
|
+
json?: boolean;
|
|
120
|
+
/** Test seam: the entitlement composition check, without loading a real `pithy.config.ts`. */
|
|
121
|
+
checkEntitlements?: (workerDir: string) => Promise<string[]>;
|
|
122
|
+
/** Seam: seed the dev secrets file into the local `SECRETS` store before anything spawns. */
|
|
123
|
+
seedSecrets?: (projectDir: string) => Promise<DevSecretsSeedReport>;
|
|
124
|
+
/** Seam: generate each Worker's `.dev.vars` before anything reads one. */
|
|
125
|
+
generateDevVars?: (projectDir: string, workerDirs: string[]) => Promise<GenerateDevVarsResult>;
|
|
126
|
+
discoverWorkers?: (projectDir: string) => Promise<WorkerTarget[]>;
|
|
127
|
+
/** Seam: the host Worker of every capability the project's Workers compose. */
|
|
128
|
+
discoverHostWorkers?: (options: {
|
|
129
|
+
projectDir: string;
|
|
130
|
+
workers: readonly WorkerTarget[];
|
|
131
|
+
}) => Promise<HostWorkerDiscovery>;
|
|
132
|
+
/** Seam: resolve and write each host's local `wrangler.jsonc`. */
|
|
133
|
+
materializeHostConfigs?: (options: MaterializeHostConfigsOptions) => Promise<HostMaterialization>;
|
|
134
|
+
/** Seam: the project name every host's derived names lead with. `null` skips the hosts, loudly. */
|
|
135
|
+
projectName?: (projectDir: string) => Promise<string | null>;
|
|
136
|
+
/** Seam: whether Cloudflare credentials resolve at all — the cheap half of the delivery preflight. */
|
|
137
|
+
hasCloudflareLogin?: (projectDir: string, env: NodeJS.ProcessEnv) => Promise<boolean>;
|
|
138
|
+
loadDevConfig?: (projectDir: string) => Promise<DevConfig | null>;
|
|
139
|
+
/** Bootstrap seam: assign and persist pinned ports when the project has none yet. */
|
|
140
|
+
ensureDevConfig?: (options: EnsureDevConfigOptions) => Promise<DevConfig>;
|
|
141
|
+
/** Seams handed to the real {@link ensureDevConfig} (git branch, registry path, write). */
|
|
142
|
+
ensureDeps?: EnsureDevConfigDeps;
|
|
143
|
+
tryBind?: TryBind;
|
|
144
|
+
/** Reap our own orphans on the pinned ports. `knownPids` are the previous session's recorded children. */
|
|
145
|
+
sweep?: (ports: number[], knownPids: readonly number[]) => Promise<number[]>;
|
|
146
|
+
spawn?: SpawnDev;
|
|
147
|
+
kill?: (pid: number, signal: NodeJS.Signals) => void;
|
|
148
|
+
isAlive?: (pid: number) => boolean;
|
|
149
|
+
sleep?: Sleep;
|
|
150
|
+
/** Seam: the ready-deadline timer. Real `setTimeout` in production, a hand-driven clock in tests. */
|
|
151
|
+
schedule?: Schedule;
|
|
152
|
+
launchWrangler?: WranglerLauncher;
|
|
153
|
+
hasSetsid?: boolean;
|
|
154
|
+
stdout?: (text: string) => void;
|
|
155
|
+
/** Seam: where the prose goes when stdout is reserved for JSON (`--json`). */
|
|
156
|
+
stderr?: (text: string) => void;
|
|
157
|
+
/** Seam: the seeded dev login the ready banner offers, if `pithy seed` wrote one. */
|
|
158
|
+
readDevLogin?: (projectDir: string) => Promise<DevLogin | undefined>;
|
|
159
|
+
/** Seam: which started workers carry the dev-login route (they compose auth). */
|
|
160
|
+
devLoginTargets?: (started: readonly { name: string; dir: string; origin: string }[]) => Promise<DevLoginTarget[]>;
|
|
161
|
+
/** Seam: the raw-mode key reader. Answers `active: false` on every non-TTY, and is never entered there. */
|
|
162
|
+
readKeys?: typeof readKeysDefault;
|
|
163
|
+
/** Seam: hand a URL to the platform's browser opener. */
|
|
164
|
+
openUrl?: (url: string) => Promise<void>;
|
|
165
|
+
openLog?: (path: string) => LogSink;
|
|
166
|
+
baseEnv?: NodeJS.ProcessEnv;
|
|
167
|
+
now?: () => Date;
|
|
168
|
+
readState?: (path: string) => Promise<DevState | null>;
|
|
169
|
+
writeState?: (path: string, state: DevState) => Promise<void>;
|
|
170
|
+
removeState?: (path: string, ownPid: number) => void;
|
|
171
|
+
ownPid?: number;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The resolved endpoint for one started worker. */
|
|
175
|
+
export interface StartedWorker {
|
|
176
|
+
name: string;
|
|
177
|
+
port: number;
|
|
178
|
+
origin: string;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** A running dev session's handle — its resolved workers, its lifecycle promises, and a shutdown hook. */
|
|
182
|
+
export interface DevHandle {
|
|
183
|
+
workers: StartedWorker[];
|
|
184
|
+
/** Resolves once every started worker has matched its ready signal (the ready banner fires). */
|
|
185
|
+
ready: Promise<void>;
|
|
186
|
+
/** Resolves once the session has fully torn down (all children gone, state removed). */
|
|
187
|
+
closed: Promise<void>;
|
|
188
|
+
/** Tear the session down: SIGTERM every child group, SIGKILL survivors after a grace window, clean up. */
|
|
189
|
+
shutdown: (reason: string) => Promise<void>;
|
|
190
|
+
state: DevState;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** How long a child gets to exit on SIGTERM before it is SIGKILLed. */
|
|
194
|
+
const SHUTDOWN_GRACE_MS = 5000;
|
|
195
|
+
|
|
196
|
+
const realSleep: Sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
197
|
+
|
|
198
|
+
/** The real log sink: truncate `logs/dev.log` fresh, stream lines to it, flush on close. */
|
|
199
|
+
function openLogDefault(path: string): LogSink {
|
|
200
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
201
|
+
const stream = createWriteStream(path, { flags: "w" });
|
|
202
|
+
return {
|
|
203
|
+
write: (line) => void stream.write(`${line}\n`),
|
|
204
|
+
end: () => new Promise<void>((resolve) => stream.end(() => resolve())),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The real spawn: a group-leader child (POSIX `setsid` via `detached`) with piped stdout/stderr. */
|
|
209
|
+
const spawnDefault: SpawnDev = (command, args, options) =>
|
|
210
|
+
spawnChild(command, args, {
|
|
211
|
+
cwd: options.cwd,
|
|
212
|
+
env: options.env,
|
|
213
|
+
detached: options.detached,
|
|
214
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const execFileAsync = promisify(execFile);
|
|
218
|
+
|
|
219
|
+
/** Seams for {@link ensureDevConfig} — the git and registry lookups a test drives itself. */
|
|
220
|
+
export interface EnsureDevConfigDeps {
|
|
221
|
+
/** Resolve the machine's registry file (default: `<config>/dev-ports.json`). */
|
|
222
|
+
registryPathFor?: (projectDir: string) => Promise<string>;
|
|
223
|
+
/** The main checkout root, the registry's outer key (default: git-common-dir; the project itself with no repo). */
|
|
224
|
+
rootFor?: (projectDir: string) => Promise<string>;
|
|
225
|
+
/** The current branch, the registry's inner key (default: `git rev-parse --abbrev-ref HEAD`; `null` off a branch). */
|
|
226
|
+
branchFor?: (projectDir: string) => Promise<string | null>;
|
|
227
|
+
/** Persist the built config (default: {@link writeDevConfig}). */
|
|
228
|
+
writeConfig?: (path: string, config: DevConfig) => Promise<void>;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Arguments to {@link ensureDevConfig}. */
|
|
232
|
+
export interface EnsureDevConfigOptions extends EnsureDevConfigDeps {
|
|
233
|
+
/** The project (or worktree) root that owns `.dev.config.json`. */
|
|
234
|
+
projectDir: string;
|
|
235
|
+
/** Every discovered worker — not just the autostart set, so a port survives an autostart flip. */
|
|
236
|
+
workers: WorkerTarget[];
|
|
237
|
+
/** The config already on disk, whose worker→port pairs are preserved. `null` on first run. */
|
|
238
|
+
existing?: DevConfig | null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The registry is machine-wide and always resolvable — no repository is involved in finding it (#435). */
|
|
242
|
+
async function defaultRegistryPath(_projectDir: string): Promise<string> {
|
|
243
|
+
return portsRegistryPath();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** The current branch, or `null` when there is no repo or HEAD is detached. */
|
|
247
|
+
async function defaultBranch(projectDir: string): Promise<string | null> {
|
|
248
|
+
try {
|
|
249
|
+
const { stdout } = await execFileAsync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: projectDir });
|
|
250
|
+
const branch = stdout.trim();
|
|
251
|
+
return branch === "" || branch === "HEAD" ? null : branch;
|
|
252
|
+
} catch {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Put this config's already-pinned block back into the registry if the registry has lost it.
|
|
259
|
+
*
|
|
260
|
+
* Gap-filling only — {@link reclaimPortBlocks} never overwrites a live allocation, so this can only ever
|
|
261
|
+
* restore a claim, never move one. Swallows its own failure: see {@link ensureDevConfig} for why a
|
|
262
|
+
* registry that cannot be written must not stop a session whose ports are already decided.
|
|
263
|
+
*/
|
|
264
|
+
async function reregisterPinnedBlock(options: EnsureDevConfigOptions, branch: string, block: PortBlock): Promise<void> {
|
|
265
|
+
try {
|
|
266
|
+
const registryPath = await (options.registryPathFor ?? defaultRegistryPath)(options.projectDir);
|
|
267
|
+
const root = await (options.rootFor ?? registryRootFor)(options.projectDir);
|
|
268
|
+
await reclaimPortBlocks({ registryPath, root, reservations: [{ branch, block }] });
|
|
269
|
+
} catch {
|
|
270
|
+
// Nothing to report and nothing to stop: the ports this run uses are the ones already on disk.
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Guarantee this project has pinned ports, then return them — the bootstrap behind `pithy dev`.
|
|
276
|
+
*
|
|
277
|
+
* `.dev.config.json` is written at feature creation, but a plain `pithy init` project is the main checkout,
|
|
278
|
+
* which `pithy feature sync` deliberately refuses to touch — the main checkout is not a feature. So the
|
|
279
|
+
* scaffold's own `pithy dev` had no way to ever get one. This writes the port half; the `.dev.vars` half is
|
|
280
|
+
* {@link startDev}'s own step, because the two are needed in different projects at different moments.
|
|
281
|
+
*
|
|
282
|
+
* The invariant is unchanged: ports are **assigned** here, from the same central registry under the same
|
|
283
|
+
* file lock, then verified before anything binds — never probed at startup. Idempotent: a block is reused
|
|
284
|
+
* once allocated, and assignment is sticky, so a second run returns the same ports and a worker added later
|
|
285
|
+
* takes a free port without moving a sibling's address. An existing config keeps its own block and branch —
|
|
286
|
+
* the registry is never re-keyed underneath a live feature.
|
|
287
|
+
*
|
|
288
|
+
* **A pinned config still re-registers its claim, and that is not a contradiction of the line above**
|
|
289
|
+
* (#435). The registry is machine-wide now, so it can lose this project's entry to something this project
|
|
290
|
+
* never did: a wiped config directory, a new machine, a moved checkout pruned as gone by another project's
|
|
291
|
+
* allocation. Every one of those ends with a live feature's ports on offer to whoever allocates next.
|
|
292
|
+
* Before, the whole reclaim lived on the path that runs when there is *no* config — which is the path a
|
|
293
|
+
* settled project never takes, so `pithy dev`, the command anybody actually runs, repaired nothing. The
|
|
294
|
+
* repair is {@link reclaimPortBlocks}, which fills gaps and never overwrites, so re-registering a block
|
|
295
|
+
* this config already pins cannot move anyone: the promise above is about *re-keying*, and nothing here
|
|
296
|
+
* re-keys.
|
|
297
|
+
*
|
|
298
|
+
* Best-effort, deliberately. This session's ports are already pinned and are verified on both stacks
|
|
299
|
+
* before anything binds, so a registry that cannot be written is not a reason to refuse to start — an
|
|
300
|
+
* unwritable `$PITHY_CONFIG_DIR` used to leave `pithy dev` working off the pinned config alone, and it
|
|
301
|
+
* still does.
|
|
302
|
+
*/
|
|
303
|
+
export async function ensureDevConfig(options: EnsureDevConfigOptions): Promise<DevConfig> {
|
|
304
|
+
const existing = options.existing ?? null;
|
|
305
|
+
const writeConfig = options.writeConfig ?? writeDevConfig;
|
|
306
|
+
|
|
307
|
+
let branch: string;
|
|
308
|
+
let block: PortBlock;
|
|
309
|
+
if (existing) {
|
|
310
|
+
branch = existing.branch;
|
|
311
|
+
block = { block: existing.ports.index, base: existing.ports.base, size: existing.ports.size };
|
|
312
|
+
await reregisterPinnedBlock(options, branch, block);
|
|
313
|
+
} else {
|
|
314
|
+
const registryPath = await (options.registryPathFor ?? defaultRegistryPath)(options.projectDir);
|
|
315
|
+
const root = await (options.rootFor ?? registryRootFor)(options.projectDir);
|
|
316
|
+
const named = await (options.branchFor ?? defaultBranch)(options.projectDir);
|
|
317
|
+
// Off a branch (no repo, detached HEAD) the checkout path is the stable key — one block per checkout.
|
|
318
|
+
branch = named ?? `local:${options.projectDir}`;
|
|
319
|
+
// Rebuild any registry entry lost since the worktrees were created, so a fresh registry can never hand
|
|
320
|
+
// out a block a live feature still holds. Scanned from the repository root, never from the registry's
|
|
321
|
+
// own directory: the file sits in the config directory now, which has no `.worktrees` and never will,
|
|
322
|
+
// so `dirname(registryPath)` would make this a silent no-op in every direction (#435).
|
|
323
|
+
await reclaimPortBlocks({ registryPath, root, reservations: await scanPinnedBlocks(root) });
|
|
324
|
+
block = await allocatePortBlock({ registryPath, root, branch });
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const config = buildDevConfig({ branch, block, workers: options.workers, previous: existing });
|
|
328
|
+
await writeConfig(devConfigPath(options.projectDir), config);
|
|
329
|
+
return config;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Compile a worker's ready-signal regex, falling back to the default when the source is invalid. */
|
|
333
|
+
function readyRegexFor(worker: WorkerTarget): RegExp {
|
|
334
|
+
const source = (worker.dev ?? defaultWorkerDev()).readySignal;
|
|
335
|
+
try {
|
|
336
|
+
return new RegExp(source);
|
|
337
|
+
} catch {
|
|
338
|
+
return new RegExp(defaultWorkerDev().readySignal);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Start and supervise the local dev session — the engine behind `pithy dev`.
|
|
344
|
+
*
|
|
345
|
+
* It discovers the autostart workers, resolves each one's **pinned** port from `.dev.config.json` (bootstrapping
|
|
346
|
+
* one from the central port registry when the project has none — see {@link ensureDevConfig}), verifies
|
|
347
|
+
* every port is free on both loopback families before spawning anything (a conflict aborts the whole session
|
|
348
|
+
* — it never drifts to another port), stops any previous session and reaps orphaned workers, then spawns each
|
|
349
|
+
* worker as a process-group leader with its siblings' addresses wired into the env. Output is tee'd — colorized
|
|
350
|
+
* to the terminal, plain to `logs/dev.log` — and a single ready banner fires once every worker matches its
|
|
351
|
+
* ready signal. Returns a handle; signal wiring and process exit stay with the caller so the engine is testable.
|
|
352
|
+
*/
|
|
353
|
+
export async function startDev(options: StartDevOptions): Promise<DevHandle> {
|
|
354
|
+
const projectDir = options.projectDir;
|
|
355
|
+
const discoverWorkers = options.discoverWorkers ?? discoverWorkersDefault;
|
|
356
|
+
const loadDevConfig = options.loadDevConfig ?? ((dir: string) => readDevConfig(devConfigPath(dir)));
|
|
357
|
+
const bind = options.tryBind ?? tryBindDefault;
|
|
358
|
+
const spawn = options.spawn ?? spawnDefault;
|
|
359
|
+
const kill = options.kill ?? ((pid, signal) => process.kill(pid, signal));
|
|
360
|
+
const isAlive = options.isAlive ?? isAliveDefault;
|
|
361
|
+
const sleep = options.sleep ?? realSleep;
|
|
362
|
+
const hasSetsid = options.hasSetsid ?? process.platform !== "win32";
|
|
363
|
+
const stdout = options.stdout ?? ((text: string) => void process.stdout.write(text));
|
|
364
|
+
const stderr = options.stderr ?? ((text: string) => void process.stderr.write(text));
|
|
365
|
+
const readDevLogin = options.readDevLogin ?? readDevLoginDefault;
|
|
366
|
+
const resolveDevLoginTargets =
|
|
367
|
+
options.devLoginTargets ??
|
|
368
|
+
((started: readonly { name: string; dir: string; origin: string }[]) => devLoginTargetsDefault({ started }));
|
|
369
|
+
const readKeys = options.readKeys ?? readKeysDefault;
|
|
370
|
+
const openUrl = options.openUrl ?? ((url: string) => openUrlDefault(url));
|
|
371
|
+
const openLog = options.openLog ?? openLogDefault;
|
|
372
|
+
const now = options.now ?? (() => new Date());
|
|
373
|
+
const readState = options.readState ?? readDevState;
|
|
374
|
+
const writeState = options.writeState ?? writeDevState;
|
|
375
|
+
const removeState = options.removeState ?? removeDevState;
|
|
376
|
+
const ownPid = options.ownPid ?? process.pid;
|
|
377
|
+
/**
|
|
378
|
+
* Say one line to whoever is reading this session.
|
|
379
|
+
*
|
|
380
|
+
* **Under `--json`, stdout is reserved for JSON and the prose goes to stderr.** Everything a person is
|
|
381
|
+
* told here — the `Starting …` line, the delivery verdict, a `.dev.vars` refusal, and above all the
|
|
382
|
+
* workers' own teed output, which is the bulk of the stream and every line wrangler and Vite print —
|
|
383
|
+
* used to land on the same descriptor as the machine-readable line. So `pithy dev --json | jq` choked
|
|
384
|
+
* on the first thing wrangler said, and the only rule a consumer could apply was to try each line and
|
|
385
|
+
* skip what did not parse — which quietly skips a JSON line we get wrong, too. CLAUDE.md asks every
|
|
386
|
+
* command to be agent-drivable; a stream is only that if a script knows which lines are for it.
|
|
387
|
+
* Splitting by descriptor is the shell's own answer, costs a person nothing (both still reach the
|
|
388
|
+
* terminal, and `logs/dev.log` has every line in either mode), and gives the rule a consumer can
|
|
389
|
+
* actually apply: **every line on stdout is one object.** `docs/commands/dev.md` §`--json` states it.
|
|
390
|
+
*/
|
|
391
|
+
const emitLine = (text: string) => (options.json ? stderr : stdout)(`${text}\n`);
|
|
392
|
+
/** The machine's half: one object per line, always on stdout, only under `--json`. */
|
|
393
|
+
const emitJson = (payload: Record<string, unknown>) => stdout(`${formatJsonLine(payload)}\n`);
|
|
394
|
+
|
|
395
|
+
// 1. Discover the autostart set. apps/ is the registry; no hand-kept list.
|
|
396
|
+
const discovered = await discoverWorkers(projectDir);
|
|
397
|
+
|
|
398
|
+
// …plus the host Worker of every capability those Workers compose (pithy-sh/pithy#410). Nine
|
|
399
|
+
// capabilities ship a prebuilt host that `pithy <capability> provision` deploys, none of them
|
|
400
|
+
// lives in `apps/`, and until now not one had ever run under `pithy dev` — which is why every
|
|
401
|
+
// email enqueued locally sat `pending` forever while the UI reported success. A host joins as an
|
|
402
|
+
// ordinary member: its own pinned port, label, color, state entry, and teardown. Discovery is
|
|
403
|
+
// through the shared registry, so the dev command names no capability.
|
|
404
|
+
//
|
|
405
|
+
// The project name is settled first, and `requireProjectName` rather than a guess: it is stamped
|
|
406
|
+
// into a Worker script name, and a guessed one differs between checkouts. A project that states
|
|
407
|
+
// none gets no hosts and one line saying so, rather than hosts running under a name nothing else
|
|
408
|
+
// in the project would reproduce.
|
|
409
|
+
const project = await (options.projectName ?? defaultProjectName)(projectDir);
|
|
410
|
+
const findHosts = options.discoverHostWorkers ?? discoverHostWorkersDefault;
|
|
411
|
+
const hostFinding =
|
|
412
|
+
project === null
|
|
413
|
+
? {
|
|
414
|
+
hosts: [],
|
|
415
|
+
notes: [
|
|
416
|
+
"No project name in pithy.config.ts, so no capability host can be named — none will run.",
|
|
417
|
+
dim(' set: export default { name: "<project>" }'),
|
|
418
|
+
],
|
|
419
|
+
}
|
|
420
|
+
: await findHosts({ projectDir, workers: discovered });
|
|
421
|
+
for (const line of hostFinding.notes) emitLine(line);
|
|
422
|
+
const hosts = hostFinding.hosts;
|
|
423
|
+
const hostNames = new Set(hosts.map((host) => host.worker.name));
|
|
424
|
+
const members = [...discovered, ...hosts.map((host) => host.worker)];
|
|
425
|
+
const autostart = members.filter((w) => (w.dev ?? defaultWorkerDev()).autostart);
|
|
426
|
+
if (autostart.length === 0) {
|
|
427
|
+
throw new ValidationError({
|
|
428
|
+
message: "No autostart workers to run.",
|
|
429
|
+
action: "Add one with pithy worker add, or set dev.autostart in a worker's pithy.worker.jsonc.",
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// 2. Generate every worker's `.dev.vars`, before anything reads one (#154).
|
|
434
|
+
//
|
|
435
|
+
// wrangler loads the file beside the worker it runs, so each one needs its own. That was a symlink at
|
|
436
|
+
// a shared root file, and the symlink is the thing that never survived a clone: `pithy init` made it
|
|
437
|
+
// once for whoever created the project, and every developer after them cloned, wrote the `.dev.vars`
|
|
438
|
+
// the example told them to, and got nothing — every secret reported absent while the file sat at the
|
|
439
|
+
// root, unread. A `postinstall` could not fix it either, because the usual order is clone, install,
|
|
440
|
+
// *then* write `.dev.vars`. Generation removes the question: `pithy dev` is the command that runs
|
|
441
|
+
// every time, and it builds each file from the machine-local sources whether or not one is there.
|
|
442
|
+
//
|
|
443
|
+
// Idempotent by content, so a second `pithy dev` writes no bytes and wrangler's watcher sees nothing.
|
|
444
|
+
// A `.dev.vars` pithy did not generate is never overwritten and never merged — it is named, with the
|
|
445
|
+
// supported place for local values, and that worker starts without one rather than with somebody
|
|
446
|
+
// else's file replaced underneath it.
|
|
447
|
+
//
|
|
448
|
+
// Non-fatal in every direction. A project whose config directory is unreadable still starts, and
|
|
449
|
+
// says why its Workers have no bindings, because the alternative is a dev session that will not run
|
|
450
|
+
// at all over a file wrangler would have reported on itself.
|
|
451
|
+
const generate =
|
|
452
|
+
options.generateDevVars ??
|
|
453
|
+
((dir: string, dirs: string[]) => generateDevVars({ projectDir: dir, workerDirs: dirs }));
|
|
454
|
+
const generateInto = async (dirs: string[]): Promise<void> => {
|
|
455
|
+
const devVars = await generate(projectDir, dirs);
|
|
456
|
+
for (const line of renderDevVarsNotes(devVars)) emitLine(line);
|
|
457
|
+
for (const line of devVars.unresolvable) emitLine(line);
|
|
458
|
+
};
|
|
459
|
+
try {
|
|
460
|
+
const devVars = await generate(
|
|
461
|
+
projectDir,
|
|
462
|
+
discovered.map((worker) => worker.dir),
|
|
463
|
+
);
|
|
464
|
+
for (const line of renderDevVarsNotes(devVars)) emitLine(line);
|
|
465
|
+
// A Worker whose `pithy.config.ts` would not import (#199). Emitted here rather than folded into
|
|
466
|
+
// `renderDevVarsNotes`, because this is not a delivery outcome: the file was written, and written
|
|
467
|
+
// empty on purpose. It is the one thing a `pithy dev` in this state has to say, and until now it
|
|
468
|
+
// said nothing — the session started, the Worker came up with no bindings, and the only line about
|
|
469
|
+
// it was `Starting <worker>.` Every run, not once: the state persists until the config is fixed,
|
|
470
|
+
// and the run after the one they missed is the one that has to reach them.
|
|
471
|
+
for (const line of devVars.unresolvable) emitLine(line);
|
|
472
|
+
} catch (error) {
|
|
473
|
+
emitLine(`.dev.vars not generated. ${messageOf(error)}`);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// 3. Resolve pinned ports from the dev config — never probe. A project that has none yet (a plain
|
|
477
|
+
// `pithy init` checkout, which `pithy feature sync` refuses to touch) gets one bootstrapped here from
|
|
478
|
+
// the same central registry, so ports stay assigned-then-verified rather than probed at startup.
|
|
479
|
+
const ensure = options.ensureDevConfig ?? ensureDevConfig;
|
|
480
|
+
const existing = await loadDevConfig(projectDir);
|
|
481
|
+
const unpinned = autostart.filter((w) => !existing?.workers[w.name]);
|
|
482
|
+
const config =
|
|
483
|
+
unpinned.length === 0 && existing
|
|
484
|
+
? existing
|
|
485
|
+
: await ensure({ projectDir, workers: members, existing, ...(options.ensureDeps ?? {}) });
|
|
486
|
+
|
|
487
|
+
const started: { worker: WorkerTarget; port: number; origin: string }[] = [];
|
|
488
|
+
for (const worker of autostart) {
|
|
489
|
+
const pinned = config.workers[worker.name];
|
|
490
|
+
if (!pinned) {
|
|
491
|
+
throw new ValidationError({
|
|
492
|
+
message: `Worker "${worker.name}" has no port in .dev.config.json.`,
|
|
493
|
+
action: "Delete .dev.config.json and run pithy dev again to reassign this project's ports.",
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
started.push({ worker, port: pinned.port, origin: pinned.origin });
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// 3b. Resolve and write each host's local `wrangler.jsonc`, now that the app Worker's own address
|
|
500
|
+
// is known — a message sent from here builds its callback links against it.
|
|
501
|
+
//
|
|
502
|
+
// The delivery preflight runs first and *decides*. `remote: true` on the email host's send
|
|
503
|
+
// binding runs the Worker locally and delivers through Cloudflare Email Service for real, which
|
|
504
|
+
// is what makes a magic link triggered from localhost actually arrive; that needs a Cloudflare
|
|
505
|
+
// login and an onboarded sending domain, neither of which the kit owns. Where the cheap check
|
|
506
|
+
// can already see one of them is missing, the host is resolved for its local simulator instead
|
|
507
|
+
// of for a binding that would fail at startup — and it says so, before anyone is waiting on an
|
|
508
|
+
// inbox. The preflight is not the guarantee: `deliveryFailureNote` watches the host's own
|
|
509
|
+
// output for the failures it cannot see from here.
|
|
510
|
+
const hostPorts: Record<string, number> = {};
|
|
511
|
+
for (const host of hosts) {
|
|
512
|
+
const pinned = config.workers[host.worker.name];
|
|
513
|
+
if (pinned) hostPorts[host.worker.name] = pinned.port;
|
|
514
|
+
}
|
|
515
|
+
// The delivery verdict, said **once** — in the ready banner, which is where a developer looks, and
|
|
516
|
+
// pre-spawn only under `--json`, where there is no banner and the reader is a script. Saying it in
|
|
517
|
+
// both places was two copies of one sentence in every interactive session.
|
|
518
|
+
let deliveryLines: readonly string[] = [];
|
|
519
|
+
// The hosts that actually have a config on disk, the seam that wrote it, and the address it wrote
|
|
520
|
+
// them against — all three needed after the block below: only these are started, and a delivery
|
|
521
|
+
// failure at runtime rewrites one of them for its simulator.
|
|
522
|
+
let liveHosts: HostWorker[] = hosts;
|
|
523
|
+
let materializeHosts: ((options: MaterializeHostConfigsOptions) => Promise<HostMaterialization>) | undefined;
|
|
524
|
+
let hostBaseUrl = "http://localhost";
|
|
525
|
+
let deliveryIsLive = false;
|
|
526
|
+
if (project !== null && hosts.length > 0) {
|
|
527
|
+
// The app's address: the first started Worker that is not a host. Callback links point at the
|
|
528
|
+
// app, never at the host — the host holds no public route of its own.
|
|
529
|
+
const app = started.find((s) => !hostNames.has(s.worker.name));
|
|
530
|
+
const identity = await hostDeliveryIdentity(hosts);
|
|
531
|
+
const preflight = deliveryPreflight({
|
|
532
|
+
composed: identity !== undefined,
|
|
533
|
+
requested: identity?.requested ?? "remote",
|
|
534
|
+
fromAddress: identity?.fromAddress,
|
|
535
|
+
hasCloudflareLogin: await (options.hasCloudflareLogin ?? defaultHasCloudflareLogin)(
|
|
536
|
+
projectDir,
|
|
537
|
+
options.baseEnv ?? process.env,
|
|
538
|
+
),
|
|
539
|
+
});
|
|
540
|
+
deliveryLines = preflight.lines;
|
|
541
|
+
deliveryIsLive = preflight.live;
|
|
542
|
+
if (options.json) for (const line of preflight.lines) emitLine(line);
|
|
543
|
+
hostBaseUrl = app?.origin ?? started[0]?.origin ?? "http://localhost";
|
|
544
|
+
const materialize = options.materializeHostConfigs ?? materializeHostConfigsDefault;
|
|
545
|
+
materializeHosts = materialize;
|
|
546
|
+
const materialized = await materialize({
|
|
547
|
+
projectDir,
|
|
548
|
+
project,
|
|
549
|
+
baseUrl: hostBaseUrl,
|
|
550
|
+
hosts,
|
|
551
|
+
simulateDelivery: !preflight.live,
|
|
552
|
+
});
|
|
553
|
+
for (const line of materialized.notes) emitLine(line);
|
|
554
|
+
// A host with no config on disk leaves the set here, and that is the whole point of the second
|
|
555
|
+
// list. Its directory was never created, so `wrangler dev` in it fails on the spawn itself — Node
|
|
556
|
+
// raises `error`, the handler below tears the session down, and every Worker that was running fine
|
|
557
|
+
// dies for one capability nobody could resolve. The note said "it will not run"; this is what makes
|
|
558
|
+
// that true. Its siblings' `<STEM>_ORIGIN` goes with it, because an address nothing listens on is
|
|
559
|
+
// worse than none: the loopback dispatcher prefers a published origin over the binding.
|
|
560
|
+
const dropped = new Set(materialized.failed);
|
|
561
|
+
if (dropped.size > 0) {
|
|
562
|
+
for (let index = started.length - 1; index >= 0; index -= 1) {
|
|
563
|
+
if (dropped.has(started[index]?.worker.name ?? "")) started.splice(index, 1);
|
|
564
|
+
}
|
|
565
|
+
for (const name of dropped) delete hostPorts[name];
|
|
566
|
+
}
|
|
567
|
+
liveHosts = hosts.filter((host) => !dropped.has(host.worker.name));
|
|
568
|
+
// A host's `.dev.vars` is generated once its directory exists, from the same project-wide
|
|
569
|
+
// bootstrap set every Worker gets — the master key above all, since a local host has no Secrets
|
|
570
|
+
// Store for the resolved template's entries to point at (which is why that block is dropped).
|
|
571
|
+
// Through the one generator, so a `.dev.vars` value is never written by a second hand.
|
|
572
|
+
try {
|
|
573
|
+
await generateInto(liveHosts.map((host) => host.worker.dir));
|
|
574
|
+
} catch (error) {
|
|
575
|
+
emitLine(`Capability hosts start without secrets. ${messageOf(error)}`);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// 4. Stop a previous session, then reap orphaned workerd/wrangler still holding the pinned ports. This runs
|
|
580
|
+
// BEFORE verification: a crashed prior session's orphan must be reaped, not treated as an external
|
|
581
|
+
// conflict that blocks startup (docs/CLI.md §6.2 — a crashed session can't block the next one). The
|
|
582
|
+
// sweep is scoped to our own orphans — the previous session's pids and workerd/wrangler-shaped
|
|
583
|
+
// commands — so anything genuinely external falls through to step 5 and is reported, never killed.
|
|
584
|
+
const statePath = devStatePath(projectDir);
|
|
585
|
+
const previous = await stopPreviousSession({ statePath, readState, isAlive, kill, sleep, emitLine });
|
|
586
|
+
const sweep =
|
|
587
|
+
options.sweep ??
|
|
588
|
+
((ports: number[], knownPids: readonly number[]) =>
|
|
589
|
+
sweepStaleDevPorts(ports, { knownPids, selfPid: ownPid, log: (m) => emitLine(m) }));
|
|
590
|
+
await sweep(
|
|
591
|
+
started.map((s) => s.port),
|
|
592
|
+
previous?.childPids ?? [],
|
|
593
|
+
);
|
|
594
|
+
|
|
595
|
+
// 5. Verify every pinned port is now free on both loopback families — one conflict (something genuinely
|
|
596
|
+
// external still holds it) aborts the whole session with one error; it never drifts to another port.
|
|
597
|
+
for (const { worker, port } of started) {
|
|
598
|
+
await verifyPinnedPort(worker.name, port, bind);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// 6. Resolve the wrangler launcher through the project's package manager (never a hardcoded global).
|
|
602
|
+
const launchWrangler =
|
|
603
|
+
options.launchWrangler ??
|
|
604
|
+
(await (async () => {
|
|
605
|
+
const pm = await detectPackageManager(projectDir);
|
|
606
|
+
return (args: string[]) => execArgs(pm, "wrangler", args);
|
|
607
|
+
})());
|
|
608
|
+
|
|
609
|
+
// 7. Open the log, wire the shared env, and spawn.
|
|
610
|
+
const logPath = join(projectDir, "logs", "dev.log");
|
|
611
|
+
// Read before anything spawns, so the banner never waits on the disk once the workers are up.
|
|
612
|
+
const devLogin = await readDevLogin(projectDir);
|
|
613
|
+
const log = openLog(logPath);
|
|
614
|
+
log.write(
|
|
615
|
+
`=== dev session ${now().toISOString()} — ${started.map((s) => `${s.worker.name}:${s.port}`).join(", ")} ===`,
|
|
616
|
+
);
|
|
617
|
+
const baseEnv = options.baseEnv ?? process.env;
|
|
618
|
+
// The keypress follows the route. Under CI the auth capability registers none, so `l` would open a
|
|
619
|
+
// 404 — the read is the same one the capability makes, from the same module, and it is the only
|
|
620
|
+
// refusal the supervisor can see coming rather than discover.
|
|
621
|
+
const ci = isContinuousIntegration(baseEnv);
|
|
622
|
+
// Which running workers carry `GET /__pithy/dev-login` — the ones composing auth. Resolved only when
|
|
623
|
+
// there is a session to open, so a project with no dev login never loads a Worker config for this.
|
|
624
|
+
const devLoginWorkers: DevLoginTarget[] =
|
|
625
|
+
devLogin && !ci
|
|
626
|
+
? await resolveDevLoginTargets(
|
|
627
|
+
started
|
|
628
|
+
.filter((s) => !hostNames.has(s.worker.name))
|
|
629
|
+
.map((s) => ({ name: s.worker.name, dir: s.worker.dir, origin: s.origin })),
|
|
630
|
+
)
|
|
631
|
+
: [];
|
|
632
|
+
const childEnv = buildWorkerEnv(config, baseEnv);
|
|
633
|
+
// One local store for the whole project, named in one place — `localDevStateRoot`. This used to compose
|
|
634
|
+
// the path itself, which made three independent statements of one directory (#404).
|
|
635
|
+
const persistTo = localDevStateRoot(projectDir);
|
|
636
|
+
|
|
637
|
+
const children: { name: string; child: ChildLike }[] = [];
|
|
638
|
+
const pipes: Promise<void>[] = [];
|
|
639
|
+
const exits: Promise<void>[] = [];
|
|
640
|
+
const readyState = new Map<string, boolean>();
|
|
641
|
+
const readyRegex = new Map<string, RegExp>();
|
|
642
|
+
let bannerShown = false;
|
|
643
|
+
let resolveReady!: () => void;
|
|
644
|
+
const ready = new Promise<void>((resolve) => {
|
|
645
|
+
resolveReady = resolve;
|
|
646
|
+
});
|
|
647
|
+
let resolveClosed!: () => void;
|
|
648
|
+
const closed = new Promise<void>((resolve) => {
|
|
649
|
+
resolveClosed = resolve;
|
|
650
|
+
});
|
|
651
|
+
let shuttingDown = false;
|
|
652
|
+
|
|
653
|
+
// The ready deadline's timer, replaced by the live watch once the children are spawned. Declared here
|
|
654
|
+
// for the same reason `keys` is: the banner and the shutdown both stop it, and both are written above
|
|
655
|
+
// the spawn loop that starts it.
|
|
656
|
+
let readyWatch: ReadyWatch = { stop: () => {} };
|
|
657
|
+
|
|
658
|
+
const showBannerIfReady = () => {
|
|
659
|
+
if (bannerShown || [...readyState.values()].some((r) => !r)) return;
|
|
660
|
+
bannerShown = true;
|
|
661
|
+
readyWatch.stop();
|
|
662
|
+
if (!options.json) {
|
|
663
|
+
// Bindings go live with the banner, not before it: `l` opens a URL, and a URL that answers is a
|
|
664
|
+
// worker that has already matched its ready signal. `--json` gets none — its output is being read
|
|
665
|
+
// by a script, and a supervisor that entered raw mode for a machine would be holding a terminal
|
|
666
|
+
// nobody is at.
|
|
667
|
+
startKeys();
|
|
668
|
+
emitLine("Ready.");
|
|
669
|
+
for (const s of started) emitLine(`${s.worker.name}: ${s.origin}`);
|
|
670
|
+
// Said once, where a developer actually looks. Real delivery or the simulator is the difference
|
|
671
|
+
// between a magic link arriving and a rendered file on disk, and nobody should learn it from an
|
|
672
|
+
// inbox that stays empty. Every line of the verdict, action included — a sentence naming the
|
|
673
|
+
// problem without the sentence naming the fix is half a report.
|
|
674
|
+
for (const line of deliveryLines) emitLine(line);
|
|
675
|
+
// The banner is the discovery mechanism. A seeded session nobody finds has removed no friction, and
|
|
676
|
+
// the line below is the only place a developer reliably looks after `pithy dev`. It says that there
|
|
677
|
+
// is a session and how to reach it — never what the session *is*.
|
|
678
|
+
for (const line of devLoginLines(devLogin, now(), { interactive: keys.active, targets: devLoginWorkers, ci })) {
|
|
679
|
+
emitLine(line);
|
|
680
|
+
}
|
|
681
|
+
emitLine(dim(`logs → ${logPath}`));
|
|
682
|
+
}
|
|
683
|
+
for (const s of started) log.write(`ready: ${s.worker.name} ${s.origin}`);
|
|
684
|
+
resolveReady();
|
|
685
|
+
};
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* `l` — open a signed-in browser.
|
|
689
|
+
*
|
|
690
|
+
* The decision is {@link devLoginKeyAction}'s and is made without touching the terminal, so the only
|
|
691
|
+
* work here is saying it and handing the URL over. A failed open is reported and survived: `pithy dev`
|
|
692
|
+
* supervises workers, and no browser is a reason for a sentence, not for tearing a session down.
|
|
693
|
+
*/
|
|
694
|
+
const openDevLogin = async (): Promise<void> => {
|
|
695
|
+
const action = devLoginKeyAction(devLogin, now(), devLoginWorkers, ci);
|
|
696
|
+
for (const line of action.lines) emitLine(line);
|
|
697
|
+
if (!action.url) return;
|
|
698
|
+
try {
|
|
699
|
+
await openUrl(action.url);
|
|
700
|
+
} catch (error) {
|
|
701
|
+
emitLine(messageOf(error));
|
|
702
|
+
}
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
// Scoped to `l`. A second binding is one more entry here — `r` to restart and `o` to open the app are
|
|
706
|
+
// the obvious neighbors — and neither is this issue.
|
|
707
|
+
let keys: KeyReader = { active: false, stop: () => {} };
|
|
708
|
+
const startKeys = () => {
|
|
709
|
+
keys = readKeys({
|
|
710
|
+
bindings: [{ key: "l", run: openDevLogin }],
|
|
711
|
+
// Raw mode takes the terminal's own Ctrl-C handling away, so the supervisor has to put it back.
|
|
712
|
+
// Without this line `pithy dev` becomes unstoppable from the keyboard.
|
|
713
|
+
onInterrupt: () => void shutdown("interrupted"),
|
|
714
|
+
onError: (error) => emitLine(messageOf(error)),
|
|
715
|
+
});
|
|
716
|
+
};
|
|
717
|
+
|
|
718
|
+
const signalChild = (pid: number | undefined, signal: NodeJS.Signals) => {
|
|
719
|
+
if (!pid) return;
|
|
720
|
+
try {
|
|
721
|
+
kill(hasSetsid ? -pid : pid, signal);
|
|
722
|
+
} catch {
|
|
723
|
+
// Already exited (ESRCH) — nothing to signal.
|
|
724
|
+
}
|
|
725
|
+
};
|
|
726
|
+
|
|
727
|
+
const shutdown = async (reason: string): Promise<void> => {
|
|
728
|
+
if (shuttingDown) return;
|
|
729
|
+
shuttingDown = true;
|
|
730
|
+
// First, before anything can take time: give the terminal back. A session that died with the
|
|
731
|
+
// terminal in raw mode leaves a shell that echoes nothing.
|
|
732
|
+
keys.stop();
|
|
733
|
+
readyWatch.stop();
|
|
734
|
+
emitLine(`Stopping — ${reason}.`);
|
|
735
|
+
log.write(`stopping — ${reason}`);
|
|
736
|
+
for (const { child } of children) signalChild(child.pid, "SIGTERM");
|
|
737
|
+
const allExited = Promise.allSettled(exits);
|
|
738
|
+
const timedOut = await Promise.race([allExited.then(() => false), sleep(SHUTDOWN_GRACE_MS).then(() => true)]);
|
|
739
|
+
if (timedOut) {
|
|
740
|
+
emitLine("Children still alive after grace window — sending SIGKILL.");
|
|
741
|
+
for (const { child } of children) signalChild(child.pid, "SIGKILL");
|
|
742
|
+
await allExited;
|
|
743
|
+
}
|
|
744
|
+
await Promise.allSettled(pipes);
|
|
745
|
+
await log.end();
|
|
746
|
+
removeState(statePath, ownPid);
|
|
747
|
+
resolveClosed();
|
|
748
|
+
};
|
|
749
|
+
|
|
750
|
+
// The entitlement composition check, reported once at startup. The seam fails closed, so a Worker that
|
|
751
|
+
// gates routes on an entitlement while composing no provider denies every one of them — and at runtime
|
|
752
|
+
// that is indistinguishable from a project full of unentitled users. Non-fatal: it is a warning about
|
|
753
|
+
// wiring, not a reason to refuse to run, and a config that will not load is left to wrangler to report.
|
|
754
|
+
const checkEntitlements = options.checkEntitlements ?? defaultCheckEntitlements;
|
|
755
|
+
for (const { worker } of started) {
|
|
756
|
+
const gates = await checkEntitlements(worker.dir);
|
|
757
|
+
if (gates.length === 0) continue;
|
|
758
|
+
emitLine(`${worker.name}: routes gate on an entitlement, but no capability resolves one — they will deny.`);
|
|
759
|
+
for (const gate of gates) emitLine(dim(` ${gate}`));
|
|
760
|
+
emitLine(dim(" run: pithy add payments"));
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// Secrets are seeded before anything spawns, for the same reason the `.dev.vars` link is wired before
|
|
764
|
+
// anything spawns (#139): a Worker reads its secrets on the first request, and a store seeded after
|
|
765
|
+
// startup is a store the first sign-in of the session missed. Idempotent, so this is silent on every
|
|
766
|
+
// run but the one that changed something.
|
|
767
|
+
//
|
|
768
|
+
// Non-fatal, in both directions. A project that never composed `secrets` has nothing to seed and
|
|
769
|
+
// hears nothing. A dev secrets file that will not parse is said out loud and the session still
|
|
770
|
+
// starts — refusing to run every Worker over one malformed file would be a worse trade than letting
|
|
771
|
+
// the capability that needs the secret fail with its own error.
|
|
772
|
+
const seedSecrets = options.seedSecrets ?? ((dir: string) => seedProjectDevSecrets({ projectDir: dir }));
|
|
773
|
+
try {
|
|
774
|
+
for (const line of renderDevSecretsNotes(await seedSecrets(projectDir))) emitLine(line);
|
|
775
|
+
} catch (error) {
|
|
776
|
+
emitLine(`Secrets not seeded. ${messageOf(error)}`);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* The runtime half of the delivery fallback (pithy-sh/pithy#410).
|
|
781
|
+
*
|
|
782
|
+
* The preflight decides what it can see from outside the process; a remote `send_email` binding that
|
|
783
|
+
* will not stand up, and a send Cloudflare refuses, appear only in the host's own output. Reporting
|
|
784
|
+
* that and stopping there leaves the session in the one state the issue forbids — every subsequent
|
|
785
|
+
* magic link failing, quietly, for the rest of the afternoon. So the host is re-resolved for its
|
|
786
|
+
* local simulator, which sends nothing and logs the recipient, subject and URL.
|
|
787
|
+
*
|
|
788
|
+
* **Rewriting the config is the whole restart.** `wrangler dev` watches the `wrangler.jsonc` it was
|
|
789
|
+
* started with and reloads the Worker when it changes, so the fallback needs no second spawn path,
|
|
790
|
+
* no kill that the exit handler would read as a crash, and no port to re-verify.
|
|
791
|
+
*
|
|
792
|
+
* Once per host. A failing binding usually says so more than once, and a rewrite loop would reload
|
|
793
|
+
* the Worker on every line it printed.
|
|
794
|
+
*/
|
|
795
|
+
const simulated = new Set<string>();
|
|
796
|
+
const fallBackToSimulator = async (capability: string): Promise<void> => {
|
|
797
|
+
// Nothing to fall back to when this session was never sending for real: the host already holds the
|
|
798
|
+
// simulator, and rewriting an identical config would reload a Worker for no change.
|
|
799
|
+
if (!deliveryIsLive || simulated.has(capability) || !materializeHosts || project === null) return;
|
|
800
|
+
const host = liveHosts.find((candidate) => candidate.worker.name === capability);
|
|
801
|
+
if (!host) return;
|
|
802
|
+
simulated.add(capability);
|
|
803
|
+
try {
|
|
804
|
+
const again = await materializeHosts({
|
|
805
|
+
projectDir,
|
|
806
|
+
project,
|
|
807
|
+
baseUrl: hostBaseUrl,
|
|
808
|
+
hosts: [host],
|
|
809
|
+
simulateDelivery: true,
|
|
810
|
+
});
|
|
811
|
+
for (const line of again.notes) emitLine(line);
|
|
812
|
+
emitLine(`${capability}: using the simulator from here. Messages are logged and written to disk, never sent.`);
|
|
813
|
+
} catch (error) {
|
|
814
|
+
emitLine(`${capability}: the simulator fallback could not be written. ${messageOf(error)}`);
|
|
815
|
+
}
|
|
816
|
+
};
|
|
817
|
+
|
|
818
|
+
emitLine(`Starting ${started.map((s) => s.worker.name).join(", ")}.`);
|
|
819
|
+
|
|
820
|
+
for (const { worker, port, origin } of started) {
|
|
821
|
+
readyState.set(worker.name, false);
|
|
822
|
+
readyRegex.set(worker.name, readyRegexFor(worker));
|
|
823
|
+
// A host is handed no origin of its own: `materializeHostConfigs` already wrote the app's into its
|
|
824
|
+
// generated config, which is the address its callback links must carry. See `ownOriginFor`.
|
|
825
|
+
const ownOrigin = ownOriginFor(worker.name, origin, hostNames);
|
|
826
|
+
const { command, args } = startCommand(worker, port, ownOrigin, launchWrangler, persistTo, baseEnv, hostPorts);
|
|
827
|
+
// Both carriers, from the one value above. The argv `--var` reaches a `wrangler dev`; the
|
|
828
|
+
// environment reaches a custom `dev.command`, where there is no argv to append to and
|
|
829
|
+
// `@pithy-sh/vite` turns it into the same binding.
|
|
830
|
+
const env = childEnvFor(childEnv, ownOrigin);
|
|
831
|
+
const child = spawn(command, args, { cwd: worker.dir, env, detached: hasSetsid });
|
|
832
|
+
children.push({ name: worker.name, child });
|
|
833
|
+
|
|
834
|
+
const isHost = hostNames.has(worker.name);
|
|
835
|
+
const onLine = (line: string) => {
|
|
836
|
+
// A remote send binding that will not stand up, or a send Cloudflare refuses, appears here and
|
|
837
|
+
// nowhere else — the preflight above cannot see either from outside the process. Caught where it
|
|
838
|
+
// appears, rendered with the action that fixes it, then the host is dropped to its simulator so
|
|
839
|
+
// the rest of the session still sends something. Never fatal: `pithy dev` supervises Workers, and
|
|
840
|
+
// a message that did not send is a reason for a sentence, not for a teardown.
|
|
841
|
+
if (isHost) {
|
|
842
|
+
const note = deliveryFailureNote(line);
|
|
843
|
+
if (note) {
|
|
844
|
+
for (const text of note.split("\n")) emitLine(text);
|
|
845
|
+
void fallBackToSimulator(worker.name);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
if (bannerShown || readyState.get(worker.name)) return;
|
|
849
|
+
if (readyRegex.get(worker.name)?.test(line)) {
|
|
850
|
+
readyState.set(worker.name, true);
|
|
851
|
+
showBannerIfReady();
|
|
852
|
+
}
|
|
853
|
+
};
|
|
854
|
+
const paint = workerColor(children.length - 1);
|
|
855
|
+
if (child.stdout) {
|
|
856
|
+
pipes.push(
|
|
857
|
+
teeStream({
|
|
858
|
+
stream: child.stdout,
|
|
859
|
+
label: worker.name,
|
|
860
|
+
paint,
|
|
861
|
+
sinks: { terminal: (l) => emitLine(l), log: (l) => log.write(l), line: onLine },
|
|
862
|
+
}),
|
|
863
|
+
);
|
|
864
|
+
}
|
|
865
|
+
if (child.stderr) {
|
|
866
|
+
pipes.push(
|
|
867
|
+
teeStream({
|
|
868
|
+
stream: child.stderr,
|
|
869
|
+
label: worker.name,
|
|
870
|
+
paint,
|
|
871
|
+
sinks: { terminal: (l) => emitLine(l), log: (l) => log.write(l), line: onLine },
|
|
872
|
+
}),
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
exits.push(
|
|
877
|
+
new Promise<void>((resolve) => {
|
|
878
|
+
child.once("exit", (code) => {
|
|
879
|
+
resolve();
|
|
880
|
+
if (!shuttingDown) void shutdown(`${worker.name} exited (${code})`);
|
|
881
|
+
});
|
|
882
|
+
// A spawn failure (ENOENT for a missing dev.command binary, EACCES, …) emits 'error' and never 'exit'.
|
|
883
|
+
// Without this listener Node re-throws it as an uncaught error, crashing dev with a raw stack and never
|
|
884
|
+
// shutting down. Handle it: report it, settle this child's exit, and tear the session down.
|
|
885
|
+
child.once("error", (error) => {
|
|
886
|
+
emitLine(`${worker.name} failed to start: ${error.message}`);
|
|
887
|
+
log.write(`error: ${worker.name} ${error.message}`);
|
|
888
|
+
resolve();
|
|
889
|
+
if (!shuttingDown) void shutdown(`${worker.name} failed to start`);
|
|
890
|
+
});
|
|
891
|
+
}),
|
|
892
|
+
);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// 8. Start the ready deadline, now that every child is running (pithy-sh/pithy#429).
|
|
896
|
+
//
|
|
897
|
+
// `wrangler dev` does not exit when a build fails — it prints the error and keeps running. So a
|
|
898
|
+
// worker that cannot build is a live child that never matches its ready signal: the banner waits on
|
|
899
|
+
// the whole set and never fires, and the session proceeds looking healthy with the real error forty
|
|
900
|
+
// lines up the scrollback, interleaved with every sibling's startup. Three capability workers reached
|
|
901
|
+
// an adopter that way (#426). The deadline names whoever has not arrived, and keeps naming them.
|
|
902
|
+
//
|
|
903
|
+
// **A child that fails to build is deliberately not treated as dead — it is reported, and left.**
|
|
904
|
+
// The tempting alternative loses on three counts. A death here is not local: the exit handler above
|
|
905
|
+
// tears the *whole* session down when any child exits, so condemning one broken build would stop
|
|
906
|
+
// every healthy worker for one worker's typo, which is a worse trade than a line naming it. The
|
|
907
|
+
// verdict would have to be read out of wrangler's own output (`Build failed with 1 error`), which
|
|
908
|
+
// is version-coupled prose, and a false positive kills a working session — while a `dev.command`
|
|
909
|
+
// worker is not wrangler at all, and Vite does recover from a bad build. And the deadline already
|
|
910
|
+
// catches strictly more than a build failure: a port that never binds, a binding that never
|
|
911
|
+
// resolves, a startup that hangs. So the watch reports; it never condemns.
|
|
912
|
+
//
|
|
913
|
+
// It does say what a restart cannot be avoided for. A `wrangler dev` whose **first** build fails
|
|
914
|
+
// never rebuilds — fixing the file changes nothing, measured, so the report's action line names
|
|
915
|
+
// `pithy dev` rather than implying the session will heal itself.
|
|
916
|
+
//
|
|
917
|
+
// **`--json` gets a record, not the prose.** CLAUDE.md makes every command agent-drivable, and the
|
|
918
|
+
// agent driving `pithy dev --json` is in exactly the position #426's adopter was: a session that
|
|
919
|
+
// never emits its ready line, and nothing on the wire saying which worker is missing. A sentence it
|
|
920
|
+
// would have to regex is not an answer, so the deadline emits one JSON line per report — the same
|
|
921
|
+
// line-per-object shape as the handshake above it, `event` naming which kind of line it is. That is
|
|
922
|
+
// the one place `pithy dev`'s streaming surface owes a machine something the handshake cannot carry:
|
|
923
|
+
// the handshake is written the moment the children are spawned, and readiness is decided after it.
|
|
924
|
+
// The prose still goes to `logs/dev.log` in both modes — the log is read by a person either way.
|
|
925
|
+
readyWatch = watchReady({
|
|
926
|
+
pending: () => started.map((s) => s.worker.name).filter((name) => !readyState.get(name)),
|
|
927
|
+
report: (waiting, first) => {
|
|
928
|
+
// Both destinations, the way the banner's own lines go: a report only in the terminal is a report
|
|
929
|
+
// a piped session loses, and `logs/dev.log` is where a developer looks after the fact.
|
|
930
|
+
const lines = stillWaitingLines(waiting, first);
|
|
931
|
+
if (options.json) {
|
|
932
|
+
emitJson({ command: "dev", event: "still-waiting", waiting: [...waiting] });
|
|
933
|
+
} else {
|
|
934
|
+
for (const line of lines) emitLine(line);
|
|
935
|
+
}
|
|
936
|
+
for (const line of lines) log.write(stripAnsi(line));
|
|
937
|
+
},
|
|
938
|
+
schedule: options.schedule,
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
// 9. Record the live session so a re-run can stop it and reap its children.
|
|
942
|
+
const state: DevState = {
|
|
943
|
+
pid: ownPid,
|
|
944
|
+
startedAt: now().toISOString(),
|
|
945
|
+
childPids: children.map((c) => c.child.pid).filter((pid): pid is number => typeof pid === "number"),
|
|
946
|
+
workers: Object.fromEntries(
|
|
947
|
+
children.map(({ name, child }, i) => [name, { port: started[i]?.port ?? 0, pid: child.pid ?? 0 }]),
|
|
948
|
+
),
|
|
949
|
+
};
|
|
950
|
+
await writeState(statePath, state);
|
|
951
|
+
|
|
952
|
+
return {
|
|
953
|
+
workers: started.map((s) => ({ name: s.worker.name, port: s.port, origin: s.origin })),
|
|
954
|
+
ready,
|
|
955
|
+
closed,
|
|
956
|
+
shutdown,
|
|
957
|
+
state,
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* Stop a still-running previous session, or reap the orphaned children of a crashed one. Returns the state
|
|
963
|
+
* it read, so the port sweep knows which pids were ours and can leave every other one to be reported.
|
|
964
|
+
*/
|
|
965
|
+
async function stopPreviousSession(deps: {
|
|
966
|
+
statePath: string;
|
|
967
|
+
readState: (path: string) => Promise<DevState | null>;
|
|
968
|
+
isAlive: (pid: number) => boolean;
|
|
969
|
+
kill: (pid: number, signal: NodeJS.Signals) => void;
|
|
970
|
+
sleep: Sleep;
|
|
971
|
+
emitLine: (text: string) => void;
|
|
972
|
+
}): Promise<DevState | null> {
|
|
973
|
+
const prev = await deps.readState(deps.statePath);
|
|
974
|
+
if (!prev) return null;
|
|
975
|
+
if (deps.isAlive(prev.pid)) {
|
|
976
|
+
deps.emitLine(`Stopping previous session (pid ${prev.pid}).`);
|
|
977
|
+
trySignal(deps.kill, prev.pid, "SIGINT");
|
|
978
|
+
const gone = await waitFor(prev.pid, 5000, deps.isAlive, deps.sleep);
|
|
979
|
+
if (!gone) {
|
|
980
|
+
trySignal(deps.kill, prev.pid, "SIGKILL");
|
|
981
|
+
await waitFor(prev.pid, 2000, deps.isAlive, deps.sleep);
|
|
982
|
+
}
|
|
983
|
+
return prev;
|
|
984
|
+
}
|
|
985
|
+
for (const pid of prev.childPids) {
|
|
986
|
+
if (deps.isAlive(pid)) {
|
|
987
|
+
deps.emitLine(`Reaping orphan child pid ${pid}.`);
|
|
988
|
+
trySignal(deps.kill, pid, "SIGTERM");
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
return prev;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
function trySignal(kill: (pid: number, signal: NodeJS.Signals) => void, pid: number, signal: NodeJS.Signals): void {
|
|
995
|
+
try {
|
|
996
|
+
kill(pid, signal);
|
|
997
|
+
} catch {
|
|
998
|
+
// Already gone.
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
async function waitFor(
|
|
1003
|
+
pid: number,
|
|
1004
|
+
timeoutMs: number,
|
|
1005
|
+
isAlive: (pid: number) => boolean,
|
|
1006
|
+
sleep: Sleep,
|
|
1007
|
+
): Promise<boolean> {
|
|
1008
|
+
const start = Date.now();
|
|
1009
|
+
for (;;) {
|
|
1010
|
+
if (!isAlive(pid)) return true;
|
|
1011
|
+
if (Date.now() - start > timeoutMs) return false;
|
|
1012
|
+
await sleep(100);
|
|
1013
|
+
}
|
|
1014
|
+
}
|