@pithy-sh/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/package.json +72 -0
- package/scripts/templateManifest.ts +49 -0
- package/scripts/tsconfig.json +26 -0
- package/scripts/vendorTemplate.ts +84 -0
- package/scripts/verifyPack.ts +88 -0
- package/src/audit/cliAudit.ts +406 -0
- package/src/bin.ts +111 -0
- package/src/capabilities/add.ts +288 -0
- package/src/capabilities/addBootstrap.ts +275 -0
- package/src/capabilities/catalog.ts +175 -0
- package/src/capabilities/compose.ts +39 -0
- package/src/capabilities/configConstants.ts +74 -0
- package/src/capabilities/configImports.ts +397 -0
- package/src/capabilities/eject.ts +331 -0
- package/src/capabilities/emailProvisioner.ts +346 -0
- package/src/capabilities/entitlementGap.ts +70 -0
- package/src/capabilities/entryExports.ts +162 -0
- package/src/capabilities/flow.ts +550 -0
- package/src/capabilities/hostRegistry.ts +368 -0
- package/src/capabilities/loadFailure.ts +208 -0
- package/src/capabilities/manifests.ts +238 -0
- package/src/capabilities/mediaProvisioner.ts +471 -0
- package/src/capabilities/mintSecrets.ts +306 -0
- package/src/capabilities/paymentsProvisioner.ts +207 -0
- package/src/capabilities/prerequisites.ts +168 -0
- package/src/capabilities/r2Bucket.ts +113 -0
- package/src/capabilities/reconcile.ts +1483 -0
- package/src/capabilities/remove.ts +597 -0
- package/src/capabilities/requiredOptions.ts +92 -0
- package/src/capabilities/rotateSecrets.ts +305 -0
- package/src/capabilities/secrets.ts +178 -0
- package/src/capabilities/secretsDispatcher.ts +29 -0
- package/src/capabilities/secretsProvisioner.ts +389 -0
- package/src/capabilities/storageProvisioner.ts +414 -0
- package/src/capabilities/supportProvisioner.ts +515 -0
- package/src/capabilities/testersLoader.ts +52 -0
- package/src/capabilities/testersProvisioner.ts +236 -0
- package/src/capabilities/turnstileProvisioner.ts +347 -0
- package/src/capabilities/vectorProvisioner.ts +260 -0
- package/src/ci/fileModes.ts +223 -0
- package/src/ci/sourceFiles.ts +200 -0
- package/src/ci/workflowDrivers.ts +524 -0
- package/src/cloudflare/accountAnswer.ts +110 -0
- package/src/cloudflare/config.ts +685 -0
- package/src/cloudflare/storeId.ts +129 -0
- package/src/commands/add.ts +372 -0
- package/src/commands/alias.ts +205 -0
- package/src/commands/dashboard.ts +651 -0
- package/src/commands/deploy.ts +150 -0
- package/src/commands/dev.ts +37 -0
- package/src/commands/doctor.ts +2059 -0
- package/src/commands/email.ts +425 -0
- package/src/commands/env.ts +155 -0
- package/src/commands/feature.ts +359 -0
- package/src/commands/init.ts +538 -0
- package/src/commands/media.ts +303 -0
- package/src/commands/migrate.ts +129 -0
- package/src/commands/payments.ts +336 -0
- package/src/commands/provision.ts +368 -0
- package/src/commands/remove.ts +151 -0
- package/src/commands/secrets.ts +652 -0
- package/src/commands/seed.ts +229 -0
- package/src/commands/storage.ts +309 -0
- package/src/commands/support.ts +331 -0
- package/src/commands/testers.ts +1020 -0
- package/src/commands/token.ts +364 -0
- package/src/commands/turnstile.ts +271 -0
- package/src/commands/ui.ts +222 -0
- package/src/commands/upgrade.ts +517 -0
- package/src/commands/vector.ts +390 -0
- package/src/commands/worker.ts +295 -0
- package/src/dashboard/api.ts +323 -0
- package/src/dashboard/connect.ts +758 -0
- package/src/dashboard/contract.ts +289 -0
- package/src/dashboard/grant.ts +124 -0
- package/src/dashboard/registry.ts +519 -0
- package/src/dashboard/resolveTarget.ts +119 -0
- package/src/dev/delivery.ts +174 -0
- package/src/dev/devLogin.ts +155 -0
- package/src/dev/devLoginTargets.ts +91 -0
- package/src/dev/env.ts +206 -0
- package/src/dev/hostWorkers.ts +290 -0
- package/src/dev/keys.ts +111 -0
- package/src/dev/logging.ts +87 -0
- package/src/dev/openUrl.ts +75 -0
- package/src/dev/orchestrator.ts +1014 -0
- package/src/dev/ports.ts +220 -0
- package/src/dev/readyWatch.ts +142 -0
- package/src/dev/state.ts +90 -0
- package/src/devSecrets/bootstrapVars.ts +265 -0
- package/src/devSecrets/devVars.ts +240 -0
- package/src/devSecrets/edit.ts +256 -0
- package/src/devSecrets/file.ts +277 -0
- package/src/devSecrets/generate.ts +428 -0
- package/src/devSecrets/location.ts +80 -0
- package/src/devSecrets/mode.ts +71 -0
- package/src/devSecrets/records.ts +30 -0
- package/src/devSecrets/report.ts +99 -0
- package/src/devSecrets/seed.ts +344 -0
- package/src/devSecrets/store.ts +262 -0
- package/src/devSecrets/targets.ts +204 -0
- package/src/dispatch.ts +147 -0
- package/src/docs/catalog.ts +246 -0
- package/src/docs/writeCatalog.ts +45 -0
- package/src/doctor/cloudflare.ts +287 -0
- package/src/doctor/devPreferences.ts +155 -0
- package/src/doctor/devSecrets.ts +464 -0
- package/src/doctor/devVars.ts +414 -0
- package/src/doctor/devVarsLocal.ts +138 -0
- package/src/doctor/environments.ts +155 -0
- package/src/doctor/health.ts +354 -0
- package/src/doctor/localDelivery.ts +91 -0
- package/src/doctor/portsRegistry.ts +252 -0
- package/src/doctor/projectName.ts +584 -0
- package/src/doctor/secretBindings.ts +166 -0
- package/src/doctor/settings.ts +274 -0
- package/src/doctor/settingsSources.ts +202 -0
- package/src/doctor/workerName.ts +174 -0
- package/src/doctor/wranglerVars.ts +33 -0
- package/src/feature/bindings.ts +93 -0
- package/src/feature/create.ts +179 -0
- package/src/feature/destroy.ts +160 -0
- package/src/feature/devConfig.ts +201 -0
- package/src/feature/identity.ts +100 -0
- package/src/feature/manifest.ts +132 -0
- package/src/feature/ports.ts +615 -0
- package/src/feature/provision.ts +362 -0
- package/src/feature/sync.ts +148 -0
- package/src/feature/worktree.ts +282 -0
- package/src/help/groups.ts +47 -0
- package/src/help/rootUsage.ts +135 -0
- package/src/main.ts +73 -0
- package/src/migrations/ledger.ts +129 -0
- package/src/migrations/registry.ts +47 -0
- package/src/migrations/run.ts +1066 -0
- package/src/notifier/check.ts +129 -0
- package/src/notifier/installer.ts +48 -0
- package/src/notifier/notify.ts +152 -0
- package/src/notifier/state.ts +248 -0
- package/src/notifier/version.ts +59 -0
- package/src/platform/editor.ts +333 -0
- package/src/platform/rc.ts +118 -0
- package/src/platform/shell.ts +83 -0
- package/src/project/appBindings.ts +184 -0
- package/src/project/appWorkflows.ts +266 -0
- package/src/project/applyDomains.ts +166 -0
- package/src/project/askDomains.ts +220 -0
- package/src/project/atomic.ts +466 -0
- package/src/project/bindingEntries.ts +425 -0
- package/src/project/config.ts +701 -0
- package/src/project/dashboard.ts +118 -0
- package/src/project/deploy.ts +364 -0
- package/src/project/devVars.ts +113 -0
- package/src/project/domainPrompt.ts +191 -0
- package/src/project/domains.ts +386 -0
- package/src/project/envInventory.ts +356 -0
- package/src/project/environment.ts +125 -0
- package/src/project/extensions.ts +69 -0
- package/src/project/jsonc.ts +289 -0
- package/src/project/packageManager.ts +238 -0
- package/src/project/readOptionalFile.ts +342 -0
- package/src/project/rollback.ts +145 -0
- package/src/project/scaffold.ts +1088 -0
- package/src/project/templateFiles.ts +53 -0
- package/src/project/verifyDeploy.ts +230 -0
- package/src/project/versionMetadata.ts +77 -0
- package/src/project/workerAddress.ts +176 -0
- package/src/project/workerCommand.ts +564 -0
- package/src/project/workerIdentity.ts +50 -0
- package/src/project/workerManifest.ts +135 -0
- package/src/project/workerScaffold.ts +289 -0
- package/src/project/workerScope.ts +394 -0
- package/src/project/workers.ts +86 -0
- package/src/project/workflows.ts +281 -0
- package/src/project/wrangler.ts +168 -0
- package/src/provision/confirm.ts +86 -0
- package/src/provision/environment.ts +407 -0
- package/src/provision/featureConfig.ts +98 -0
- package/src/provision/mode.ts +62 -0
- package/src/provision/pendingSecrets.ts +96 -0
- package/src/provision/resources.ts +126 -0
- package/src/provision/secretBindings.ts +149 -0
- package/src/provision/store.ts +33 -0
- package/src/provision/unprovisioned.ts +114 -0
- package/src/provision/wranglerEnv.ts +220 -0
- package/src/rootFlags.ts +48 -0
- package/src/seed/drivers.ts +423 -0
- package/src/seed/media.ts +187 -0
- package/src/seed/plan.ts +137 -0
- package/src/seed/prepare.ts +224 -0
- package/src/seed/registry.ts +25 -0
- package/src/seed/run.ts +793 -0
- package/src/seed/safety.ts +206 -0
- package/src/terminal/logger.ts +42 -0
- package/src/terminal/output.ts +64 -0
- package/src/terminal/style.ts +132 -0
- package/src/test-utils/doctorHarness.ts +190 -0
- package/src/test-utils/migrateHarness.ts +126 -0
- package/src/test-utils/seedHarness.ts +173 -0
- package/src/test-utils/tempRepo.ts +45 -0
- package/src/tokens/config.ts +16 -0
- package/src/tokens/engine.ts +345 -0
- package/src/tokens/mintedTokens.ts +233 -0
- package/src/tokens/sinks.ts +84 -0
- package/src/ui/flow.ts +451 -0
- package/src/ui/react.ts +112 -0
- package/src/ui/routeAllowlist.ts +208 -0
- package/src/ui/scaffold.ts +113 -0
- package/src/ui/screenStyles.ts +127 -0
- package/src/ui/stubs.ts +135 -0
- package/src/ui/templates.ts +52 -0
- package/src/ui/wire.ts +311 -0
- package/src/ui/workerUi.ts +172 -0
- package/templates/starter/.dev.secrets.example.jsonc +43 -0
- package/templates/starter/.dev.vars.example +30 -0
- package/templates/starter/apps/api/package.json +22 -0
- package/templates/starter/apps/api/pithy.config.ts +65 -0
- package/templates/starter/apps/api/pithy.worker.jsonc +11 -0
- package/templates/starter/apps/api/src/bindings.workers.test.ts +18 -0
- package/templates/starter/apps/api/src/cloudflare-test.d.ts +11 -0
- package/templates/starter/apps/api/src/index.ts +8 -0
- package/templates/starter/apps/api/tsconfig.json +26 -0
- package/templates/starter/apps/api/wrangler.jsonc +68 -0
- package/templates/starter/biome.template.jsonc +75 -0
- package/templates/starter/gitignore +37 -0
- package/templates/starter/package.json +28 -0
- package/templates/starter/pithy.config.ts +67 -0
- package/templates/starter/plugins/no-console.grit +25 -0
- package/templates/starter/plugins/no-process-io.grit +25 -0
- package/templates/starter/tsconfig.json +14 -0
- package/templates/starter/tsconfig.tools.json +30 -0
- package/templates/starter/vitest.config.ts +124 -0
- package/templates/starter/vitest.workers.config.ts +26 -0
package/src/ui/wire.ts
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { InternalError, NotFoundError } from "@pithy-sh/core/src/error/pithyError";
|
|
7
|
+
import { parse } from "comment-json";
|
|
8
|
+
import { writeFileAtomic } from "../project/atomic";
|
|
9
|
+
import { writeJsonc } from "../project/jsonc";
|
|
10
|
+
import { alreadyProvided, execArgs, type PackageManager } from "../project/packageManager";
|
|
11
|
+
import { readOptionalFile, requireRecord } from "../project/readOptionalFile";
|
|
12
|
+
import { DEV_PORT_TOKEN } from "../project/workerManifest";
|
|
13
|
+
import { readWranglerConfig, writeWranglerConfig } from "../project/wrangler";
|
|
14
|
+
import type { UiStub } from "./stubs";
|
|
15
|
+
import { readManifestDocument, writeManifestDocument } from "./workerUi";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The four files `pithy ui` edits rather than creates: the worker's `wrangler.jsonc` (how the built
|
|
19
|
+
* assets are served), its `pithy.worker.jsonc` (how `pithy dev` runs it and `pithy deploy` builds it),
|
|
20
|
+
* its `package.json` (the stub's packages and scripts), and the project's root `tsconfig.json` (which
|
|
21
|
+
* programs `bun run typecheck` builds).
|
|
22
|
+
*
|
|
23
|
+
* All four are edited in place and comment-preserving. comment-json keeps an adopter's notes as
|
|
24
|
+
* symbol-keyed properties on the very object or array they hang off, so a replaced array is a
|
|
25
|
+
* deleted comment — every update below mutates what is already there.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** The `assets` slice of a worker's `wrangler.jsonc`. */
|
|
29
|
+
interface AssetsStanza {
|
|
30
|
+
not_found_handling?: string;
|
|
31
|
+
run_worker_first?: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** What {@link wireAssets} changed — the before/after `pithy ui sync` reports. */
|
|
35
|
+
export interface AssetsChange {
|
|
36
|
+
/** The previous `run_worker_first`, or `null` when there was no `assets` stanza at all. */
|
|
37
|
+
before: string[] | null;
|
|
38
|
+
/** The derived allowlist now in the file. */
|
|
39
|
+
after: string[];
|
|
40
|
+
/** `not_found_handling` as it stands now — written when absent, left alone when the adopter set it. */
|
|
41
|
+
notFoundHandling: string | undefined;
|
|
42
|
+
/** True when this call wrote `not_found_handling`, so a report can say the file moved. */
|
|
43
|
+
wroteNotFoundHandling: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** SPA routing: a request matching no asset is answered with `index.html` rather than a 404. */
|
|
47
|
+
const SPA_NOT_FOUND = "single-page-application";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The `assets` stanza as the file currently holds it, without touching it — what `pithy ui sync
|
|
51
|
+
* --check` reads. A missing stanza, or a `run_worker_first` that is not an array, both come back as an
|
|
52
|
+
* empty list: neither is a list this Worker is relying on.
|
|
53
|
+
*
|
|
54
|
+
* Separate from {@link wireAssets} rather than a flag on it, because a check that can write is a check
|
|
55
|
+
* nobody can run twice for the same answer.
|
|
56
|
+
*/
|
|
57
|
+
export async function readAssets(workerDir: string): Promise<{ runWorkerFirst: string[]; notFoundHandling?: string }> {
|
|
58
|
+
const document = (await readWranglerConfig(workerDir)) as { assets?: AssetsStanza };
|
|
59
|
+
const assets = document.assets;
|
|
60
|
+
return {
|
|
61
|
+
runWorkerFirst: Array.isArray(assets?.run_worker_first) ? [...assets.run_worker_first] : [],
|
|
62
|
+
...(assets?.not_found_handling === undefined ? {} : { notFoundHandling: assets.not_found_handling }),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Write the `assets` stanza into a worker's `wrangler.jsonc`.
|
|
68
|
+
*
|
|
69
|
+
* `assets.directory` is deliberately **not** written. Under the Vite plugin it is supplied by the
|
|
70
|
+
* plugin, which overwrites whatever is there without complaining — so a `directory` in the adopter's
|
|
71
|
+
* config would be a line that looks authoritative and is not.
|
|
72
|
+
*
|
|
73
|
+
* `not_found_handling` is written only when **absent**. The allowlist is derived and must be
|
|
74
|
+
* re-derived on every sync; `not_found_handling` is a routing choice, and once the file has one it is
|
|
75
|
+
* the adopter's. Overwriting it on every `pithy ui sync` would silently undo a deliberate change —
|
|
76
|
+
* create-never-overwrite applied to a value rather than a file.
|
|
77
|
+
*
|
|
78
|
+
* Idempotent: re-running rewrites the same derived list. That is exactly what `pithy ui sync` is.
|
|
79
|
+
*
|
|
80
|
+
* **The allowlist arrives derived; this function does not compose anything.** It used to take the config
|
|
81
|
+
* and call {@link deriveWorkerFirst} itself, which put the step that can fail — assembling every
|
|
82
|
+
* capability's routes, in every environment — *inside* the writer, and therefore after `pithy ui add`
|
|
83
|
+
* had already written its whole template (#259). The derivation is the caller's now, so a composition
|
|
84
|
+
* that throws throws before a byte is written.
|
|
85
|
+
*/
|
|
86
|
+
export async function wireAssets(workerDir: string, patterns: readonly string[]): Promise<AssetsChange> {
|
|
87
|
+
const document = (await readWranglerConfig(workerDir)) as { assets?: AssetsStanza };
|
|
88
|
+
|
|
89
|
+
const existing = document.assets;
|
|
90
|
+
let before: string[] | null = null;
|
|
91
|
+
if (existing) before = Array.isArray(existing.run_worker_first) ? [...existing.run_worker_first] : [];
|
|
92
|
+
|
|
93
|
+
const assets: AssetsStanza = existing ?? {};
|
|
94
|
+
const wroteNotFoundHandling = assets.not_found_handling === undefined;
|
|
95
|
+
if (wroteNotFoundHandling) assets.not_found_handling = SPA_NOT_FOUND;
|
|
96
|
+
if (Array.isArray(assets.run_worker_first)) {
|
|
97
|
+
// In place: the array object carries the adopter's comments.
|
|
98
|
+
assets.run_worker_first.splice(0, assets.run_worker_first.length, ...patterns);
|
|
99
|
+
} else {
|
|
100
|
+
assets.run_worker_first = [...patterns];
|
|
101
|
+
}
|
|
102
|
+
document.assets = assets;
|
|
103
|
+
|
|
104
|
+
await writeWranglerConfig(workerDir, document);
|
|
105
|
+
return {
|
|
106
|
+
before,
|
|
107
|
+
after: [...patterns],
|
|
108
|
+
notFoundHandling: assets.not_found_handling,
|
|
109
|
+
wroteNotFoundHandling,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Write the `dev` and `ui` blocks into a worker's `pithy.worker.jsonc`.
|
|
115
|
+
*
|
|
116
|
+
* `dev.command` is a full argv — the manifest has no package-manager concept — so the stub's
|
|
117
|
+
* package-manager-agnostic argv is resolved here through the adopter's `execArgs`: `bun x vite dev …`
|
|
118
|
+
* for a Bun project, `npx vite dev …` for an npm one. `{port}` stays a placeholder; `pithy dev`
|
|
119
|
+
* substitutes the feature's pinned port at spawn time, because ports are assigned at feature
|
|
120
|
+
* creation and never probed at startup.
|
|
121
|
+
*
|
|
122
|
+
* `ui.build` stays package-manager-agnostic: `pithy deploy` runs it through the same `execArgs` at
|
|
123
|
+
* build time, where it also knows the environment.
|
|
124
|
+
*/
|
|
125
|
+
export async function wireManifest(workerDir: string, stub: UiStub, packageManager: PackageManager): Promise<void> {
|
|
126
|
+
const document = await readManifestDocument(workerDir);
|
|
127
|
+
|
|
128
|
+
const dev: Record<string, unknown> = (document.dev as Record<string, unknown> | undefined) ?? {};
|
|
129
|
+
const [bin, ...rest] = stub.devCommand(DEV_PORT_TOKEN);
|
|
130
|
+
if (!bin) {
|
|
131
|
+
throw new InternalError({
|
|
132
|
+
message: `The ${stub.id} stub declares an empty dev command.`,
|
|
133
|
+
action: "This is a Pithy bug. Report it at https://github.com/pithy-sh/pithy/issues.",
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
const { command, args } = execArgs(packageManager, bin, rest);
|
|
137
|
+
dev.autostart = true;
|
|
138
|
+
dev.readySignal = stub.readySignal;
|
|
139
|
+
dev.command = [command, ...args];
|
|
140
|
+
document.dev = dev;
|
|
141
|
+
|
|
142
|
+
const ui: Record<string, unknown> = (document.ui as Record<string, unknown> | undefined) ?? {};
|
|
143
|
+
ui.stub = stub.id;
|
|
144
|
+
ui.build = [...stub.buildCommand];
|
|
145
|
+
document.ui = ui;
|
|
146
|
+
|
|
147
|
+
await writeManifestDocument(workerDir, document);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The one script value `pithy ui add` replaces. Pithy wrote it into every scaffolded worker, and
|
|
152
|
+
* serving the worker through Vite supersedes it — leaving it would hand the adopter a `bun run dev`
|
|
153
|
+
* that starts the API without the UI. Any other value is the adopter's and is kept.
|
|
154
|
+
*/
|
|
155
|
+
const REPLACEABLE_DEV_SCRIPT = "wrangler dev";
|
|
156
|
+
|
|
157
|
+
/** A worker's `package.json`, in the shape this module merges into. */
|
|
158
|
+
interface WorkerPackage {
|
|
159
|
+
scripts?: Record<string, string>;
|
|
160
|
+
dependencies?: Record<string, string>;
|
|
161
|
+
devDependencies?: Record<string, string>;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Add every missing key from `incoming`; never touch one that is already there. */
|
|
165
|
+
function mergeMissing(target: Record<string, string>, incoming: Record<string, string>): string[] {
|
|
166
|
+
const added: string[] = [];
|
|
167
|
+
for (const [key, value] of Object.entries(incoming)) {
|
|
168
|
+
if (key in target) continue;
|
|
169
|
+
target[key] = value;
|
|
170
|
+
added.push(key);
|
|
171
|
+
}
|
|
172
|
+
return added;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Drop every package there is no range to write for, so nothing unresolvable lands in a `package.json`.
|
|
177
|
+
*
|
|
178
|
+
* Two ways that happens, and both end in the same 404 on the adopter's *next* install — long after
|
|
179
|
+
* `pithy ui add` said Done.
|
|
180
|
+
*
|
|
181
|
+
* **The project already provides it.** A `@pithy-sh/*` package linked in from a checkout resolves from
|
|
182
|
+
* the project root either way, so omitting the line costs nothing and the build still finds it. Same
|
|
183
|
+
* predicate `pithy add` skips its install on ({@link alreadyProvided}).
|
|
184
|
+
*
|
|
185
|
+
* **There is no such version yet.** A stub declares a `null` range for a package the registry cannot
|
|
186
|
+
* serve at all — every `@pithy-sh/*` one, until the scope publishes. That case is the checkout case
|
|
187
|
+
* generalized: `alreadyProvided` only ever caught the adopter who linked one in, and a plain project
|
|
188
|
+
* kept the literal and broke. `kitRange` decides it once, for every producer, and stops deciding it the
|
|
189
|
+
* day the version is real.
|
|
190
|
+
*/
|
|
191
|
+
async function withoutProvided(
|
|
192
|
+
projectDir: string,
|
|
193
|
+
packages: Record<string, string | null>,
|
|
194
|
+
): Promise<Record<string, string>> {
|
|
195
|
+
const kept: Record<string, string> = {};
|
|
196
|
+
for (const [name, range] of Object.entries(packages)) {
|
|
197
|
+
if (range === null) continue;
|
|
198
|
+
if (await alreadyProvided(projectDir, name)) continue;
|
|
199
|
+
kept[name] = range;
|
|
200
|
+
}
|
|
201
|
+
return kept;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** What {@link wirePackage} added, for the command's report. */
|
|
205
|
+
export interface PackageChange {
|
|
206
|
+
/** Dependency names newly added (an existing pin is never touched, so never downgraded). */
|
|
207
|
+
dependencies: string[];
|
|
208
|
+
/** Dev-dependency names newly added. */
|
|
209
|
+
devDependencies: string[];
|
|
210
|
+
/** Script names newly added or, for `dev`, superseded. */
|
|
211
|
+
scripts: string[];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Merge the stub's packages and scripts into `apps/<worker>/package.json`.
|
|
216
|
+
*
|
|
217
|
+
* **Merge, never replace.** Every existing key survives, which also means an existing pin is never
|
|
218
|
+
* downgraded: a dependency already declared keeps whatever version the adopter chose. The single
|
|
219
|
+
* exception is the `dev` script when it still holds Pithy's own scaffolded `wrangler dev` — see
|
|
220
|
+
* {@link REPLACEABLE_DEV_SCRIPT}.
|
|
221
|
+
*
|
|
222
|
+
* `projectDir` is the root, not the worker: it is where the packages resolve from, and a `@pithy-sh/*`
|
|
223
|
+
* one that already does gets no range written for it — see {@link withoutProvided}.
|
|
224
|
+
*/
|
|
225
|
+
export async function wirePackage(projectDir: string, workerDir: string, stub: UiStub): Promise<PackageChange> {
|
|
226
|
+
const path = join(workerDir, "package.json");
|
|
227
|
+
// One `try` used to wrap the read and the parse and answer both with *pithy worker add creates one*
|
|
228
|
+
// (#217). That command refuses on a worker that already exists, so a `package.json` holding a stray
|
|
229
|
+
// comma sent the adopter to a door that was already shut — and an `EACCES` sent them there too. Three
|
|
230
|
+
// failures, three answers: absence is the only one `pithy worker add` addresses, `readOptionalFile`
|
|
231
|
+
// owns the errno that is not absence, and the parse gets its own. `requireRecord` closes the fourth:
|
|
232
|
+
// `[]` parses, and the merge below would have hung `dependencies` off an array and written it back.
|
|
233
|
+
const raw = await readOptionalFile(path);
|
|
234
|
+
if (raw === null) {
|
|
235
|
+
throw new NotFoundError({
|
|
236
|
+
message: `No package.json in ${workerDir}.`,
|
|
237
|
+
action: "Every worker under apps/ needs a package.json. pithy worker add creates one.",
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
let parsed: unknown;
|
|
242
|
+
try {
|
|
243
|
+
parsed = JSON.parse(raw);
|
|
244
|
+
} catch (cause) {
|
|
245
|
+
throw new InternalError({
|
|
246
|
+
message: `${path} is not valid JSON.`,
|
|
247
|
+
action: "Fix that file's JSON, then run the command again.",
|
|
248
|
+
detail: cause instanceof Error ? cause.message : String(cause),
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
const pkg = requireRecord(path, parsed) as WorkerPackage;
|
|
252
|
+
|
|
253
|
+
pkg.dependencies ??= {};
|
|
254
|
+
pkg.devDependencies ??= {};
|
|
255
|
+
pkg.scripts ??= {};
|
|
256
|
+
|
|
257
|
+
const dependencies = mergeMissing(pkg.dependencies, await withoutProvided(projectDir, stub.dependencies));
|
|
258
|
+
const devDependencies = mergeMissing(pkg.devDependencies, await withoutProvided(projectDir, stub.devDependencies));
|
|
259
|
+
|
|
260
|
+
if (pkg.scripts.dev === REPLACEABLE_DEV_SCRIPT) delete pkg.scripts.dev;
|
|
261
|
+
const scripts = mergeMissing(pkg.scripts, stub.scripts);
|
|
262
|
+
|
|
263
|
+
await writeFileAtomic(path, `${JSON.stringify(pkg, null, 2)}\n`);
|
|
264
|
+
return { dependencies, devDependencies, scripts };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** The project's root `tsconfig.json`, in the shape this module extends. */
|
|
268
|
+
interface SolutionFile {
|
|
269
|
+
references?: { path: string }[];
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Add the client's two programs to the project's root `tsconfig.json`, so `bun run typecheck` builds them.
|
|
274
|
+
*
|
|
275
|
+
* The client cannot join the Worker's program and must not: the Worker needs `@cloudflare/workers-types`
|
|
276
|
+
* and the client needs the DOM, and a program holding both makes `Uint8Array` structurally incompatible
|
|
277
|
+
* with `BufferSource` — which breaks every crypto call in the kit's control-plane signing code. So a
|
|
278
|
+
* scaffolded front end is two more *programs*, and a program nothing references is a program nothing
|
|
279
|
+
* checks. `pithy ui add` used to leave exactly that: two tsconfigs, unreferenced, silently unchecked.
|
|
280
|
+
*
|
|
281
|
+
* Appended in file order — the Worker's program, then its client's — because `tsc -b` reports in that
|
|
282
|
+
* order and reading a failure top-down should walk the project the way its layout does.
|
|
283
|
+
*
|
|
284
|
+
* **Extends, never creates.** A project scaffolded before the root solution file existed has Workers whose
|
|
285
|
+
* programs are not `composite`, and `tsc -b` refuses a reference to one of those outright. Writing the file
|
|
286
|
+
* would hand that adopter a `typecheck` that cannot pass; leaving it alone costs them only what they
|
|
287
|
+
* already had. Returns the paths added — empty when there is no solution file, or when a re-run finds both
|
|
288
|
+
* already there.
|
|
289
|
+
*/
|
|
290
|
+
export async function wireSolution(projectDir: string, worker: string): Promise<string[]> {
|
|
291
|
+
const path = join(projectDir, "tsconfig.json");
|
|
292
|
+
let document: SolutionFile;
|
|
293
|
+
try {
|
|
294
|
+
document = parse(await readFile(path, "utf8")) as unknown as SolutionFile;
|
|
295
|
+
} catch {
|
|
296
|
+
return []; // no solution file — this project predates it, and inventing one would break its typecheck
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const references = Array.isArray(document.references) ? document.references : [];
|
|
300
|
+
const present = new Set(references.map((reference) => reference.path));
|
|
301
|
+
const added = [`./apps/${worker}/tsconfig.client.json`, `./apps/${worker}/tsconfig.node.json`].filter(
|
|
302
|
+
(reference) => !present.has(reference),
|
|
303
|
+
);
|
|
304
|
+
if (added.length === 0) return [];
|
|
305
|
+
|
|
306
|
+
// In place: the array object carries the adopter's comments, and comment-json hangs them off it.
|
|
307
|
+
references.push(...added.map((reference) => ({ path: reference })));
|
|
308
|
+
document.references = references;
|
|
309
|
+
await writeJsonc(path, document);
|
|
310
|
+
return added;
|
|
311
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { ConflictError } from "@pithy-sh/core/src/error/pithyError";
|
|
6
|
+
import { parse } from "comment-json";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { writeJsonc } from "../project/jsonc";
|
|
9
|
+
import { readMergeBase } from "../project/readOptionalFile";
|
|
10
|
+
import { WORKER_MANIFEST_FILE, type WorkerUi } from "../project/workerManifest";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The `ui` block of `pithy.worker.jsonc` — the framework-stub contract as persisted.
|
|
14
|
+
*
|
|
15
|
+
* ```jsonc
|
|
16
|
+
* "ui": { "stub": "react", "build": ["vite", "build"] }
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* `pithy deploy` reads `build` and runs it through the adopter's package manager before
|
|
20
|
+
* `wrangler deploy`; `pithy ui sync` reads `stub` to know which worker already has a front end. The
|
|
21
|
+
* block's presence **is** the "this worker has a UI" signal, which is why `pithy ui add` refuses on
|
|
22
|
+
* a worker that already carries one rather than half-overwriting a scaffold.
|
|
23
|
+
*
|
|
24
|
+
* Read here from the raw JSONC rather than through `parseWorkerManifest`: the Zod manifest strips
|
|
25
|
+
* what it does not declare, and this reader has to see the block whether or not the schema has caught
|
|
26
|
+
* up with it. Writes go back comment-preserving (comment-json), mutating in place — replacing an
|
|
27
|
+
* object or array would drop the adopter's notes, which live on it as symbol-keyed properties.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** A parsed `pithy.worker.jsonc`, loose — this module writes two known blocks and preserves the rest. */
|
|
31
|
+
type ManifestDocument = Record<string, unknown> & {
|
|
32
|
+
dev?: Record<string, unknown>;
|
|
33
|
+
ui?: Record<string, unknown>;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* What the type above claims, as a check rather than as a cast — the two blocks this module assigns
|
|
38
|
+
* into, and every other one of the adopter's kept untouched.
|
|
39
|
+
*
|
|
40
|
+
* `catchall` rather than a closed object for the same reason `dev.json`'s schema has one: a manifest holds
|
|
41
|
+
* bindings, routes, a name and whatever the schema has not caught up with, and a write here must round-trip
|
|
42
|
+
* all of it. What is declared is only what this module *dereferences* — `document.ui` is read as an object
|
|
43
|
+
* and `document.dev` is assigned as one.
|
|
44
|
+
*/
|
|
45
|
+
const ManifestBlocks = z
|
|
46
|
+
.object({
|
|
47
|
+
dev: z
|
|
48
|
+
.record(z.string().describe("A key of the dev block."), z.unknown().describe("Its value, whatever it is."))
|
|
49
|
+
.optional()
|
|
50
|
+
.describe("The `dev` block — `pithy dev`'s per-worker settings. An object when it is there at all."),
|
|
51
|
+
ui: z
|
|
52
|
+
.record(z.string().describe("A key of the ui block."), z.unknown().describe("Its value, whatever it is."))
|
|
53
|
+
.optional()
|
|
54
|
+
.describe("The `ui` block — the framework-stub contract. An object when it is there at all."),
|
|
55
|
+
})
|
|
56
|
+
.catchall(z.unknown().describe("Another block of the manifest, read and written back untouched."))
|
|
57
|
+
.describe("A `pithy.worker.jsonc` as this module reads it: two blocks it knows, and every other one kept.");
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The same document, **validated without being rebuilt** (#222).
|
|
61
|
+
*
|
|
62
|
+
* The check has to be a check and not a parse, because comment-json hangs the file's notes off the object
|
|
63
|
+
* it returns as symbol-keyed properties, and every Zod object schema constructs a *new* object from the
|
|
64
|
+
* keys it validated. Running {@link ManifestBlocks} as the merge base's schema directly would therefore
|
|
65
|
+
* pass every test about shape and silently drop every comment in the adopter's manifest at the next
|
|
66
|
+
* write — trading the cast this exists to remove for a quieter version of the same loss.
|
|
67
|
+
*
|
|
68
|
+
* So the schema validates by delegation and hands back the value it was given, issue paths and all, which
|
|
69
|
+
* is what lets a refusal name `ui` rather than "the top level".
|
|
70
|
+
*/
|
|
71
|
+
const ManifestDocument = z
|
|
72
|
+
.custom<ManifestDocument>()
|
|
73
|
+
.check((ctx) => {
|
|
74
|
+
const checked = ManifestBlocks.safeParse(ctx.value);
|
|
75
|
+
if (checked.success) return;
|
|
76
|
+
// The key path travels and the message travels; the value never does. `readMergeBase` reports where a
|
|
77
|
+
// document broke by key path alone, and this is the schema that has to keep that true.
|
|
78
|
+
for (const issue of checked.error.issues) {
|
|
79
|
+
ctx.issues.push({ code: "custom", path: issue.path, message: issue.message, input: ctx.value });
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
.describe("A `pithy.worker.jsonc` document, checked against its shape and handed back exactly as parsed.");
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Parse `<dir>/pithy.worker.jsonc` comment-preserving, or an empty document when the file is **absent**.
|
|
86
|
+
*
|
|
87
|
+
* Absent means `ENOENT` and nothing else — the decision is {@link readOptionalFile}'s, and the words are
|
|
88
|
+
* this file's. A read that answered `{}` for every failure was #142 with a different file name: the caller
|
|
89
|
+
* merges its two blocks into that empty base and {@link writeManifestDocument} renames the result over a
|
|
90
|
+
* file whose every other block the process never saw. Committed rather than gitignored, so the loss is
|
|
91
|
+
* recoverable from git — but only by someone who notices before committing over it.
|
|
92
|
+
*
|
|
93
|
+
* **Three ways in, and `{}` is the answer to exactly one of them.** The file is not there; the file is
|
|
94
|
+
* there and will not open; the file opened and is not a document. The third was the last path left from
|
|
95
|
+
* "the read succeeded" to "start from an empty base" (#204) — `typeof null === "object"` let `null`
|
|
96
|
+
* through, and an array passed the same check and then lost every key `stringify` drops off it. Such a
|
|
97
|
+
* file is malformed either way, which changes what the loss costs, not whether it is the same defect.
|
|
98
|
+
*
|
|
99
|
+
* The tag check that decides the third was written here and is `requireRecord`'s now (#209): the two
|
|
100
|
+
* credential files reached the same conclusion independently, which is the count at which a rule stops
|
|
101
|
+
* belonging to the call site.
|
|
102
|
+
*
|
|
103
|
+
* **And there was a fourth, which a cast made invisible (#222).** The read refused on all three above and
|
|
104
|
+
* then asserted the survivor was a {@link ManifestDocument} — a type claiming `dev` and `ui` are objects —
|
|
105
|
+
* so a manifest whose `ui` is the string `"react"` reached the merge as if it were valid. `readMergeBase`
|
|
106
|
+
* is the whole sentence and this was the one reader in the family not asking it, so it asks it now: the
|
|
107
|
+
* cast is a schema, the four refusals are its four, and the JSONC parser comes in through the seam that
|
|
108
|
+
* exists for it. The words below are still this file's — a `pithy.worker.jsonc` names a manifest object,
|
|
109
|
+
* not a document — and only the decisions moved.
|
|
110
|
+
*/
|
|
111
|
+
export async function readManifestDocument(workerDir: string): Promise<ManifestDocument> {
|
|
112
|
+
const path = join(workerDir, WORKER_MANIFEST_FILE);
|
|
113
|
+
const base = await readMergeBase(path, ManifestDocument, {
|
|
114
|
+
// Comment-preserving, because {@link writeManifestDocument} hands this straight back to the printer.
|
|
115
|
+
parse: (source) => parse(source),
|
|
116
|
+
unreadable: ({ code, cause }) =>
|
|
117
|
+
new ConflictError(
|
|
118
|
+
{
|
|
119
|
+
message: `Cannot update ${path}: Pithy could not read what is already in it.`,
|
|
120
|
+
action: "Fix the file's permissions, or move it aside, and run the command again.",
|
|
121
|
+
detail: `${code ?? "unknown error"} while reading ${path}`,
|
|
122
|
+
},
|
|
123
|
+
{ cause },
|
|
124
|
+
),
|
|
125
|
+
unparseable: () =>
|
|
126
|
+
new ConflictError({
|
|
127
|
+
message: `Cannot update ${path}: it is there and is not valid JSONC.`,
|
|
128
|
+
action: "Fix the syntax, or move it aside, and run the command again.",
|
|
129
|
+
// Never the parser's own message: comment-json quotes the line it choked on, and this rewrite
|
|
130
|
+
// renames a whole document over the adopter's file. The path and "it did not parse" is the whole
|
|
131
|
+
// of what fixes it (#219).
|
|
132
|
+
detail: `${WORKER_MANIFEST_FILE} in ${workerDir} did not parse, and Pithy will not rewrite a manifest it could not read back`,
|
|
133
|
+
}),
|
|
134
|
+
notARecord: ({ found }) =>
|
|
135
|
+
new ConflictError({
|
|
136
|
+
message: `Cannot update ${path}: it holds ${found}, not a manifest object.`,
|
|
137
|
+
action: "Restore it to a JSONC object, or move it aside, and run the command again.",
|
|
138
|
+
detail: `${WORKER_MANIFEST_FILE} in ${workerDir} parsed to ${found}`,
|
|
139
|
+
}),
|
|
140
|
+
invalid: ({ at }) =>
|
|
141
|
+
new ConflictError({
|
|
142
|
+
message: `Cannot update ${path}: it is not the manifest Pithy keeps there.`,
|
|
143
|
+
action: "Fix it, or move it aside, and run the command again.",
|
|
144
|
+
// The key path, so the block can be found. Never what was on it.
|
|
145
|
+
detail: `${WORKER_MANIFEST_FILE} in ${workerDir} failed its schema at ${at}`,
|
|
146
|
+
}),
|
|
147
|
+
});
|
|
148
|
+
return base.document;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Write the document back, printed the way the Biome `pithy init` scaffolds would print it (#249). */
|
|
152
|
+
export async function writeManifestDocument(workerDir: string, document: ManifestDocument): Promise<void> {
|
|
153
|
+
await writeJsonc(join(workerDir, WORKER_MANIFEST_FILE), document);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The `ui` block on a parsed document, or `null` when the worker has no front end. */
|
|
157
|
+
export function uiBlock(document: ManifestDocument): WorkerUi | null {
|
|
158
|
+
const block = document.ui;
|
|
159
|
+
if (typeof block !== "object" || block === null) return null;
|
|
160
|
+
const stub = (block as { stub?: unknown }).stub;
|
|
161
|
+
const build = (block as { build?: unknown }).build;
|
|
162
|
+
if (typeof stub !== "string") return null;
|
|
163
|
+
return {
|
|
164
|
+
stub,
|
|
165
|
+
build: Array.isArray(build) ? build.filter((entry): entry is string => typeof entry === "string") : [],
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The `ui` block for a worker directory, or `null`. The "does this worker already have a UI?" check. */
|
|
170
|
+
export async function readWorkerUi(workerDir: string): Promise<WorkerUi | null> {
|
|
171
|
+
return uiBlock(await readManifestDocument(workerDir));
|
|
172
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Documentation. Nothing copies this file anywhere — `pithy add <capability>` writes the real one.
|
|
2
|
+
//
|
|
3
|
+
// Your dev secrets do not live in this repository. They live at
|
|
4
|
+
// <config>/<project>/secrets.jsonc
|
|
5
|
+
// where <config> is $PITHY_CONFIG_DIR, else %APPDATA%\pithy, $XDG_CONFIG_HOME/pithy, or
|
|
6
|
+
// ~/.config/pithy. `pithy doctor` prints the exact path every run — that is how you find it.
|
|
7
|
+
//
|
|
8
|
+
// Outside the checkout on purpose: nothing about a secret can then reach a commit, a tarball, or an
|
|
9
|
+
// `rm -rf` on the working copy, and every worktree of one project reads the same values.
|
|
10
|
+
//
|
|
11
|
+
// Two files, two namespaces, and the split is the point:
|
|
12
|
+
// .dev.vars — wrangler env bindings. UPPER_SNAKE. In the worker's directory, because wrangler
|
|
13
|
+
// reads it there. Still in the repo, still gitignored.
|
|
14
|
+
// secrets.jsonc — secrets. <capability>-<what>, the payload each one's destination receives.
|
|
15
|
+
// Outside the repo.
|
|
16
|
+
//
|
|
17
|
+
// The key is the registry secret name, verbatim. Nothing here names a destination: the registry
|
|
18
|
+
// already knows each secret's backend, so `pithy seed` sends a `d1` secret to the local SECRETS
|
|
19
|
+
// database and a `cf-secrets-store` one to .dev.vars, and the two can never disagree.
|
|
20
|
+
//
|
|
21
|
+
// A secret's value here is the precise payload its destination receives. Nothing wraps it, nothing
|
|
22
|
+
// unwraps it, and no secret is an exception.
|
|
23
|
+
//
|
|
24
|
+
// For every ordinary secret that payload is a full { currentVersion, versions } envelope, even a
|
|
25
|
+
// single-version one — which is what makes the format unambiguous, because a JSON secret's own
|
|
26
|
+
// object sits inside `versions` where it can never be mistaken for the envelope around it, and it is
|
|
27
|
+
// the shape the store actually holds. SECRETS_ENCRYPTION_KEYS is the one secret whose binding is read
|
|
28
|
+
// before any envelope decoder exists, so its binding carries the value and this file states the value.
|
|
29
|
+
// The registry says which is which; you never have to guess.
|
|
30
|
+
//
|
|
31
|
+
// Generated secrets are not hand-written. Anything a value can honestly be invented for — a session
|
|
32
|
+
// key, a link-signing key — is minted here on `pithy add` and left alone afterwards. What you write
|
|
33
|
+
// by hand is what a third party issued you.
|
|
34
|
+
{
|
|
35
|
+
// "auth-session-secret": {
|
|
36
|
+
// "currentVersion": "1",
|
|
37
|
+
// "versions": { "1": "..." }
|
|
38
|
+
// },
|
|
39
|
+
// "auth-google-credentials": {
|
|
40
|
+
// "currentVersion": "1",
|
|
41
|
+
// "versions": { "1": { "clientId": "...", "clientSecret": "..." } }
|
|
42
|
+
// }
|
|
43
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# This file is documentation. Two different `.dev.vars` exist, and they have different owners.
|
|
2
|
+
#
|
|
3
|
+
# <root>/.dev.vars yours. The CLI's own Cloudflare credentials, below. Copy this to it.
|
|
4
|
+
# <root>/apps/<worker>/.dev.vars generated. pithy writes it; wrangler reads it. Do not edit one.
|
|
5
|
+
#
|
|
6
|
+
# One-time setup:
|
|
7
|
+
# 1. Run `wrangler login` — OAuth, used by `wrangler dev` and `wrangler deploy`.
|
|
8
|
+
# 2. Set the bootstrap Cloudflare API token below. pithy uses it to call the
|
|
9
|
+
# Cloudflare REST API and to mint scoped, account-owned tokens from there.
|
|
10
|
+
# CLOUDFLARE_ACCOUNT_ID=your-account-id
|
|
11
|
+
# CLOUDFLARE_API_TOKEN=your-bootstrap-token
|
|
12
|
+
#
|
|
13
|
+
# A Worker's `.dev.vars` is generated on every `pithy dev` and `pithy seed`, from the dev secrets file
|
|
14
|
+
# and this machine's `dev.json`. Run `pithy doctor` to see where those are. An edit to a generated file
|
|
15
|
+
# is lost on the next run, and a file pithy did not write is never overwritten — it refuses and says so.
|
|
16
|
+
#
|
|
17
|
+
# To override a value locally, use `.dev.vars.local` — git-ignored, hand-authored, merged last:
|
|
18
|
+
#
|
|
19
|
+
# <root>/.dev.vars.local every worker
|
|
20
|
+
# <root>/apps/<worker>/.dev.vars.local that worker, and wins
|
|
21
|
+
#
|
|
22
|
+
# It is for overrides, not for variables. A value that should exist in production belongs in
|
|
23
|
+
# `wrangler.jsonc`'s `vars` — committed, reviewed, deployed. A variable that lives only in a `.local`
|
|
24
|
+
# file works in dev and is simply absent in production, and that failure lands at deploy. `pithy doctor`
|
|
25
|
+
# names any such key.
|
|
26
|
+
#
|
|
27
|
+
# Application secrets live in neither. `pithy add <capability>` puts them in the dev secrets file,
|
|
28
|
+
# outside this repository. The registry's `backend` decides: a `d1` secret is read from the secrets
|
|
29
|
+
# store in every environment, dev included. A `cf-secrets-store` secret has no local store to read, so
|
|
30
|
+
# pithy puts its value in the generated file for you.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pithy-app-api",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"engines": {
|
|
6
|
+
"node": ">=22"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"dev": "wrangler dev",
|
|
10
|
+
"deploy": "wrangler deploy",
|
|
11
|
+
"deploy:staging": "wrangler deploy --env staging",
|
|
12
|
+
"deploy:prod": "wrangler deploy --env prod"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@pithy-sh/core": "^0.0.0",
|
|
16
|
+
"hono": "^4.13.2"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@cloudflare/workers-types": "^5.20260729.1",
|
|
20
|
+
"wrangler": "^4.123.0"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// apps/api/pithy.config.ts — what THIS Worker is made of.
|
|
2
|
+
//
|
|
3
|
+
// Every Worker under apps/ has its own config. Capabilities are per-Worker
|
|
4
|
+
// because everything they drive is per-Worker: the composed route tree, the
|
|
5
|
+
// bindings written into this Worker's wrangler.jsonc, and Durable Object class
|
|
6
|
+
// migrations (which register a class against a specific script).
|
|
7
|
+
//
|
|
8
|
+
// Two Workers share a resource by declaring the SAME binding name — feature
|
|
9
|
+
// resource names are derived from the binding, not the Worker — so two Workers
|
|
10
|
+
// that both declare `DB` are backed by one D1. A Worker that wants its own
|
|
11
|
+
// declares a different binding (e.g. COLLAB_DB).
|
|
12
|
+
|
|
13
|
+
import { defineCapability } from "@pithy-sh/core/src/capability/capability";
|
|
14
|
+
import { compositionEnvironment } from "@pithy-sh/core/src/env/ambient";
|
|
15
|
+
import { originFor } from "@pithy-sh/core/src/naming/domains";
|
|
16
|
+
|
|
17
|
+
// Where this Worker answers, per environment. Declare it once here and the
|
|
18
|
+
// `routes` entry and `vars.BASE_URL` in wrangler.jsonc are generated from it —
|
|
19
|
+
// `pithy init` and `pithy worker add` fill this in when you name a domain.
|
|
20
|
+
// `dev` is absent on purpose: local runs on the port your feature pinned.
|
|
21
|
+
const DOMAINS = {
|
|
22
|
+
// staging: { pattern: "staging.api.example.com", zone: "example.com" },
|
|
23
|
+
// prod: { pattern: "api.example.com", zone: "example.com" },
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// This Worker's public origin, for the environment it is composing in. Hand it
|
|
27
|
+
// to every capability that needs one — auth's callbacks, email's links, a
|
|
28
|
+
// payment's return URL — so no origin is ever written down.
|
|
29
|
+
//
|
|
30
|
+
// An origin written down is production's origin written into staging, and that
|
|
31
|
+
// is not a typo you notice: it is staging mailing your testers magic links into
|
|
32
|
+
// production, and an unsubscribe from a staging test unsubscribing them there.
|
|
33
|
+
// An environment DOMAINS does not name resolves to the local origin, so a link
|
|
34
|
+
// built in the wrong place goes nowhere rather than somewhere real.
|
|
35
|
+
//
|
|
36
|
+
// Exported so your own code can build a link against the same origin.
|
|
37
|
+
export const PUBLIC_ORIGIN = originFor(compositionEnvironment(), DOMAINS);
|
|
38
|
+
|
|
39
|
+
// Your app is a capability like any other: routes, middleware, databases, KV
|
|
40
|
+
// namespaces, and the bindings they need. It composes last, after every
|
|
41
|
+
// library capability.
|
|
42
|
+
const app = defineCapability({
|
|
43
|
+
// The app's capability name. Also its migration namespace once it has tables.
|
|
44
|
+
name: "app",
|
|
45
|
+
// Bindings your own routes need beyond what capabilities declare. Validated
|
|
46
|
+
// on the first request — a missing binding fails fast with the binding's name.
|
|
47
|
+
requiredBindings: [],
|
|
48
|
+
// Mount your routes here. Every route declares how callers are verified —
|
|
49
|
+
// bearer, session, signed-webhook, or public. `GET /health` is built in.
|
|
50
|
+
// routes: (a) => {
|
|
51
|
+
// a.get("/hello", (c) => c.text("Hi."));
|
|
52
|
+
// },
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const config = {
|
|
56
|
+
domains: DOMAINS,
|
|
57
|
+
// Library capabilities this Worker composes, in order.
|
|
58
|
+
// `pithy add <capability> --worker api` registers them here.
|
|
59
|
+
capabilities: [
|
|
60
|
+
// pithy:capabilities (managed region — do not remove this marker)
|
|
61
|
+
],
|
|
62
|
+
app,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export default config;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
// How `pithy dev` runs this Worker locally. This file is yours; wrangler.jsonc stays wrangler's.
|
|
3
|
+
"dev": {
|
|
4
|
+
// Must this run for the local environment to function? pithy dev starts exactly the autostart workers.
|
|
5
|
+
"autostart": true,
|
|
6
|
+
// Regex marking "ready" in this Worker's output.
|
|
7
|
+
"readySignal": "Ready on https?://"
|
|
8
|
+
// "preferredPort": 8787 // a hint only — the feature's reserved port block is authoritative.
|
|
9
|
+
// "command": ["bun", "run", "dev"] // set this for a non-Worker process (e.g. a Vite frontend).
|
|
10
|
+
}
|
|
11
|
+
}
|