@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,208 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { createBackend } from "@pithy-sh/core/src/createBackend";
|
|
5
|
+
import { CI_ENV } from "@pithy-sh/core/src/env/ci";
|
|
6
|
+
import { LOCAL_ENVIRONMENT } from "@pithy-sh/core/src/naming/environment";
|
|
7
|
+
import { HEALTH_PATH } from "@pithy-sh/core/src/worker/health";
|
|
8
|
+
import { ENVIRONMENT_VAR } from "@pithy-sh/core/src/worker/identity";
|
|
9
|
+
import type { WorkerConfig } from "../project/config";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `assets.run_worker_first` — the explicit allowlist of paths that reach the Worker instead of the
|
|
13
|
+
* static assets.
|
|
14
|
+
*
|
|
15
|
+
* It has to be an allowlist, and it has to be derived, because the ordering is not what it looks
|
|
16
|
+
* like: with `not_found_handling: "single-page-application"`, the asset router answers a request with
|
|
17
|
+
* `index.html` **before** the Worker runs. A `run_worker_first` that names the wrong prefix does not
|
|
18
|
+
* degrade — `GET /health` comes back as 200 text/html and `POST /auth/sign-in/magic-link` as 405,
|
|
19
|
+
* with the Worker never invoked. Pithy's routes sit at capability base paths (`/auth`,
|
|
20
|
+
* `/leaderboard`, `/ledger`, `/payments`, …) plus `/health`; none of them is under `/api`. So the only
|
|
21
|
+
* correct list is the one the Worker's own composed route table produces.
|
|
22
|
+
*
|
|
23
|
+
* Two verified details shape the emitted patterns:
|
|
24
|
+
* - `"/auth/*"` does **not** match bare `"/auth"`, so each segment emits both.
|
|
25
|
+
* - a bare-prefix glob like `"/media*"` over-matches `/mediafoo`, so it is never emitted.
|
|
26
|
+
*
|
|
27
|
+
* **Why a list at all, when dropping it makes `fetch` calls work by themselves.** The asset worker's
|
|
28
|
+
* `canFetch` is one line, and it decides everything here:
|
|
29
|
+
*
|
|
30
|
+
* ```js
|
|
31
|
+
* if (!(has_static_routing || (navigateFlag && request.headers.get("Sec-Fetch-Mode") === "navigate")))
|
|
32
|
+
* configuration = { ...configuration, not_found_handling: "none" };
|
|
33
|
+
* ```
|
|
34
|
+
*
|
|
35
|
+
* An array `run_worker_first` sets `has_static_routing`. With it, a path the list misses is answered
|
|
36
|
+
* by the SPA shell whatever the method — a 200 with the wrong body, the worst failure available, and
|
|
37
|
+
* the bug this file's derivation exists to prevent. *Without* it, every non-navigation falls through
|
|
38
|
+
* to the Worker, which is why removing the key looks like a clean fix from a browser's `fetch` and
|
|
39
|
+
* from curl.
|
|
40
|
+
*
|
|
41
|
+
* It is not one, because the navigation half stays: with no list, a request carrying
|
|
42
|
+
* `Sec-Fetch-Mode: navigate` keeps `not_found_handling` and gets the shell. A magic-link click lands
|
|
43
|
+
* on `/auth/magic-link/verify` and an OAuth provider redirects to `/auth/callback/<provider>`, both as
|
|
44
|
+
* top-level navigations — so dropping the list trades every API route for the two flows that carry
|
|
45
|
+
* Pithy's sign-in, and trades them just as silently. The list stays; {@link uncoveredRoutes} is how
|
|
46
|
+
* its drift is made loud instead.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The first path segment of a Hono route pattern, or `null` when it cannot be expressed as an
|
|
51
|
+
* allowlist entry. Wildcards and the root are `null` (core's own `app.use("*")` middleware lands
|
|
52
|
+
* here). A `:param` in the first position is `null` too — a Worker mounted at a bare parameter root
|
|
53
|
+
* claims every path, which no allowlist can say and no Pithy capability does.
|
|
54
|
+
*
|
|
55
|
+
* A wildcard in a LATER segment still yields its prefix: `/auth/*` is `auth`, which is how the auth
|
|
56
|
+
* capability's Better Auth catch-all gets covered.
|
|
57
|
+
*
|
|
58
|
+
* **A route the adopter mounts at `/` is deliberately not allowlisted.** In a Worker that serves a
|
|
59
|
+
* SPA, `/` is the app shell — that is what `not_found_handling` is for. An adopter wanting an API
|
|
60
|
+
* response at the root of a UI-bearing Worker has a real conflict with the front end, and this
|
|
61
|
+
* resolves it in the front end's favor rather than shadowing the app's own entry point. It is
|
|
62
|
+
* silent, so `docs/UI.md` says so as well.
|
|
63
|
+
*/
|
|
64
|
+
export function firstSegment(path: string): string | null {
|
|
65
|
+
const segment = path.replace(/^\/+/, "").split("/")[0] ?? "";
|
|
66
|
+
if (segment === "" || segment.includes("*") || segment.startsWith(":")) return null;
|
|
67
|
+
return segment;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The `run_worker_first` patterns for a route table: `"/<segment>"` and `"/<segment>/*"` for each
|
|
72
|
+
* distinct first segment, sorted so the written config is stable run to run. The health route is always
|
|
73
|
+
* present — `createBackend` serves it for every Worker, and it is the one route an adopter is most
|
|
74
|
+
* likely to check first.
|
|
75
|
+
*
|
|
76
|
+
* Seeded from `HEALTH_PATH` through the same `firstSegment` every other route goes through, rather than
|
|
77
|
+
* from a `"health"` written here (#400). One statement in `@pithy-sh/core`, and the allowlist follows a
|
|
78
|
+
* rename instead of quietly leaving the renamed route shadowed by the SPA shell.
|
|
79
|
+
*/
|
|
80
|
+
export function workerFirstPatterns(paths: readonly string[]): string[] {
|
|
81
|
+
const segments = new Set<string>();
|
|
82
|
+
for (const path of [HEALTH_PATH, ...paths]) {
|
|
83
|
+
const segment = firstSegment(path);
|
|
84
|
+
if (segment) segments.add(segment);
|
|
85
|
+
}
|
|
86
|
+
return [...segments].sort().flatMap((segment) => [`/${segment}`, `/${segment}/*`]);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* **A Worker has one route table per environment, not one route table.** That is the whole of #255.
|
|
91
|
+
*
|
|
92
|
+
* A capability may decide at *registration* whether to mount a route, and `@pithy-sh/auth` does: its
|
|
93
|
+
* dev-login route is mounted only when the composition's `ENVIRONMENT` is `dev` and `CI` is unset. Both
|
|
94
|
+
* gates are right — the route mints an authenticated session with no credential presented, so its
|
|
95
|
+
* absence from a production route table is the security property, not an implementation detail.
|
|
96
|
+
*
|
|
97
|
+
* The consequence is that composing the app *once*, under whatever environment the command happened to
|
|
98
|
+
* run in, produces the route table of that environment and calls it the Worker's. `pithy ui sync` runs
|
|
99
|
+
* with nothing stamped, so it composed a not-`dev` app, never saw `/__pithy/dev-login`, wrote an
|
|
100
|
+
* allowlist without it — and `--check` then reported `every route reaches the worker`, which is the one
|
|
101
|
+
* claim that command exists to make.
|
|
102
|
+
*
|
|
103
|
+
* So the environment is an *input* here, and every route table is taken.
|
|
104
|
+
*
|
|
105
|
+
* ## Why `process.env`
|
|
106
|
+
*
|
|
107
|
+
* Because that is the seam a capability reads. `createBackend` calls `routes()` with no environment
|
|
108
|
+
* argument — in a Worker there is none to give, since bindings arrive per request — so a registration-time
|
|
109
|
+
* gate reads `process.env` (`@pithy-sh/core`'s `env/ambient`), which workerd populates from the script's
|
|
110
|
+
* own vars before the first request. Under Node it is the host's. Setting it around a synchronous
|
|
111
|
+
* composition and restoring it in a `finally` is therefore not a trick played on the capability; it is
|
|
112
|
+
* the only way to ask it the question it answers.
|
|
113
|
+
*
|
|
114
|
+
* ## Why `CI` is cleared rather than honored
|
|
115
|
+
*
|
|
116
|
+
* `ui sync --check` is a CI gate, and `ui sync` runs on a laptop. If the derivation honored `CI`, those
|
|
117
|
+
* two would derive different lists from the same repository: CI would demand the shorter one and fail on
|
|
118
|
+
* the file a developer correctly wrote. A list is only checkable if it is a function of the project.
|
|
119
|
+
*
|
|
120
|
+
* The asymmetry makes the choice free. An allowlist entry nothing serves costs a 404 from the Worker,
|
|
121
|
+
* which is the right answer. A missing entry costs a 200 with the SPA shell — a request that was never
|
|
122
|
+
* even seen by the code meant to answer it.
|
|
123
|
+
*/
|
|
124
|
+
function composedPathsIn(config: WorkerConfig, environment: string): string[] {
|
|
125
|
+
const previousEnvironment = process.env[ENVIRONMENT_VAR];
|
|
126
|
+
const previousCi = process.env[CI_ENV];
|
|
127
|
+
process.env[ENVIRONMENT_VAR] = environment;
|
|
128
|
+
delete process.env[CI_ENV];
|
|
129
|
+
try {
|
|
130
|
+
const app = createBackend({ capabilities: config.capabilities, ...(config.app ? { app: config.app } : {}) });
|
|
131
|
+
return app.routes.map((route) => route.path);
|
|
132
|
+
} finally {
|
|
133
|
+
// Restored, not defaulted: a variable this process never had must not exist afterwards, or the next
|
|
134
|
+
// thing to read `ENVIRONMENT` in this CLI run is told something the project never said.
|
|
135
|
+
if (previousEnvironment === undefined) delete process.env[ENVIRONMENT_VAR];
|
|
136
|
+
else process.env[ENVIRONMENT_VAR] = previousEnvironment;
|
|
137
|
+
if (previousCi === undefined) delete process.env[CI_ENV];
|
|
138
|
+
else process.env[CI_ENV] = previousCi;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Every environment a route table is taken in: the ones the project declares, plus the local one.
|
|
144
|
+
*
|
|
145
|
+
* `dev` is added here rather than asked for, because {@link DeclaredEnvironments} refuses it — it is the
|
|
146
|
+
* top-level wrangler stanza, it never deploys, and it always exists. A caller cannot pass it, so a
|
|
147
|
+
* derivation that waited to be handed it would have missed exactly the environment `pithy dev` runs, on
|
|
148
|
+
* every project there is. Which is the bug.
|
|
149
|
+
*/
|
|
150
|
+
function derivationEnvironments(environments: readonly string[]): string[] {
|
|
151
|
+
return [...new Set([...environments, LOCAL_ENVIRONMENT])];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* A Worker's real composed route table, unioned across every environment it has. The config is code, so
|
|
156
|
+
* the only honest source is the assembled app: `createBackend` mounts every capability's routes onto one
|
|
157
|
+
* Hono instance, and `app.routes` carries the pattern intact — see {@link composedPathsIn} for why the
|
|
158
|
+
* app is assembled once per environment rather than once.
|
|
159
|
+
*
|
|
160
|
+
* `environments` is the project's declaration, from the root `pithy.config.ts`. It has no default: a
|
|
161
|
+
* defaulted environment set is how a caller silently derives the allowlist of a project that is not the
|
|
162
|
+
* one in front of it.
|
|
163
|
+
*/
|
|
164
|
+
export function composedPaths(config: WorkerConfig, environments: readonly string[]): string[] {
|
|
165
|
+
const paths = new Set<string>();
|
|
166
|
+
for (const environment of derivationEnvironments(environments)) {
|
|
167
|
+
for (const path of composedPathsIn(config, environment)) paths.add(path);
|
|
168
|
+
}
|
|
169
|
+
return [...paths];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Derive the allowlist from a Worker's real composed route table, in every environment it has. */
|
|
173
|
+
export function deriveWorkerFirst(config: WorkerConfig, environments: readonly string[]): string[] {
|
|
174
|
+
return workerFirstPatterns(composedPaths(config, environments));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The routes `patterns` does not cover — the drift `pithy ui sync --check` gates on.
|
|
179
|
+
*
|
|
180
|
+
* The list is derived once, at `pithy ui add`, and every route mounted afterwards is a route the SPA
|
|
181
|
+
* shell answers with a 200. That includes routes the adopter writes into their **own** app capability,
|
|
182
|
+
* which no `pithy add` and no `pithy remove` is present for, so nothing but a comparison can catch
|
|
183
|
+
* them. It compares by first segment, because that is the unit the patterns are emitted in: a segment
|
|
184
|
+
* whose pair is in the list covers every route beneath it.
|
|
185
|
+
*
|
|
186
|
+
* A route {@link firstSegment} cannot express is never reported. `core`'s own `app.use("*")` and a
|
|
187
|
+
* route at `/` are not drift — they are paths this derivation deliberately leaves to the shell, and a
|
|
188
|
+
* check that flagged them would mark every project drifted forever, which is how a check gets ignored.
|
|
189
|
+
*
|
|
190
|
+
* **The invariant it gates is over every environment the project has**, not over the one this process
|
|
191
|
+
* happens to be: a route the Worker mounts in any of them is a route the asset handler must not answer
|
|
192
|
+
* first. Stated over one composition, the check passed with the defect present — see {@link composedPaths}.
|
|
193
|
+
*/
|
|
194
|
+
export function uncoveredRoutes(
|
|
195
|
+
config: WorkerConfig,
|
|
196
|
+
patterns: readonly string[],
|
|
197
|
+
environments: readonly string[],
|
|
198
|
+
): string[] {
|
|
199
|
+
const covered = new Set(patterns);
|
|
200
|
+
const uncovered = new Set<string>();
|
|
201
|
+
for (const path of composedPaths(config, environments)) {
|
|
202
|
+
const segment = firstSegment(path);
|
|
203
|
+
if (!segment) continue;
|
|
204
|
+
if (covered.has(`/${segment}`) && covered.has(`/${segment}/*`)) continue;
|
|
205
|
+
uncovered.add(path);
|
|
206
|
+
}
|
|
207
|
+
return [...uncovered].sort();
|
|
208
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { mkdir } from "node:fs/promises";
|
|
5
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
6
|
+
import { ConflictError } from "@pithy-sh/core/src/error/pithyError";
|
|
7
|
+
import { writeFileAtomic } from "../project/atomic";
|
|
8
|
+
import { ensureScaffoldPath, pathExists } from "../project/scaffold";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Create, never overwrite.
|
|
12
|
+
*
|
|
13
|
+
* A stub is scaffolded once and then it belongs to the adopter. Pithy may add files that do not exist
|
|
14
|
+
* yet — an OTP screen today, whatever templates follow — because a new file cannot clobber work
|
|
15
|
+
* somebody did. It may never rewrite one. That asymmetry is the whole ownership rule, and it is what
|
|
16
|
+
* lets `pithy ui add --auth` backfill a scaffold created with `--no-auth`.
|
|
17
|
+
*
|
|
18
|
+
* The check runs over **every** path before anything is written, so a collision is a clean error
|
|
19
|
+
* rather than a half-written directory.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** The directory every Worker lives under, and the segment `pithy ui add` composes a path through. */
|
|
23
|
+
const APPS_DIR = "apps";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The boundary {@link ensureScaffoldPath} walks down from: **the project root**, not the worker.
|
|
27
|
+
*
|
|
28
|
+
* The primitive checks every path from its root down to the target, and skips the root itself — the root
|
|
29
|
+
* is the adopter's directory, which they may legitimately keep behind a symlink. Bounding it at
|
|
30
|
+
* `workerDir` therefore put `apps` *and* `apps/<worker>` above the walk, where nothing looked at them,
|
|
31
|
+
* and a symlink at either carried the whole front end out of the project. Reproduced: `pithy ui add
|
|
32
|
+
* react --worker board` with `apps` linked outside wrote ten files there and printed "Done."
|
|
33
|
+
*
|
|
34
|
+
* `apps/<worker>` is a path **Pithy composed**, out of a name, exactly like the directories under it. So
|
|
35
|
+
* it belongs inside the walk, and the way to put it there is to start one directory above `apps`.
|
|
36
|
+
*
|
|
37
|
+
* The layout is read from the path rather than guessed at: this is only the boundary when the worker
|
|
38
|
+
* really does sit at `apps/<name>`, which is the shape `pithy ui add` resolves and the shape every other
|
|
39
|
+
* function in this flow assumes (`workerName` is `basename(workerDir)`). Handed anything else — a bare
|
|
40
|
+
* directory, as the unit tests pass — there is no `apps` segment Pithy invented, nothing above the
|
|
41
|
+
* directory is ours to judge, and the boundary is the directory itself.
|
|
42
|
+
*/
|
|
43
|
+
function scaffoldRoot(workerDir: string): string {
|
|
44
|
+
const here = resolve(workerDir);
|
|
45
|
+
return basename(dirname(here)) === APPS_DIR ? resolve(here, "..", "..") : here;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** What one scaffold run wrote. */
|
|
49
|
+
export interface ScaffoldResult {
|
|
50
|
+
/** The worker-relative paths written, sorted. Empty when every file already existed. */
|
|
51
|
+
written: string[];
|
|
52
|
+
/** The worker-relative paths already present and therefore left byte-identical, sorted. */
|
|
53
|
+
skipped: string[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Write a stub's file record into `workerDir`, creating parent directories.
|
|
58
|
+
*
|
|
59
|
+
* Every path that already exists is a `ConflictError` naming all of them, and nothing is written —
|
|
60
|
+
* `strict` is the initial `pithy ui add`, where a collision means the worker already has a front end
|
|
61
|
+
* of some sort and guessing which file to keep is not ours to do.
|
|
62
|
+
*
|
|
63
|
+
* With `strict` off (the backfill path) an existing file is skipped and reported instead. Either way
|
|
64
|
+
* no existing byte changes.
|
|
65
|
+
*/
|
|
66
|
+
export async function scaffoldFiles(options: {
|
|
67
|
+
/** The worker's directory — `apps/<name>`. Every path below it is checked; the directory itself is the
|
|
68
|
+
* caller's, and `pithy worker add` is what gates *that* one. */
|
|
69
|
+
workerDir: string;
|
|
70
|
+
/** The stub's file record: worker-relative path → contents. */
|
|
71
|
+
files: Record<string, string>;
|
|
72
|
+
/** Refuse the whole run if any path exists (the default). Off for a backfill, which skips instead. */
|
|
73
|
+
strict?: boolean;
|
|
74
|
+
}): Promise<ScaffoldResult> {
|
|
75
|
+
const entries = Object.entries(options.files).sort(([a], [b]) => a.localeCompare(b));
|
|
76
|
+
const root = scaffoldRoot(options.workerDir);
|
|
77
|
+
|
|
78
|
+
const skipped: string[] = [];
|
|
79
|
+
for (const [rel] of entries) {
|
|
80
|
+
const path = join(options.workerDir, rel);
|
|
81
|
+
// Every directory this run would create or write through, before any of it is created. `exists()` here
|
|
82
|
+
// was `access`, which follows a link and answers about its destination — so a symlink at
|
|
83
|
+
// `apps/<worker>/src` read as "src/client.tsx is missing", cleared the gate, and `pithy ui add react`
|
|
84
|
+
// wrote six files of the front end outside the project and exited 0. Reproduced against the real CLI.
|
|
85
|
+
//
|
|
86
|
+
// From {@link scaffoldRoot}, not from `workerDir`: bounded at the worker, the two segments above it
|
|
87
|
+
// were outside the walk and a link at either escaped just as completely.
|
|
88
|
+
//
|
|
89
|
+
// The refusal covers the backfill (`strict: false`) too, and that is not the same rule as the skip
|
|
90
|
+
// below. A file already *there* is safe to skip because nothing is written to it. A link where a
|
|
91
|
+
// directory belongs is the opposite: the file under it does not exist, so the backfill writes — outside.
|
|
92
|
+
await ensureScaffoldPath(root, dirname(path));
|
|
93
|
+
if (await pathExists(path)) skipped.push(rel);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (skipped.length > 0 && options.strict !== false) {
|
|
97
|
+
throw new ConflictError({
|
|
98
|
+
message: `${skipped.length === 1 ? "This file already exists" : "These files already exist"}: ${skipped.join(", ")}.`,
|
|
99
|
+
action: "Pithy never overwrites a file it did not just write. Move or delete them, then run pithy ui add again.",
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const written: string[] = [];
|
|
104
|
+
for (const [rel, contents] of entries) {
|
|
105
|
+
if (skipped.includes(rel)) continue;
|
|
106
|
+
const path = join(options.workerDir, rel);
|
|
107
|
+
await mkdir(dirname(path), { recursive: true });
|
|
108
|
+
await writeFileAtomic(path, contents);
|
|
109
|
+
written.push(rel);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return { written, skipped };
|
|
113
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* **A screen this command writes renders styled, or the command says exactly what is missing.**
|
|
6
|
+
*
|
|
7
|
+
* `pithy ui add` writes only what does not exist, which is right and is what makes a backfill safe: an
|
|
8
|
+
* adopter's `src/styles.css` is theirs, and overwriting it would destroy their design. But the screens
|
|
9
|
+
* and the rules they need were once one file, so the backfill wrote `routes/pithy/sign-in.tsx` —
|
|
10
|
+
* rendering `stack`, `divider` and `secondary` — while correctly skipping the only file that defined
|
|
11
|
+
* them, and reported the result as `created`. An adopter's first sight of the feature they had just
|
|
12
|
+
* enabled was an unstyled login page.
|
|
13
|
+
*
|
|
14
|
+
* The structural half of the fix is `src/pithy-screens.css`: Pithy's screens carry their own stylesheet,
|
|
15
|
+
* in the `base` group, so the run that writes a screen writes its rules. This module is the half that
|
|
16
|
+
* keeps it true — it extracts what the screens render and what the stylesheets define, and diffs them.
|
|
17
|
+
*
|
|
18
|
+
* It runs in three places, and the third is why it is a module rather than a test helper:
|
|
19
|
+
*
|
|
20
|
+
* - **As a gate**, over the template itself. A screen gaining a class whose rule lives somewhere the
|
|
21
|
+
* backfill never writes fails CI. That is the drift that produced this, in that direction.
|
|
22
|
+
* - **As output**, at `pithy ui add`, over what that run left on disk. A stylesheet the adopter has
|
|
23
|
+
* since edited, a `pithy-screens.css` they deleted, a screen of their own under `routes/pithy/` —
|
|
24
|
+
* none of those are things the template can be checked for, and all of them end in the same unstyled
|
|
25
|
+
* screen. So the report names the classes rather than claiming the screens are fine.
|
|
26
|
+
* - **As a gate again**, at `pithy ui sync --check`, where a non-empty finding exits 1 (#401). It used
|
|
27
|
+
* to run only at scaffold, print, and not affect the exit — but `styles.css` is the adopter's, and the
|
|
28
|
+
* ordinary way a screen goes unstyled is an edit a week later. A one-shot warning cannot see that.
|
|
29
|
+
* `docs/UI.md` § *Two stylesheets, and why* is where an adopter reads this.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** Where Pithy's own screens live inside a scaffolded Worker — the only files this checks. */
|
|
33
|
+
export const PITHY_SCREEN_DIR = "src/routes/pithy/";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The class names a React source renders.
|
|
37
|
+
*
|
|
38
|
+
* Both `className="a b"` and `className={cond ? "a" : "b"}` are read, because both put a literal class
|
|
39
|
+
* name in the markup and only one of them is the shape the templates happen to use today. An expression
|
|
40
|
+
* with no literal at all contributes nothing, which is the honest answer — a class assembled at runtime
|
|
41
|
+
* is not something a static check can claim to have verified.
|
|
42
|
+
*
|
|
43
|
+
* ## The blind spot, stated because a template author will meet it
|
|
44
|
+
*
|
|
45
|
+
* **`className={CHECKOUT_FRAME}` contributes nothing.** A bare identifier is an expression with no
|
|
46
|
+
* literal in it, so this reads no name from it, and the check downstream reports the screen as fully
|
|
47
|
+
* styled. Today that is harmless and deliberate: `CHECKOUT_FRAME` is `"pithy-checkout"`, the hosted
|
|
48
|
+
* checkout's mount point, and it is defined by no stylesheet Pithy ships because it is an adopter hook.
|
|
49
|
+
* The one Pithy-rendered class with no rule anywhere is exactly the one this cannot see (#391 item E).
|
|
50
|
+
*
|
|
51
|
+
* A future constant that *is* meant to be styled would be invisible the same way, and now that a finding
|
|
52
|
+
* fails `pithy ui sync --check` (#401), invisible means it passes. Resolving identifiers across files is
|
|
53
|
+
* not the fix — it would surface `pithy-checkout` and force an exemption list for a class that is
|
|
54
|
+
* correctly undefined. **Write the name as a literal in the `className` if you want it checked.**
|
|
55
|
+
*/
|
|
56
|
+
export function renderedClassNames(source: string): string[] {
|
|
57
|
+
const names = new Set<string>();
|
|
58
|
+
for (const match of source.matchAll(/className\s*=\s*(?:"([^"]*)"|\{([^}]*)\})/g)) {
|
|
59
|
+
const literals =
|
|
60
|
+
match[1] !== undefined ? [match[1]] : [...(match[2] ?? "").matchAll(/"([^"]*)"/g)].map((m) => m[1]);
|
|
61
|
+
for (const literal of literals) {
|
|
62
|
+
for (const name of (literal ?? "").split(/\s+/)) if (name) names.add(name);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return [...names].sort();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The class names a stylesheet defines.
|
|
70
|
+
*
|
|
71
|
+
* Read from the selector preludes rather than by scanning the whole text, so a `.secondary` inside a
|
|
72
|
+
* comment or a `content: ".x"` string is not mistaken for a rule. A prelude is whatever precedes a `{`
|
|
73
|
+
* since the last `{`, `}` or `;`, which walks nested blocks at any depth — and that is what lets it see
|
|
74
|
+
* inside `@layer` and `@media`. The layer is how these rules stay overridable, so a reader that could
|
|
75
|
+
* not look into one would report every class as undefined.
|
|
76
|
+
*
|
|
77
|
+
* **Its own `/* … *\/` strip, and it stays one (#439).** Every TypeScript-source scan in this repository
|
|
78
|
+
* reads through `blankComments` in `@pithy-sh/core/src/text/comments`, because a pattern over comments
|
|
79
|
+
* cannot tell a comment from a `//` in a URL or a `/*` in a glob. CSS has neither: no line comments at
|
|
80
|
+
* all, and no string a `/*` can hide in that this reader would reach — a `content: ".x"` is a value,
|
|
81
|
+
* inside a block this walk has already entered. A stripper written for TypeScript would also have to
|
|
82
|
+
* guess at `/` as a divide and as a regex, which is a guess with nothing to gain here.
|
|
83
|
+
*/
|
|
84
|
+
export function definedClassNames(css: string): string[] {
|
|
85
|
+
const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, " ");
|
|
86
|
+
const names = new Set<string>();
|
|
87
|
+
let prelude = "";
|
|
88
|
+
for (const character of withoutComments) {
|
|
89
|
+
if (character === "{") {
|
|
90
|
+
// An at-rule's prelude (`@layer pithy`, `@media …`) names no selector; its body is walked next.
|
|
91
|
+
if (!prelude.trim().startsWith("@")) {
|
|
92
|
+
for (const match of prelude.matchAll(/\.(-?[A-Za-z_][\w-]*)/g)) if (match[1]) names.add(match[1]);
|
|
93
|
+
}
|
|
94
|
+
prelude = "";
|
|
95
|
+
} else if (character === "}" || character === ";") {
|
|
96
|
+
// A declaration ends at `;` and a block at `}`; either way what came before is not a selector.
|
|
97
|
+
prelude = "";
|
|
98
|
+
} else {
|
|
99
|
+
prelude += character;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return [...names].sort();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** One scaffolded file, keyed the way `pithy ui` keys everything: relative to `apps/<worker>/`. */
|
|
106
|
+
export type ScaffoldedFiles = Readonly<Record<string, string>>;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The class names Pithy's screens render that no stylesheet in the same set defines — empty, or the
|
|
110
|
+
* exact list a report has to name.
|
|
111
|
+
*
|
|
112
|
+
* Every `.css` in the set counts, not only Pithy's own: a project whose stylesheet already defines
|
|
113
|
+
* `stack` is not broken, and a check that insisted the rule live in a particular file would be
|
|
114
|
+
* enforcing a layout rather than the property that matters.
|
|
115
|
+
*/
|
|
116
|
+
export function unstyledScreenClasses(files: ScaffoldedFiles): string[] {
|
|
117
|
+
const defined = new Set<string>();
|
|
118
|
+
for (const [path, contents] of Object.entries(files)) {
|
|
119
|
+
if (path.endsWith(".css")) for (const name of definedClassNames(contents)) defined.add(name);
|
|
120
|
+
}
|
|
121
|
+
const missing = new Set<string>();
|
|
122
|
+
for (const [path, contents] of Object.entries(files)) {
|
|
123
|
+
if (!path.startsWith(PITHY_SCREEN_DIR) || !path.endsWith(".tsx")) continue;
|
|
124
|
+
for (const name of renderedClassNames(contents)) if (!defined.has(name)) missing.add(name);
|
|
125
|
+
}
|
|
126
|
+
return [...missing].sort();
|
|
127
|
+
}
|
package/src/ui/stubs.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { NotFoundError } from "@pithy-sh/core/src/error/pithyError";
|
|
5
|
+
import type { PackageManager } from "../project/packageManager";
|
|
6
|
+
import { reactStub } from "./react";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The framework-stub contract — everything `pithy ui` needs to scaffold a front end into a Worker and
|
|
10
|
+
* everything `pithy dev` and `pithy deploy` need to run and build it afterwards. Nothing more abstract
|
|
11
|
+
* than that: React 19 is the only stub today, so the interface describes the two jobs a stub actually
|
|
12
|
+
* has (write files, declare its commands) rather than a framework model invented against one example.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** What a stub is told about the Worker it is being scaffolded into. */
|
|
16
|
+
export interface UiStubContext {
|
|
17
|
+
/**
|
|
18
|
+
* The target Worker's **directory** name — the `<name>` in `apps/<name>`.
|
|
19
|
+
*
|
|
20
|
+
* Not its deployed name: a Worker deploys as `<project>-<worker>`, and every use of this value is a path
|
|
21
|
+
* or a build-state file that sits beside the ones `pithy init` already named after the directory.
|
|
22
|
+
*/
|
|
23
|
+
worker: string;
|
|
24
|
+
/** Whether to write the auth template (the passwordless screens) on top of the bare SPA. */
|
|
25
|
+
auth: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Whether to write the payments screens — the paywall and the subscription status page.
|
|
28
|
+
*
|
|
29
|
+
* A second capability-gated screen set, chosen exactly the way `auth` is. It rides on the target Worker
|
|
30
|
+
* composing `payments`, and it stacks: a Worker with both gets both, because the groups name disjoint
|
|
31
|
+
* files over one layout.
|
|
32
|
+
*/
|
|
33
|
+
payments: boolean;
|
|
34
|
+
/** The project's package manager — what the generated docs and commands tell the adopter to run. */
|
|
35
|
+
packageManager: PackageManager;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** One file a stub writes: where it comes from in the template tree, and where it lands in the Worker. */
|
|
39
|
+
export interface UiStubFile {
|
|
40
|
+
/** Path within the stub's `templateDir`. */
|
|
41
|
+
source: string;
|
|
42
|
+
/** Path relative to `apps/<worker>/`. Usually the same as `source`, but not always — the bare
|
|
43
|
+
* template's home screen lives beside the auth one as `home.bare.tsx` so both compile in place. */
|
|
44
|
+
target: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** One framework stub: the files it writes, the packages it needs, and how it is run and built. */
|
|
48
|
+
export interface UiStub {
|
|
49
|
+
/** The positional `pithy ui add <framework>` takes, e.g. `react`. */
|
|
50
|
+
id: string;
|
|
51
|
+
/** One line, for `pithy ui list`. */
|
|
52
|
+
description: string;
|
|
53
|
+
/**
|
|
54
|
+
* The root of this stub's template tree — real files on disk, in the stub's own template library
|
|
55
|
+
* (`@pithy-sh/ui-react` for React).
|
|
56
|
+
*
|
|
57
|
+
* They are real files rather than string literals so that CI typechecks them and Biome lints them:
|
|
58
|
+
* ~800 lines of React that only ever existed as a template literal is 800 lines no gate can see.
|
|
59
|
+
* They live in a separate package because a framework's templates need that framework's toolchain
|
|
60
|
+
* to be checked — folding them in here would make the CLI carry every framework's devDependencies
|
|
61
|
+
* and every framework's tsconfig, to typecheck files it only ever copies.
|
|
62
|
+
*
|
|
63
|
+
* The tree mirrors the scaffolded layout exactly, which is what lets a screen's `../../router`
|
|
64
|
+
* import resolve in the template as well as in the Worker it is copied into.
|
|
65
|
+
*/
|
|
66
|
+
templateDir: string;
|
|
67
|
+
/**
|
|
68
|
+
* Every file this stub writes, for a given context. **Pure** — no filesystem, no clock, no
|
|
69
|
+
* randomness — so the whole file set is assertable as a value in a test, and so the
|
|
70
|
+
* create-never-overwrite check can see every target path before anything is read or written.
|
|
71
|
+
* {@link loadStubFiles} is what turns this declaration into contents.
|
|
72
|
+
*/
|
|
73
|
+
manifest(context: UiStubContext): UiStubFile[];
|
|
74
|
+
/**
|
|
75
|
+
* Literal tokens replaced in every template's text, e.g. `__PITHY_WORKER__` → the Worker's name.
|
|
76
|
+
* Pure, and deliberately tiny: a template that needs more than a name substituted is a template
|
|
77
|
+
* that should have been two files.
|
|
78
|
+
*/
|
|
79
|
+
substitutions(context: UiStubContext): Record<string, string>;
|
|
80
|
+
/**
|
|
81
|
+
* Runtime dependencies merged into the Worker's `package.json`.
|
|
82
|
+
*
|
|
83
|
+
* A **null** range means "declare nothing" — the package is needed, but no range exists that the
|
|
84
|
+
* registry could resolve. That is the state every `@pithy-sh/*` package is in until the scope
|
|
85
|
+
* publishes, and it is `kitRange` that decides, not this declaration: see {@link devDependencies}.
|
|
86
|
+
*/
|
|
87
|
+
dependencies: Record<string, string | null>;
|
|
88
|
+
/**
|
|
89
|
+
* Build-time dependencies merged into the Worker's `package.json`.
|
|
90
|
+
*
|
|
91
|
+
* Every `@pithy-sh/*` range here is `kitRange(PACKAGE_VERSION)` rather than a literal, for the reason
|
|
92
|
+
* `stampWorkerManifest` gives: a hardcoded `"^0.0.0"` 404s the adopter's next install today and keeps
|
|
93
|
+
* 404ing after release, because no release moves it. `null` drops the line; a real version writes it.
|
|
94
|
+
*/
|
|
95
|
+
devDependencies: Record<string, string | null>;
|
|
96
|
+
/**
|
|
97
|
+
* The dev argv **without** a package-manager prefix, for a given port. `wire` runs it through the
|
|
98
|
+
* adopter's `execArgs` before persisting, so a Bun project gets `bun x vite dev …` and an npm project
|
|
99
|
+
* `npx vite dev …` — the same rule `buildCommand` follows, and the reason adoption is never gated
|
|
100
|
+
* behind Bun. `--strictPort` is not optional: without it Vite silently increments off a busy port,
|
|
101
|
+
* which breaks Pithy's rule that ports are assigned at feature creation, never probed at startup.
|
|
102
|
+
*/
|
|
103
|
+
devCommand(port: string): string[];
|
|
104
|
+
/** Regex source marking "ready" in this process's output — `pithy dev` matches it to release the gate. */
|
|
105
|
+
readySignal: string;
|
|
106
|
+
/** The build argv, run through the adopter's package manager before `wrangler deploy`. */
|
|
107
|
+
buildCommand: string[];
|
|
108
|
+
/**
|
|
109
|
+
* The `package.json` scripts merged into the target Worker — the adopter's own entry points, for
|
|
110
|
+
* when they reach past `pithy dev`/`pithy deploy` and run the tool directly.
|
|
111
|
+
*
|
|
112
|
+
* Declared by the stub rather than hardcoded by the wiring, because they must not drift from
|
|
113
|
+
* {@link devCommand} and {@link buildCommand}. They carry the same flags for the same reasons: a
|
|
114
|
+
* `build` script missing `--configLoader runner` fails to load `vite.config.ts` at all, and the
|
|
115
|
+
* adopter meets that as a raw Node resolution error on the most ordinary command there is.
|
|
116
|
+
*/
|
|
117
|
+
scripts: Record<string, string>;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Every framework stub `pithy ui add` can scaffold, keyed by its `id`. */
|
|
121
|
+
export const UI_STUBS: Record<string, UiStub> = {
|
|
122
|
+
[reactStub.id]: reactStub,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/** The stub a `<framework>` positional names, or an actionable error pointing at `pithy ui list`. */
|
|
126
|
+
export function resolveStub(id: string): UiStub {
|
|
127
|
+
const stub = UI_STUBS[id];
|
|
128
|
+
if (!stub) {
|
|
129
|
+
throw new NotFoundError({
|
|
130
|
+
message: `No UI stub named "${id}".`,
|
|
131
|
+
action: `Run pithy ui list to see the frameworks you can add. Known: ${Object.keys(UI_STUBS).join(", ")}.`,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return stub;
|
|
135
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
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 } from "@pithy-sh/core/src/error/pithyError";
|
|
7
|
+
import type { UiStub, UiStubContext } from "./stubs";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Turn a stub's declaration into the file contents it writes.
|
|
11
|
+
*
|
|
12
|
+
* A stub declares *what* it writes ({@link UiStub.manifest}) and keeps that pure; this is the one
|
|
13
|
+
* place that reads the template tree off disk. Splitting it that way is what lets the screens be real
|
|
14
|
+
* `.tsx` files — typechecked by CI, linted by Biome, readable in a diff — while the file set a given
|
|
15
|
+
* invocation produces stays assertable as a plain value.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Replace every literal token in `text`. Tokens are fixed strings, never patterns. */
|
|
19
|
+
function substitute(text: string, tokens: Record<string, string>): string {
|
|
20
|
+
let out = text;
|
|
21
|
+
for (const [token, value] of Object.entries(tokens)) out = out.split(token).join(value);
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Read every file a stub writes for this context, keyed by its path relative to `apps/<worker>/`.
|
|
27
|
+
*
|
|
28
|
+
* A missing template is an internal error, not an adopter error: it means this package shipped
|
|
29
|
+
* without part of its own template tree, and no adopter action can fix it.
|
|
30
|
+
*/
|
|
31
|
+
export async function loadStubFiles(stub: UiStub, context: UiStubContext): Promise<Record<string, string>> {
|
|
32
|
+
const tokens = stub.substitutions(context);
|
|
33
|
+
const files: Record<string, string> = {};
|
|
34
|
+
for (const file of stub.manifest(context)) {
|
|
35
|
+
const path = join(stub.templateDir, file.source);
|
|
36
|
+
let text: string;
|
|
37
|
+
try {
|
|
38
|
+
text = await readFile(path, "utf8");
|
|
39
|
+
} catch (cause) {
|
|
40
|
+
throw new InternalError(
|
|
41
|
+
{
|
|
42
|
+
message: `The ${stub.id} front-end template is incomplete.`,
|
|
43
|
+
action: "Reinstall @pithy-sh/cli. This is a packaging fault, not something in your project.",
|
|
44
|
+
detail: `missing template file ${path}`,
|
|
45
|
+
},
|
|
46
|
+
{ cause },
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
files[file.target] = substitute(text, tokens);
|
|
50
|
+
}
|
|
51
|
+
return files;
|
|
52
|
+
}
|