@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,394 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { access } from "node:fs/promises";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import type { Capability } from "@pithy-sh/core/src/capability/capability";
|
|
7
|
+
import { messageOf, NotFoundError, PithyError, ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
8
|
+
import { composeCapabilities } from "../capabilities/compose";
|
|
9
|
+
import { allCapabilities, loadWorkerConfig, type WorkerConfig } from "./config";
|
|
10
|
+
import { discoverWorkers as discoverWorkersDefault, type WorkerTarget } from "./workers";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resolving *which Worker* a command acts on — the one seam every per-Worker command shares.
|
|
14
|
+
*
|
|
15
|
+
* Since every Worker lives in `apps/<name>/` with its own `pithy.config.ts` and `wrangler.jsonc`, a command
|
|
16
|
+
* either targets **one** Worker (`add`, `remove` — they write that Worker's wiring) or **fans out** over the
|
|
17
|
+
* whole set (`migrate`, `seed`, `upgrade`, `doctor`, `env`). Both paths resolve through here so the ambiguity
|
|
18
|
+
* rules and the error copy stay identical across the CLI.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** A Worker plus its loaded config — what a per-Worker command actually needs to do its work. */
|
|
22
|
+
export interface ResolvedWorker {
|
|
23
|
+
/** The Worker's name (its `wrangler.jsonc` name, else its `apps/<dir>` basename). */
|
|
24
|
+
name: string;
|
|
25
|
+
/** The Worker's directory — where its `wrangler.jsonc` and `pithy.config.ts` live. */
|
|
26
|
+
dir: string;
|
|
27
|
+
/** The Worker's own `pithy.config.ts`. */
|
|
28
|
+
config: WorkerConfig;
|
|
29
|
+
/** That config's capabilities in composition order — libraries first, its app last. */
|
|
30
|
+
capabilities: Capability[];
|
|
31
|
+
/** The discovered target, carrying its dev-manifest block. */
|
|
32
|
+
target: WorkerTarget;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Shared options for both resolvers: the project root, plus seams so tests need no real filesystem. */
|
|
36
|
+
export interface ResolveOptions {
|
|
37
|
+
/** The project root — the parent of `apps/`. */
|
|
38
|
+
projectDir: string;
|
|
39
|
+
/** Discovery seam (default: `discoverWorkers`). */
|
|
40
|
+
discoverWorkers?: (projectDir: string) => Promise<WorkerTarget[]>;
|
|
41
|
+
/** Worker-config loader seam (default: `loadWorkerConfig`). */
|
|
42
|
+
loadConfig?: (workerDir: string) => Promise<WorkerConfig>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The actionable error when nothing resolved, distinguishing the two very different causes: standing outside
|
|
47
|
+
* a Pithy project entirely (no root `pithy.config.ts` — you want `pithy init`) versus standing in one that has
|
|
48
|
+
* no Workers yet (you want `pithy worker add`). Telling someone to add a worker when they are simply in the
|
|
49
|
+
* wrong directory sends them the wrong way.
|
|
50
|
+
*/
|
|
51
|
+
async function noWorkers(projectDir: string): Promise<never> {
|
|
52
|
+
try {
|
|
53
|
+
await access(join(projectDir, "pithy.config.ts"));
|
|
54
|
+
} catch {
|
|
55
|
+
throw new NotFoundError({
|
|
56
|
+
message: "No pithy.config.ts here.",
|
|
57
|
+
action: "Run from a Pithy project. pithy init creates one.",
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
throw new NotFoundError({
|
|
61
|
+
message: "No workers here.",
|
|
62
|
+
action: "Every worker lives in apps/<name>. Run pithy worker add <name> to create one.",
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Load a discovered target's config, attaching its capabilities. */
|
|
67
|
+
async function resolve(target: WorkerTarget, load: (dir: string) => Promise<WorkerConfig>): Promise<ResolvedWorker> {
|
|
68
|
+
const config = await load(target.dir);
|
|
69
|
+
return { name: target.name, dir: target.dir, config, capabilities: allCapabilities(config), target };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A discovered Worker left out of a resolution, and why. */
|
|
73
|
+
export interface SkippedWorker {
|
|
74
|
+
/** The Worker's name, as `pithy worker list` shows it. */
|
|
75
|
+
name: string;
|
|
76
|
+
/** The Worker's directory. */
|
|
77
|
+
dir: string;
|
|
78
|
+
/** Why its config could not be read. Reaches a terminal, so never `detail`. */
|
|
79
|
+
reason: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Both halves of one resolution: the Workers that resolved, and the ones that could not be asked. */
|
|
83
|
+
export interface WorkerResolution {
|
|
84
|
+
/** Every Worker whose `pithy.config.ts` loaded, in discovery order. */
|
|
85
|
+
workers: ResolvedWorker[];
|
|
86
|
+
/**
|
|
87
|
+
* Every Worker the set is *missing* — a directory with a `wrangler.jsonc` whose `pithy.config.ts` is
|
|
88
|
+
* absent. Empty on an ordinary run, and empty for a dev-only process, which never had one.
|
|
89
|
+
*/
|
|
90
|
+
skipped: SkippedWorker[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Every Worker in the project **and every one that had to be left out** — the two answers that must not
|
|
95
|
+
* arrive as one (#455). {@link resolveWorkers} is this with the second half dropped, which is the right
|
|
96
|
+
* call for a fan-out that acts per Worker and the wrong one for anything reasoning about the project as a
|
|
97
|
+
* whole.
|
|
98
|
+
*
|
|
99
|
+
* A `pithy.config.ts` that **throws** still throws from here, unchanged: swallowing it reports
|
|
100
|
+
* "No workers here" for a project that plainly has workers, hiding the real cause (usually uninstalled
|
|
101
|
+
* dependencies or a syntax error). What changes is the *absent* file. A dev-only process — a Vite frontend
|
|
102
|
+
* joining the dev set through `pithy.worker.jsonc` alone — has no config and never had one, so skipping it
|
|
103
|
+
* is the ordinary state. A directory with a `wrangler.jsonc` is a Worker, so a missing config there means
|
|
104
|
+
* the resolved set is **incomplete**, not merely smaller: `#454`'s guard closes "a config throws" and this
|
|
105
|
+
* is the case it does not cover. `feature destroy`'s reconcile backstop scans the union's bindings, so a
|
|
106
|
+
* Worker silently dropped leaks every resource it declared while the run exits 0.
|
|
107
|
+
*
|
|
108
|
+
* `hasWrangler` is what tells the two apart, and it is read strictly (`=== true`): real discovery always
|
|
109
|
+
* sets it, and the many test doubles that carry only `name`/`dir` must not start reporting themselves as
|
|
110
|
+
* gaps. See {@link WorkerTarget}.
|
|
111
|
+
*/
|
|
112
|
+
export async function resolveWorkersReporting(
|
|
113
|
+
options: ResolveOptions & { worker?: string },
|
|
114
|
+
): Promise<WorkerResolution> {
|
|
115
|
+
const discover = options.discoverWorkers ?? discoverWorkersDefault;
|
|
116
|
+
const load = options.loadConfig ?? loadWorkerConfig;
|
|
117
|
+
const targets = await discover(options.projectDir);
|
|
118
|
+
if (targets.length === 0) await noWorkers(options.projectDir);
|
|
119
|
+
|
|
120
|
+
if (options.worker !== undefined) {
|
|
121
|
+
// A named Worker is the one the caller asked for: its config is loaded, and its failure is theirs to
|
|
122
|
+
// see. Narrowing happens *before* the load, so a sibling's broken config costs nothing.
|
|
123
|
+
return { workers: [await resolve(pick(targets, options.worker), load)], skipped: [] };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const workers: ResolvedWorker[] = [];
|
|
127
|
+
const skipped: SkippedWorker[] = [];
|
|
128
|
+
for (const target of targets) {
|
|
129
|
+
let config: WorkerConfig;
|
|
130
|
+
try {
|
|
131
|
+
config = await load(target.dir);
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (!(error instanceof PithyError && error.payload.code === "core/not_found")) throw error;
|
|
134
|
+
if (target.hasWrangler === true) skipped.push({ name: target.name, dir: target.dir, reason: messageOf(error) });
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
workers.push({ name: target.name, dir: target.dir, config, capabilities: allCapabilities(config), target });
|
|
138
|
+
}
|
|
139
|
+
// Nothing resolved and nothing skipped is genuinely nothing, and {@link noWorkers} says which kind. An
|
|
140
|
+
// empty set *with* something skipped is a different fact, and reporting it is this function's whole job
|
|
141
|
+
// — the refusal belongs to {@link resolveWorkers}, whose callers have only an array to read it from.
|
|
142
|
+
if (workers.length === 0 && skipped.length === 0) await noWorkers(options.projectDir);
|
|
143
|
+
return { workers, skipped };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The refusal when every discovered Worker is missing its `pithy.config.ts`. "No workers here" is false —
|
|
148
|
+
* the project plainly has them — and it sent the reader to `pithy worker add` for Workers that exist.
|
|
149
|
+
*/
|
|
150
|
+
function everyWorkerUnreadable(skipped: readonly SkippedWorker[]): never {
|
|
151
|
+
throw new NotFoundError({
|
|
152
|
+
message: "Every worker here is missing its pithy.config.ts.",
|
|
153
|
+
action: `Restore it, or run pithy worker add to rewrite one. Missing: ${skipped.map((s) => s.name).join(", ")}.`,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Every Worker in the project, each with its config loaded — the fan-out set for `migrate`, `seed`,
|
|
159
|
+
* `upgrade`, `doctor`, and `env`. Pass `worker` to narrow to one. Workers are returned in discovery order
|
|
160
|
+
* (alphabetical), so output ordering is stable run to run.
|
|
161
|
+
*
|
|
162
|
+
* Only Workers that carry a `pithy.config.ts` are returned: a non-Worker dev process (a Vite frontend joining
|
|
163
|
+
* the dev set via `pithy.worker.jsonc` alone) has no capabilities to migrate, seed, or reconcile. A command
|
|
164
|
+
* that acts on the project as a whole rather than per Worker wants {@link resolveWorkersReporting}, which
|
|
165
|
+
* also names the Workers this one drops.
|
|
166
|
+
*/
|
|
167
|
+
export async function resolveWorkers(options: ResolveOptions & { worker?: string }): Promise<ResolvedWorker[]> {
|
|
168
|
+
const { workers, skipped } = await resolveWorkersReporting(options);
|
|
169
|
+
if (workers.length === 0) everyWorkerUnreadable(skipped);
|
|
170
|
+
return workers;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Why a set could not be determined — the third state, **carrying its own diagnosis**.
|
|
175
|
+
*
|
|
176
|
+
* A bare `null` was the first shape of this and it repeated `#454`'s own mistake one level down: every
|
|
177
|
+
* refusal downstream had to invent a sentence, and each invented the same wrong one — *this project's
|
|
178
|
+
* Worker configuration will not load* — for a config that is **absent**, pointing the reader at a file
|
|
179
|
+
* that does not exist and never naming which Worker. {@link resolveWorkersReporting} knows both facts, so
|
|
180
|
+
* the reason travels with the refusal instead of being recomputed badly by whoever catches it.
|
|
181
|
+
*/
|
|
182
|
+
export interface UnknownSet {
|
|
183
|
+
/** One actionable sentence naming the Workers that could not be read. Reaches a terminal, never `detail`. */
|
|
184
|
+
readonly unknown: string;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Every Worker in the project, or why that set cannot be known. */
|
|
188
|
+
export type WorkerSet = ResolvedWorker[] | UnknownSet;
|
|
189
|
+
|
|
190
|
+
/** Every capability composed anywhere in the project, or why that set cannot be known. */
|
|
191
|
+
export type CapabilitySet = readonly Capability[] | UnknownSet;
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Narrow either set: `true` when the answer is unknowable. An array — **including an empty one** — is a
|
|
195
|
+
* real answer, and that is the whole distinction: a project with no Workers composes nothing, and one
|
|
196
|
+
* whose config will not load composes something nobody here can name.
|
|
197
|
+
*/
|
|
198
|
+
export function isUnknown<T>(set: readonly T[] | UnknownSet): set is UnknownSet {
|
|
199
|
+
return !Array.isArray(set);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** The diagnosis for a set left incomplete by Workers whose `pithy.config.ts` is absent. */
|
|
203
|
+
function missingConfigs(skipped: readonly SkippedWorker[]): UnknownSet {
|
|
204
|
+
const names = skipped.map((worker) => worker.name).join(", ");
|
|
205
|
+
return { unknown: `No pithy.config.ts in ${names} — every worker under apps/ needs one.` };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Every Worker resolved, or why the set is **unknowable from here** — the third state (#455).
|
|
210
|
+
*
|
|
211
|
+
* Unknowable is not "none". A project with no Workers at all answers `[]`, because nothing was ever named
|
|
212
|
+
* and there is nothing to reason about. {@link UnknownSet} means a `pithy.config.ts` threw, or a Worker was
|
|
213
|
+
* skipped and the set that came back is incomplete — and a caller deriving policy from it (which
|
|
214
|
+
* credentials CI gets, whether this project audits, which resources teardown must delete) has to refuse
|
|
215
|
+
* rather than infer absence. Folding the two together is what let `pithy deploy` ship unaudited and exit 0.
|
|
216
|
+
*
|
|
217
|
+
* **Deliberately no `worker`.** The distinction only means something across the project: narrowed to one
|
|
218
|
+
* Worker, `core/not_found` stops being "this project has no Workers" and becomes "*this* Worker has no
|
|
219
|
+
* config", which the catch below would answer `[]` — the exact conflation this function exists to undo.
|
|
220
|
+
* A caller wanting one Worker wants {@link resolveWorkers}, whose failure is theirs to see.
|
|
221
|
+
*/
|
|
222
|
+
export async function resolveWorkerSet(options: ResolveOptions): Promise<WorkerSet> {
|
|
223
|
+
try {
|
|
224
|
+
const { workers, skipped } = await resolveWorkersReporting(options);
|
|
225
|
+
// Incomplete is unknowable: a Worker nobody could read is a Worker whose capabilities are unaccounted
|
|
226
|
+
// for, and every caller of this function derives policy from the set being whole.
|
|
227
|
+
return skipped.length > 0 ? missingConfigs(skipped) : workers;
|
|
228
|
+
} catch (error) {
|
|
229
|
+
/*
|
|
230
|
+
**A project with no Workers is `[]`, not unknowable.** `resolveWorkers` throws `core/not_found` for
|
|
231
|
+
exactly that — an empty `apps/`, or one holding only dev-only processes with no `pithy.config.ts` —
|
|
232
|
+
and nothing was ever named, so a reconcile pass has nothing to recompute and a manifest pass still
|
|
233
|
+
runs. Swallowed into `null`, `feature destroy` refused with a diagnosis that was not true: *this
|
|
234
|
+
project's Worker configuration will not load*, pointing at a file that does not exist, and a CI
|
|
235
|
+
teardown failed on it (#454).
|
|
236
|
+
|
|
237
|
+
`core/not_found` also covers "no `pithy.config.ts` here", which is not this — but a caller wanting the
|
|
238
|
+
third state loads the *root* config first and throws its own error before reaching here. The
|
|
239
|
+
every-Worker-unreadable case never arrives as a throw at all: {@link resolveWorkersReporting} reports
|
|
240
|
+
it, and the branch above has already read it as `null`.
|
|
241
|
+
*/
|
|
242
|
+
if (error instanceof PithyError && error.payload.code === "core/not_found") return [];
|
|
243
|
+
return { unknown: messageOf(error) };
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Find a Worker by the name `pithy worker list` shows, or by its `apps/<dir>` basename. */
|
|
248
|
+
function pick(targets: WorkerTarget[], name: string): WorkerTarget {
|
|
249
|
+
const found = targets.find((target) => target.name === name || target.dir.endsWith(`/${name}`));
|
|
250
|
+
if (!found) {
|
|
251
|
+
throw new NotFoundError({
|
|
252
|
+
message: `No worker named "${name}".`,
|
|
253
|
+
action: `Run pithy worker list to see this project's workers. Known: ${targets.map((t) => t.name).join(", ")}.`,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
return found;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Fold a later instance of an already-seen capability into the kept one: same entry, plus any binding the
|
|
261
|
+
* later instance declares that the kept one does not. Returns the kept instance untouched when it declares
|
|
262
|
+
* everything (the common case — two Workers composing the same capability the same way), so nothing is
|
|
263
|
+
* copied unless a binding would otherwise be dropped. Bindings are matched by **name**, because one binding
|
|
264
|
+
* name is exactly one provisioned resource.
|
|
265
|
+
*/
|
|
266
|
+
function mergeBindings(kept: Capability, later: Capability): Capability {
|
|
267
|
+
const declared = new Set(kept.requiredBindings.map((binding) => binding.name));
|
|
268
|
+
const extra = later.requiredBindings.filter((binding) => !declared.has(binding.name));
|
|
269
|
+
return extra.length === 0 ? kept : { ...kept, requiredBindings: [...kept.requiredBindings, ...extra] };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Every capability composed anywhere in the project, one entry per name, in worker-discovery order.
|
|
274
|
+
*
|
|
275
|
+
* Most commands fan out per Worker — that is the point of the layout. A few genuinely need the project's
|
|
276
|
+
* whole capability surface as one list: `pithy feature` provisions **one** resource per binding name for the
|
|
277
|
+
* whole feature (two Workers that both declare `DB` deliberately share a database), and the local
|
|
278
|
+
* migrate/seed it runs must cover every table any Worker owns.
|
|
279
|
+
*
|
|
280
|
+
* One entry per capability **name**, because a capability composed by two Workers ships one migration
|
|
281
|
+
* namespace and one set of tables — running it twice would double-apply the same registry. But two Workers
|
|
282
|
+
* may compose the same capability with **different config**, and config changes what it binds
|
|
283
|
+
* (`media({ recordStore: "kv" })` adds a KV namespace; `audit({ database })` renames the D1 binding).
|
|
284
|
+
* Keeping only the first instance would drop the second's bindings from the union `pithy provision`
|
|
285
|
+
* creates resources from, leaving that Worker deployed with a binding nothing backs. So the first instance
|
|
286
|
+
* wins and every later instance's *additional* bindings are folded into it: one namespace, no lost binding.
|
|
287
|
+
*/
|
|
288
|
+
export function projectCapabilities(workers: readonly ResolvedWorker[]): Capability[] {
|
|
289
|
+
const byName = new Map<string, Capability>();
|
|
290
|
+
for (const worker of workers) {
|
|
291
|
+
for (const capability of worker.capabilities) {
|
|
292
|
+
const kept = byName.get(capability.name);
|
|
293
|
+
byName.set(capability.name, kept ? mergeBindings(kept, capability) : capability);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return [...byName.values()];
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* The same union, or why it is **unknowable from here** — the third state (#455, #454).
|
|
301
|
+
*
|
|
302
|
+
* Unknowable is not "none", and every caller has to tell the two apart. An empty array would let `feature
|
|
303
|
+
* destroy` report a clean remote teardown having deleted nothing, `pithy deploy` ship unaudited from a
|
|
304
|
+
* project that has audit composed, and `pithy token rotate` mint a replacement carrying only the base
|
|
305
|
+
* permissions before deleting the fully-permissioned token it replaces. See {@link resolveWorkerSet} for
|
|
306
|
+
* what separates `[]` from an {@link UnknownSet}.
|
|
307
|
+
*/
|
|
308
|
+
export async function projectCapabilitySet(
|
|
309
|
+
projectDir: string,
|
|
310
|
+
seams: Omit<ResolveOptions, "projectDir"> = {},
|
|
311
|
+
): Promise<CapabilitySet> {
|
|
312
|
+
return capabilitySetOf(await resolveWorkerSet({ projectDir, ...seams }));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* {@link projectCapabilities} over a set that may be unknowable — the pure half of
|
|
317
|
+
* {@link projectCapabilitySet}, for a caller that already resolved the Workers and would otherwise
|
|
318
|
+
* resolve them a second time.
|
|
319
|
+
*/
|
|
320
|
+
export function capabilitySetOf(workers: WorkerSet): CapabilitySet {
|
|
321
|
+
return isUnknown(workers) ? workers : projectCapabilities(workers);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* The same union, **assembled** — every capability's `compose` hook run over it, the way a Worker runs
|
|
326
|
+
* them at startup.
|
|
327
|
+
*
|
|
328
|
+
* The pairing is deliberate. {@link projectCapabilities} is a pure fold and stays one, because most of
|
|
329
|
+
* its callers want the binding surface and nothing else. A caller that goes on to *read* a value off a
|
|
330
|
+
* capability — `hostCatalogs()`, `layersFor`, `composedMessages` — wants this one, because those are
|
|
331
|
+
* the values a hook fills and they are placeholders until it has. See {@link composeCapabilities} for
|
|
332
|
+
* what an uncomposed read produces, and why it is silent.
|
|
333
|
+
*/
|
|
334
|
+
export function composedProjectCapabilities(workers: readonly ResolvedWorker[]): Capability[] {
|
|
335
|
+
return composeCapabilities(projectCapabilities(workers));
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Options for {@link resolveSingleWorker}. */
|
|
339
|
+
export interface ResolveSingleOptions extends ResolveOptions {
|
|
340
|
+
/** The `--worker` value, when the caller passed one. */
|
|
341
|
+
worker?: string;
|
|
342
|
+
/**
|
|
343
|
+
* Prompt for the Worker when several exist and none was named. Supplied only by an interactive command
|
|
344
|
+
* (a TTY, not `--json`); omitted, ambiguity is an actionable error instead.
|
|
345
|
+
*/
|
|
346
|
+
prompt?: (choices: ResolvedWorker[]) => Promise<string>;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* The single Worker a wiring command acts on — `pithy add`/`remove`, which write one Worker's
|
|
351
|
+
* `pithy.config.ts` and `wrangler.jsonc`.
|
|
352
|
+
*
|
|
353
|
+
* `--worker` names it outright. With none named: a project holding exactly one Worker uses it (the common
|
|
354
|
+
* case — no ceremony for a single-Worker project); a project holding several **never guesses**, because
|
|
355
|
+
* wiring a capability into the wrong Worker silently puts bindings and Durable Object class migrations on the
|
|
356
|
+
* wrong script. It prompts when a human is attached, and fails with an actionable error otherwise, so an
|
|
357
|
+
* agent driving `--json` gets told exactly what to pass rather than a surprise.
|
|
358
|
+
*/
|
|
359
|
+
export async function resolveSingleWorker(options: ResolveSingleOptions): Promise<ResolvedWorker> {
|
|
360
|
+
const seams = {
|
|
361
|
+
...(options.discoverWorkers ? { discoverWorkers: options.discoverWorkers } : {}),
|
|
362
|
+
...(options.loadConfig ? { loadConfig: options.loadConfig } : {}),
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
// A named Worker narrows **before** the load (#455). Resolving the whole set first meant one unloadable
|
|
366
|
+
// config disabled `pithy add` and `pithy remove` for every Worker in the project — including when editing
|
|
367
|
+
// a healthy Worker was the way around the broken one. `resolveWorkers` already supports the narrowing,
|
|
368
|
+
// and its own "No worker named" error builds the `Known:` list from discovery, which needs no load.
|
|
369
|
+
if (options.worker !== undefined) {
|
|
370
|
+
const [found] = await resolveWorkers({ projectDir: options.projectDir, worker: options.worker, ...seams });
|
|
371
|
+
// Unreachable: the single-Worker path either resolves one or throws. Narrowed for the type, not the case.
|
|
372
|
+
if (!found) throw new NotFoundError({ message: `No worker named "${options.worker}".` });
|
|
373
|
+
return found;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// With none named, the *set* is the question — how many Workers there are decides between using the only
|
|
377
|
+
// one, prompting, and refusing — so every config is loaded, and one that will not load is still fatal.
|
|
378
|
+
// Guessing past it would wire bindings and Durable Object class migrations onto the wrong script.
|
|
379
|
+
const workers = await resolveWorkers({ projectDir: options.projectDir, ...seams });
|
|
380
|
+
|
|
381
|
+
const only = workers[0];
|
|
382
|
+
if (workers.length === 1 && only) return only;
|
|
383
|
+
|
|
384
|
+
if (options.prompt) {
|
|
385
|
+
const chosen = await options.prompt(workers);
|
|
386
|
+
const found = workers.find((candidate) => candidate.name === chosen);
|
|
387
|
+
if (found) return found;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
throw new ValidationError({
|
|
391
|
+
message: "This project has several workers, so which one to wire is ambiguous.",
|
|
392
|
+
action: `Pass --worker <name>. Known: ${workers.map((w) => w.name).join(", ")}.`,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { parse } from "comment-json";
|
|
7
|
+
import { defaultWorkerDev, parseWorkerManifest, WORKER_MANIFEST_FILE, type WorkerDev } from "./workerManifest";
|
|
8
|
+
|
|
9
|
+
/** A discovered Worker: its deploy name, its directory, its dev-set settings, and whether it is a real Worker. */
|
|
10
|
+
export interface WorkerTarget {
|
|
11
|
+
/** The Worker's name — its `wrangler.jsonc` `name`, or the directory when that is absent. */
|
|
12
|
+
name: string;
|
|
13
|
+
/** The directory holding the Worker's `wrangler.jsonc`/`pithy.worker.jsonc` — the `cwd` for wrangler. */
|
|
14
|
+
dir: string;
|
|
15
|
+
/**
|
|
16
|
+
* How `pithy dev` runs this worker locally, from `pithy.worker.jsonc` (or a synthesized default). Real
|
|
17
|
+
* discovery always sets it; it is optional so the many test doubles that only need `name`/`dir` stay valid.
|
|
18
|
+
*/
|
|
19
|
+
dev?: WorkerDev;
|
|
20
|
+
/** Whether the directory holds a `wrangler.jsonc`. `false` → a non-Worker process (deploy skips it). */
|
|
21
|
+
hasWrangler?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Just the `name` field of a `wrangler.jsonc` — the Worker's deployed name. */
|
|
25
|
+
interface NamedWrangler {
|
|
26
|
+
name?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** True if `dir` holds a file named `file`. */
|
|
30
|
+
async function hasFile(dir: string, file: string): Promise<boolean> {
|
|
31
|
+
try {
|
|
32
|
+
return (await stat(join(dir, file))).isFile();
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The Worker's name from its `wrangler.jsonc`, falling back to `fallback` if unreadable or unnamed. */
|
|
39
|
+
async function workerName(dir: string, fallback: string): Promise<string> {
|
|
40
|
+
try {
|
|
41
|
+
const config = parse(await readFile(join(dir, "wrangler.jsonc"), "utf8")) as unknown as NamedWrangler;
|
|
42
|
+
return config.name ?? fallback;
|
|
43
|
+
} catch {
|
|
44
|
+
return fallback;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Build a {@link WorkerTarget} for `dir`, reading its manifest (or synthesizing a default dev block). */
|
|
49
|
+
async function toTarget(dir: string, fallbackName: string, hasWrangler: boolean): Promise<WorkerTarget> {
|
|
50
|
+
const manifest = await parseWorkerManifest(dir);
|
|
51
|
+
return {
|
|
52
|
+
name: hasWrangler ? await workerName(dir, fallbackName) : fallbackName,
|
|
53
|
+
dir,
|
|
54
|
+
dev: manifest?.dev ?? defaultWorkerDev(),
|
|
55
|
+
hasWrangler,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The project's Workers. `apps/` **is** the registry (`docs/CLI.md` §6): every `apps/<name>/` carrying a
|
|
61
|
+
* `pithy.worker.jsonc` **or** a `wrangler.jsonc` is one Worker. Discovery keys on `pithy.worker.jsonc` so a
|
|
62
|
+
* non-Worker process (a Vite frontend with no `wrangler.jsonc`) can still join the dev set; a `wrangler.jsonc`
|
|
63
|
+
* with no manifest is still discovered with a synthesized autostart dev block.
|
|
64
|
+
*
|
|
65
|
+
* **There is no root Worker.** Every Worker lives in `apps/<name>/` with its own `wrangler.jsonc` and its own
|
|
66
|
+
* `pithy.config.ts`, so capabilities, bindings, and Durable Object class migrations attach to the Worker that
|
|
67
|
+
* actually owns them. `dev`, `deploy`, `migrate`, `seed`, `upgrade`, and `doctor` all discover the set this
|
|
68
|
+
* way — no hand-kept list, and nothing special-cases the project root.
|
|
69
|
+
*/
|
|
70
|
+
export async function discoverWorkers(projectDir: string): Promise<WorkerTarget[]> {
|
|
71
|
+
let entries: string[] = [];
|
|
72
|
+
try {
|
|
73
|
+
entries = await readdir(join(projectDir, "apps"));
|
|
74
|
+
} catch {
|
|
75
|
+
entries = [];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const workers: WorkerTarget[] = [];
|
|
79
|
+
for (const entry of entries) {
|
|
80
|
+
const dir = join(projectDir, "apps", entry);
|
|
81
|
+
const hasManifest = await hasFile(dir, WORKER_MANIFEST_FILE);
|
|
82
|
+
const hasWrangler = await hasFile(dir, "wrangler.jsonc");
|
|
83
|
+
if (hasManifest || hasWrangler) workers.push(await toTarget(dir, entry, hasWrangler));
|
|
84
|
+
}
|
|
85
|
+
return workers.sort((a, b) => a.name.localeCompare(b.name));
|
|
86
|
+
}
|