@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,1088 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { lstatSync } from "node:fs";
|
|
5
|
+
import { cp, lstat, mkdir, readdir, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
|
|
6
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { ConflictError, InternalError, PithyError, ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
9
|
+
import { NAMESPACE_PATTERN } from "@pithy-sh/core/src/migrations/registry";
|
|
10
|
+
import { DEFAULT_ENVIRONMENTS } from "@pithy-sh/core/src/naming/environment";
|
|
11
|
+
import {
|
|
12
|
+
assertValidProjectName,
|
|
13
|
+
isReservedProjectName,
|
|
14
|
+
kebab,
|
|
15
|
+
RESERVED_TEST_PREFIX,
|
|
16
|
+
} from "@pithy-sh/core/src/naming/resource";
|
|
17
|
+
import { PACKAGE_NAME, PACKAGE_VERSION } from "@pithy-sh/core/src/version.generated";
|
|
18
|
+
import { errnoOf } from "./atomic";
|
|
19
|
+
import { loadProjectEnvironments } from "./config";
|
|
20
|
+
import { committedFiles } from "./templateFiles";
|
|
21
|
+
import { readWranglerConfig, writeWranglerConfig } from "./wrangler";
|
|
22
|
+
|
|
23
|
+
export interface ScaffoldOptions {
|
|
24
|
+
/** Directory to scaffold into. Created if missing; must hold none of the paths the template writes. */
|
|
25
|
+
targetDir: string;
|
|
26
|
+
/** Application name, written into package.json and wrangler.jsonc. */
|
|
27
|
+
appName: string;
|
|
28
|
+
/** The first worker's name — it lives at `apps/<worker>/`. Defaults to {@link DEFAULT_WORKER}. */
|
|
29
|
+
worker?: string;
|
|
30
|
+
/**
|
|
31
|
+
* The environments this project has (#241). Defaults to {@link DEFAULT_ENVIRONMENTS}, which is what the
|
|
32
|
+
* template ships — so a run that does not pass this writes exactly the bytes it always wrote.
|
|
33
|
+
*/
|
|
34
|
+
environments?: readonly string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Where the starter template sits, relative to this module, in the order the two layouts are tried.
|
|
39
|
+
*
|
|
40
|
+
* **The checkout first**, and only when this module really is inside `packages/cli` of a repo root —
|
|
41
|
+
* see {@link workspaceTemplate}. There the repo root's `templates/starter` is the single source of
|
|
42
|
+
* truth, and preferring it means a `packages/cli/templates/starter` left behind by a pack that failed
|
|
43
|
+
* after `prepack` cannot shadow it. That copy is gitignored, so `git status` says nothing while every
|
|
44
|
+
* later run in the checkout scaffolds from a stale template.
|
|
45
|
+
*
|
|
46
|
+
* The package second: `prepack` vendors the starter in, so an installed `@pithy-sh/cli` carries its own
|
|
47
|
+
* copy at `<package>/templates/starter`. That is the layout an adopter has, and the only one they have.
|
|
48
|
+
*/
|
|
49
|
+
const PACKAGED_LAYOUT = ["..", "..", "templates", "starter"] as const;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The repo root's template, or nothing — and *nothing* unless `moduleDir` sits under that root's
|
|
53
|
+
* `packages/cli`.
|
|
54
|
+
*
|
|
55
|
+
* Four levels up from `src/project` is the repo root in a checkout. In an installed package it is
|
|
56
|
+
* `<node_modules>/templates/starter` — a path owned by any dependency called `templates`, an adopter's
|
|
57
|
+
* own or a squatter's, and `pithy init` would have scaffolded the customer's project out of it. The
|
|
58
|
+
* layout check is what makes that unreachable: `node_modules/@pithy-sh/cli/src/project` is not inside
|
|
59
|
+
* `node_modules/packages/cli`, whatever anybody installs.
|
|
60
|
+
*/
|
|
61
|
+
function workspaceTemplate(moduleDir: string): string | null {
|
|
62
|
+
const root = resolve(moduleDir, "..", "..", "..", "..");
|
|
63
|
+
const within = relative(join(root, "packages", "cli"), moduleDir);
|
|
64
|
+
if (within.length === 0 || within.startsWith("..") || isAbsolute(within)) return null;
|
|
65
|
+
return join(root, "templates", "starter");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Where the starter came from, and — the part that matters — **which of the two layouts it is**.
|
|
70
|
+
*
|
|
71
|
+
* The layout is the only honest way to tell "git could not answer" from "there is nothing to ask": a
|
|
72
|
+
* checkout's `templates/starter` is tracked and always has an index, and the copy `prepack` vendored into
|
|
73
|
+
* the package never does. {@link templateContents} turns that into two different decisions about the same
|
|
74
|
+
* `null`, and getting it from the resolver means nothing has to guess later.
|
|
75
|
+
*/
|
|
76
|
+
export interface TemplateSource {
|
|
77
|
+
/** The starter template directory. */
|
|
78
|
+
dir: string;
|
|
79
|
+
/** True for the copy `prepack` vendored into the package — the layout with no `.git` beside it. */
|
|
80
|
+
vendored: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The starter template, resolved from `moduleDir`, with the layout it was found in.
|
|
85
|
+
*
|
|
86
|
+
* Exported because the only honest way to test this is against an **extracted tarball**, not against
|
|
87
|
+
* the checkout the test runs in. This resolved the repo-root path and nothing else, which exists only
|
|
88
|
+
* in a workspace: a published CLI shipped no template at all and `pithy init` — the first command an
|
|
89
|
+
* adopter runs — could not work. Every scaffold test stayed green, because each one ran from the
|
|
90
|
+
* checkout where the missing path happened to be there.
|
|
91
|
+
*/
|
|
92
|
+
export function resolveTemplateSource(moduleDir: string): TemplateSource {
|
|
93
|
+
const here = resolve(moduleDir);
|
|
94
|
+
const workspace = workspaceTemplate(here);
|
|
95
|
+
const candidates: TemplateSource[] = [
|
|
96
|
+
...(workspace === null ? [] : [{ dir: workspace, vendored: false }]),
|
|
97
|
+
{ dir: resolve(here, ...PACKAGED_LAYOUT), vendored: true },
|
|
98
|
+
];
|
|
99
|
+
// `lstatSync`, not `existsSync`, and the reason is the module's own rule rather than a threat model:
|
|
100
|
+
// this file writes, and a writing module answers "is something at this path" one way — about the path
|
|
101
|
+
// itself. An exception here would be an exception somebody has to remember, and the escape this module
|
|
102
|
+
// exists to stop has five producers because nobody did. `throwIfNoEntry` off, so missing is a value.
|
|
103
|
+
for (const candidate of candidates) {
|
|
104
|
+
if (lstatSync(join(candidate.dir, "package.json"), { throwIfNoEntry: false })) return candidate;
|
|
105
|
+
}
|
|
106
|
+
throw new InternalError({
|
|
107
|
+
message: "This pithy install is missing its starter template.",
|
|
108
|
+
action: "Reinstall @pithy-sh/cli. Report it if a fresh install does the same.",
|
|
109
|
+
detail: `no starter template under ${here} at ${candidates.map((candidate) => candidate.dir).join(" or ")}`,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** {@link resolveTemplateSource}, for the callers that only want the path. */
|
|
114
|
+
export function resolveTemplateDir(moduleDir: string): string {
|
|
115
|
+
return resolveTemplateSource(moduleDir).dir;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function templateSource(): TemplateSource {
|
|
119
|
+
return resolveTemplateSource(dirname(fileURLToPath(import.meta.url)));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Every path from `root` (exclusive) down to `target` (inclusive), outermost first.
|
|
124
|
+
*
|
|
125
|
+
* The walk stops at `root` on purpose. `root` is the directory the adopter handed us — a project they may
|
|
126
|
+
* legitimately keep behind a symlink, and not ours to judge. What has to be real is every path *we* invent
|
|
127
|
+
* out of a name below it, because those are the ones an attacker or an accident can aim somewhere else.
|
|
128
|
+
*
|
|
129
|
+
* A `target` outside `root` is a bug at the call site, and it is precisely the bug that would make this
|
|
130
|
+
* walk useless: the loop would climb to the filesystem root having checked nothing anyone meant. So it
|
|
131
|
+
* throws rather than quietly returning a chain nobody asked for.
|
|
132
|
+
*/
|
|
133
|
+
function descent(root: string, target: string): string[] {
|
|
134
|
+
const within = relative(resolve(root), resolve(target));
|
|
135
|
+
if (within.length === 0) return [];
|
|
136
|
+
if (within.startsWith("..") || isAbsolute(within)) {
|
|
137
|
+
throw new InternalError({
|
|
138
|
+
message: "Pithy tried to scaffold outside the project.",
|
|
139
|
+
action: "Report this — the command should not have built that path.",
|
|
140
|
+
detail: `${target} is not under ${root}`,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
const steps: string[] = [];
|
|
144
|
+
let step = resolve(root);
|
|
145
|
+
for (const segment of within.split(sep)) {
|
|
146
|
+
step = join(step, segment);
|
|
147
|
+
steps.push(step);
|
|
148
|
+
}
|
|
149
|
+
return steps;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Throw unless every path between `root` and `target` — `target` itself included — is a real directory or
|
|
154
|
+
* missing. **The one answer to "is this path safe to scaffold into", and every gate routes through it.**
|
|
155
|
+
*
|
|
156
|
+
* This escape has had four producers. `ensureScaffoldable`'s `exists` (#111), `ensureEmptyTarget`'s
|
|
157
|
+
* `readdir` (#147), `scaffoldFiles`' `exists` and `renameWorker`'s `exists` (#152) — four hand-rolled
|
|
158
|
+
* predicates over `access` or a following `readdir`, each asking about *the destination of a link* while
|
|
159
|
+
* the caller then wrote to *the link's path*. Each fix landed in one of them and review found the rest. So
|
|
160
|
+
* the rule lives in one function, and `scaffold.test.ts` fails the build if a writing module rolls another.
|
|
161
|
+
*
|
|
162
|
+
* **`lstat`, and the whole chain of it.** `access` and `existsSync` follow links, so a dangling one reads
|
|
163
|
+
* as missing and clears any gate that asks them; `lstat` answers about the path itself. And asking only
|
|
164
|
+
* about `target` is the half-fix #147 shipped — a link at `apps` carries the scaffold out of the project
|
|
165
|
+
* exactly as completely as a link at `apps/<name>`, and `pithy worker add` walked through it.
|
|
166
|
+
*
|
|
167
|
+
* **Missing stops the walk**, because everything below a missing directory is missing too, and creating it
|
|
168
|
+
* is what the scaffold is for.
|
|
169
|
+
*
|
|
170
|
+
* A non-directory in the way is refused here too, rather than left to blow up later: `mkdir`, `cp` and
|
|
171
|
+
* `rename` all die on it with a raw `node:fs` ENOTDIR — outside the `PithyError` contract `withErrorReporting`
|
|
172
|
+
* prints from and `--json` callers parse — and by then the run is usually half-written.
|
|
173
|
+
*/
|
|
174
|
+
export async function ensureScaffoldPath(root: string, target: string, intent: PathIntent = "write"): Promise<void> {
|
|
175
|
+
for (const step of descent(root, target)) {
|
|
176
|
+
const entry = await probe(lstat(step));
|
|
177
|
+
if (entry.state === "unanswerable") throw cannotSee(root, step, entry.reason);
|
|
178
|
+
if (entry.state === "missing") return; // missing, and so is everything below it
|
|
179
|
+
if (entry.value.isDirectory()) continue;
|
|
180
|
+
|
|
181
|
+
const named = relative(root, step);
|
|
182
|
+
throw new ConflictError(
|
|
183
|
+
entry.value.isSymbolicLink()
|
|
184
|
+
? {
|
|
185
|
+
message: `${named} is a symlink.`,
|
|
186
|
+
action: THROUGH_A_LINK[intent],
|
|
187
|
+
detail: `refusing to reach ${target} through the symlink at ${step}`,
|
|
188
|
+
}
|
|
189
|
+
: {
|
|
190
|
+
message: `${named} isn't a directory.`,
|
|
191
|
+
action: "Move it aside, or pick another name, and run the command again.",
|
|
192
|
+
detail: `refusing to reach ${target} through the non-directory at ${step}`,
|
|
193
|
+
},
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* What the caller is about to do to the path — the only thing the refusals differ by.
|
|
200
|
+
*
|
|
201
|
+
* A delete borrowing the write refusal told the adopter "the files would land outside the project" about a
|
|
202
|
+
* command that was writing nothing, which is the one sentence they would act on and the one that was
|
|
203
|
+
* false. The *rule* is identical for all three, so it stays in one walk; only the sentence moves.
|
|
204
|
+
*/
|
|
205
|
+
type PathIntent = "write" | "delete" | "move";
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The sentence each intent gives an adopter who has a link in the way. One line each, and every one of
|
|
209
|
+
* them has to be true of the command that is asking — see {@link PathIntent}.
|
|
210
|
+
*/
|
|
211
|
+
const THROUGH_A_LINK: Record<PathIntent, string> = {
|
|
212
|
+
write:
|
|
213
|
+
"Pithy won't scaffold through a link — the files would land outside the project. Remove it, or pick another name, and run the command again.",
|
|
214
|
+
delete:
|
|
215
|
+
"Pithy won't delete through a link — the tree removed would be outside the project. Remove the link, or check the name, and run the command again.",
|
|
216
|
+
move: "Pithy won't move through a link — the directory would leave the project, and the files rewritten after the move would land outside it. Remove the link, or check the name, and run the command again.",
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* What a filesystem probe established about a path — **three answers, never two.**
|
|
221
|
+
*
|
|
222
|
+
* The rule is {@link survivorsOf}'s, generalized: **only `ENOENT` means gone.** Anything else the probe
|
|
223
|
+
* cannot answer — `EACCES`, `ELOOP`, a mount that went away — is `unanswerable`, and reading it as gone is
|
|
224
|
+
* how a gate clears a path it never saw and a delete reports a tree it never removed.
|
|
225
|
+
*
|
|
226
|
+
* Exported for the test. Two of the three callers below are reachable only by racing the walk above, and a
|
|
227
|
+
* suite cannot stage that against a real `realpath` — but a gate whose second question can be skipped is a
|
|
228
|
+
* gate that asks one.
|
|
229
|
+
*/
|
|
230
|
+
export type Probed<T> =
|
|
231
|
+
| { readonly state: "answered"; readonly value: T }
|
|
232
|
+
| { readonly state: "missing" }
|
|
233
|
+
| { readonly state: "unanswerable"; readonly reason: string };
|
|
234
|
+
|
|
235
|
+
/** Run a filesystem probe, keeping "it isn't there" apart from "I could not find out". */
|
|
236
|
+
export async function probe<T>(ask: Promise<T>): Promise<Probed<T>> {
|
|
237
|
+
try {
|
|
238
|
+
return { state: "answered", value: await ask };
|
|
239
|
+
} catch (err) {
|
|
240
|
+
const errno = errnoOf(err) ?? "unknown error";
|
|
241
|
+
return errno === "ENOENT" ? { state: "missing" } : { state: "unanswerable", reason: errno };
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The refusal for a path the filesystem would not answer about.
|
|
247
|
+
*
|
|
248
|
+
* One sentence for all three intents, because it is the only one that is true of all three: nothing is
|
|
249
|
+
* claimed about what is at the path, only that we could not find out. The intent-specific wording above
|
|
250
|
+
* describes what Pithy *won't do through a link*, and there is no link here — there is no answer at all.
|
|
251
|
+
*
|
|
252
|
+
* The errno is throw-site context, in `detail`, where the HTTP codec strips it. What the adopter gets is
|
|
253
|
+
* the path and the fact that the command stopped rather than guessed.
|
|
254
|
+
*/
|
|
255
|
+
function cannotSee(root: string, step: string, reason: string): PithyError {
|
|
256
|
+
return new ConflictError({
|
|
257
|
+
message: `Pithy couldn't check ${relative(root, step) || step}.`,
|
|
258
|
+
action:
|
|
259
|
+
"Something blocked the check — a permission, or a mount that went away. Clear that and run the command again.",
|
|
260
|
+
detail: `${reason} while checking ${step}`,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Delete `target` and everything under it — **the one answer to "may this path be removed", and the `rm`
|
|
266
|
+
* is inside it so no caller can route around it.**
|
|
267
|
+
*
|
|
268
|
+
* This escape had two producers and both were deletes: `pithy worker remove` on `apps/<name>` and
|
|
269
|
+
* `pithy remove <capability>` on an ejected `apps/<worker>/capabilities/<cap>`. Reproduced with the real
|
|
270
|
+
* CLI — a symlink at `apps` pointing at a canary directory, and `pithy worker remove board` removed the
|
|
271
|
+
* canary's whole `board/` tree and printed "Done."
|
|
272
|
+
*
|
|
273
|
+
* **The gate is stricter than {@link ensureScaffoldPath}, and it has to be.** Every other producer in this
|
|
274
|
+
* series writes a file somewhere it should not, and recovery is deleting the file; these remove a tree and
|
|
275
|
+
* there is nothing to recover. So two questions are asked rather than one:
|
|
276
|
+
*
|
|
277
|
+
* - Every component between the root and the target is a real directory or missing — the write gate,
|
|
278
|
+
* unchanged, so a link at `apps` or at `apps/<name>` is refused and named.
|
|
279
|
+
* - **And the path actually lands inside the project.** `ensureScaffoldPath` judges components one at a
|
|
280
|
+
* time and stops at the first missing one; this asks the kernel where the whole thing resolves to. A
|
|
281
|
+
* link swapped in between the walk and the `rm` ends here instead of in a recursive delete. The root
|
|
282
|
+
* itself is resolved the same way, because a project kept behind a symlink is the adopter's arrangement
|
|
283
|
+
* and none of our business.
|
|
284
|
+
*
|
|
285
|
+
* **`realpath` resolves symlinks and nothing else, so this is not a containment guarantee.** A bind
|
|
286
|
+
* mount and a hard-linked directory are not links: the kernel resolves such a path to itself, so a bind
|
|
287
|
+
* mount at `apps/` answers *inside the project* while the bytes it covers live anywhere the mounter
|
|
288
|
+
* chose, and the `rm` below follows it out. Telling one apart needs the mount table, which is
|
|
289
|
+
* platform-specific and not portable. Accepted, with the threat model that decides how much it matters:
|
|
290
|
+
* `docs/ACCEPTED-LIMITS.md`, "Bind mounts and hard-linked directories".
|
|
291
|
+
*
|
|
292
|
+
* **The root is never the target.** A gate that permits deleting the directory it is containing to permits
|
|
293
|
+
* everything, and no command here has any business removing the project.
|
|
294
|
+
*
|
|
295
|
+
* A target that is not there is not a delete: `rm` is `force`, so a caller rolling back a step that never
|
|
296
|
+
* ran gets a clean no-op rather than a refusal it would have to special-case. **Not there means `ENOENT`
|
|
297
|
+
* and nothing else** — see {@link Probed}. Both probes here read every other errno as "gone", so a
|
|
298
|
+
* `realpath` the kernel refused returned from this function having removed nothing, and the caller printed
|
|
299
|
+
* success. Through `pithy remove <cap>` it was worse: config and wrangler are unwired first, so the run
|
|
300
|
+
* ended with the capability unwired, its source entire on disk, and an audit record saying
|
|
301
|
+
* `capability/removed`, `outcome: "success"`. A false audit record is the one failure this project cannot
|
|
302
|
+
* treat as cosmetic. Reproduced with `chmod 0600` on `apps/` — readable, not searchable.
|
|
303
|
+
*
|
|
304
|
+
* **And a delete that fails part-way says which part.** See {@link removeFailure}: the `rm` threw a raw
|
|
305
|
+
* `node:fs` errno through the contract, after it had already emptied some of the tree.
|
|
306
|
+
*/
|
|
307
|
+
export async function removeScaffoldPath(root: string, target: string): Promise<void> {
|
|
308
|
+
await ensureScaffoldPath(root, target, "delete");
|
|
309
|
+
|
|
310
|
+
const doomed = await probe(realpath(target));
|
|
311
|
+
if (doomed.state === "unanswerable") throw cannotSee(root, target, doomed.reason);
|
|
312
|
+
if (doomed.state === "missing") return; // nothing there — and `rm` is force, so nothing to do
|
|
313
|
+
|
|
314
|
+
// The root's own resolution, and its failure is a *different* sentence. Swallowed, it made this refusal
|
|
315
|
+
// say "it isn't inside the project" about a target nothing had established anything about — advice to
|
|
316
|
+
// treat a path as hostile, printed because the project directory could not be resolved.
|
|
317
|
+
const anchor = await probe(realpath(root));
|
|
318
|
+
if (anchor.state !== "answered") {
|
|
319
|
+
throw new ConflictError({
|
|
320
|
+
message: `Pithy couldn't resolve the project directory, so ${relative(root, target) || target} was left alone.`,
|
|
321
|
+
action: "Check the project directory is there and readable, then run the command again.",
|
|
322
|
+
detail: `${anchor.state === "missing" ? "ENOENT" : anchor.reason} while resolving ${root}`,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const within = relative(anchor.value, doomed.value);
|
|
327
|
+
if (within.length === 0 || within.startsWith("..") || isAbsolute(within)) {
|
|
328
|
+
throw new ConflictError({
|
|
329
|
+
message: `Refusing to delete ${relative(root, target) || target}: it isn't inside the project.`,
|
|
330
|
+
action: "Check what apps/ points at. If you didn't put it there, treat it as hostile.",
|
|
331
|
+
detail: `${target} resolves to ${doomed.value}, which is not under ${anchor.value}`,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
try {
|
|
336
|
+
await rm(target, { recursive: true, force: true });
|
|
337
|
+
} catch (err) {
|
|
338
|
+
throw await removeFailure(root, target, err);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** How many surviving paths a failed delete names. Enough to see the shape of what is left, not a listing. */
|
|
343
|
+
const SURVIVOR_SAMPLE = 5;
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* The error a `rm` that could not finish should have thrown all along.
|
|
347
|
+
*
|
|
348
|
+
* A recursive delete fails part-way for ordinary reasons — a directory the adopter chmod'd, a file another
|
|
349
|
+
* process holds open, a mount that went read-only — and it fails **after** removing whatever it got to
|
|
350
|
+
* first. What escaped here was the raw `node:fs` errno and its stack: outside the `PithyError` contract
|
|
351
|
+
* `withErrorReporting` prints from and `--json` callers parse, so a CI wrapper got unparseable output, and
|
|
352
|
+
* silent about the half-deleted tree, which is the part the adopter has to act on. A worker directory
|
|
353
|
+
* missing its `src/` and still holding its `wrangler.jsonc` is a worse state than the failure, and nothing
|
|
354
|
+
* said it had happened.
|
|
355
|
+
*
|
|
356
|
+
* So the survivors are read back and named. Best effort — this is already the failure path, and a scan that
|
|
357
|
+
* cannot run must not replace the error with its own.
|
|
358
|
+
*/
|
|
359
|
+
async function removeFailure(root: string, target: string, err: unknown): Promise<PithyError> {
|
|
360
|
+
if (err instanceof PithyError) return err;
|
|
361
|
+
const named = relative(root, target) || target;
|
|
362
|
+
const left = await survivorsOf(target);
|
|
363
|
+
return new InternalError(
|
|
364
|
+
{
|
|
365
|
+
message: `Could not finish deleting ${named}. ${whatSurvived(left)}`,
|
|
366
|
+
action: "Something blocked it — a permission, or a file in use. Clear that and run the command again.",
|
|
367
|
+
// Two errnos when the scan failed too, because they are two different failures and the adopter's
|
|
368
|
+
// copy above deliberately carries neither: the one that stopped the delete, and the one that
|
|
369
|
+
// stopped us finding out what it left.
|
|
370
|
+
detail:
|
|
371
|
+
`${errnoOf(err) ?? "unknown error"} while removing ${target}` +
|
|
372
|
+
(left.state === "unknown" ? `; ${left.reason} while reading it back` : ""),
|
|
373
|
+
},
|
|
374
|
+
{ cause: err },
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* What a failed delete left behind — **three states, never two.**
|
|
380
|
+
*
|
|
381
|
+
* The bug this shape exists to make unrepresentable: one `null` carried both "the target is gone" and
|
|
382
|
+
* "the scan threw", and {@link whatSurvived} rendered the pair as *"Nothing of it is left"*. An unreadable
|
|
383
|
+
* directory fails the `rm` and fails the scan for the same reason, so the one case where the adopter is
|
|
384
|
+
* told least was the case where the whole tree survived — and they were told the opposite of the truth.
|
|
385
|
+
* An error path that lies is worse than the raw errno it replaced: the adopter reads it, moves on, and
|
|
386
|
+
* the half-deleted worker stays on their disk.
|
|
387
|
+
*
|
|
388
|
+
* `unknown` carries the errno so the failure can say *why* it cannot answer, in `detail` where every
|
|
389
|
+
* other throw-site fact in this module lives.
|
|
390
|
+
*/
|
|
391
|
+
export type Survivors =
|
|
392
|
+
| { readonly state: "gone" }
|
|
393
|
+
| { readonly state: "left"; readonly paths: readonly string[] }
|
|
394
|
+
| { readonly state: "unknown"; readonly reason: string };
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Read back what is still under `target`, relative to it.
|
|
398
|
+
*
|
|
399
|
+
* `lstat` first, and it is the whole distinction: only `ENOENT` means gone. That rule is {@link probe}
|
|
400
|
+
* now, shared with the two gates above rather than stated once here — it was written down in this
|
|
401
|
+
* docstring while three sites nine lines up read every errno as "gone", which is how the delete gate came
|
|
402
|
+
* to report a removal it never made. Best effort about *what* is left, never about *whether* something is.
|
|
403
|
+
*
|
|
404
|
+
* A target that is there but not a directory is left with no paths under it: it is still there, which is
|
|
405
|
+
* the fact the adopter acts on.
|
|
406
|
+
*
|
|
407
|
+
* Exported for the test. `gone` needs the target to vanish between the `rm` and this call — a race no
|
|
408
|
+
* suite can stage against a real `rm`, and the only state the old sentence was ever true for.
|
|
409
|
+
*/
|
|
410
|
+
export async function survivorsOf(target: string): Promise<Survivors> {
|
|
411
|
+
const entry = await probe(lstat(target));
|
|
412
|
+
if (entry.state === "missing") return { state: "gone" };
|
|
413
|
+
if (entry.state === "unanswerable") return { state: "unknown", reason: entry.reason };
|
|
414
|
+
if (!entry.value.isDirectory()) return { state: "left", paths: [] };
|
|
415
|
+
try {
|
|
416
|
+
return { state: "left", paths: (await readdir(target, { recursive: true })).sort() };
|
|
417
|
+
} catch (err) {
|
|
418
|
+
return { state: "unknown", reason: errnoOf(err) ?? "unknown error" };
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** The sentence that tells the adopter which half of the tree they are holding. Exported with {@link survivorsOf}. */
|
|
423
|
+
export function whatSurvived(left: Survivors): string {
|
|
424
|
+
if (left.state === "gone") return "Nothing of it is left, but the delete did not report finishing.";
|
|
425
|
+
if (left.state === "unknown") return "Pithy could not read it back, so what is left of it is unknown. Check it.";
|
|
426
|
+
if (left.paths.length === 0) return "It is empty, and still there.";
|
|
427
|
+
const rest = left.paths.length - SURVIVOR_SAMPLE;
|
|
428
|
+
const shown = left.paths.slice(0, SURVIVOR_SAMPLE).join(", ");
|
|
429
|
+
const counted = left.paths.length === 1 ? "1 path is" : `${left.paths.length} paths are`;
|
|
430
|
+
return `${counted} still there: ${shown}${rest > 0 ? `, and ${rest} more` : ""}.`;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Throw unless `targetDir` is missing or a real, empty directory, reached through real directories.
|
|
435
|
+
*
|
|
436
|
+
* This is the guard for a directory **Pithy owns outright** — `apps/<worker>`, which `scaffoldWorker`
|
|
437
|
+
* creates and fills. Nothing else may already live there, so emptiness is the right question. The
|
|
438
|
+
* project root is the adopter's directory and asks a narrower one: see {@link ensureScaffoldable}.
|
|
439
|
+
*
|
|
440
|
+
* Safety is {@link ensureScaffoldPath}'s answer, not this function's, which is the fix #152 asked for:
|
|
441
|
+
* #147 left this gate lstat-ing `apps/<worker>` and nothing above it, so a symlink at `apps` still carried
|
|
442
|
+
* the whole worker outside the project. Emptiness is all that is left here, and {@link occupied} asks it.
|
|
443
|
+
*
|
|
444
|
+
* The gate also has to run *before* anything is created. `addWorker` rolls `apps/<worker>` back on failure,
|
|
445
|
+
* and `rm` unlinks a symlink rather than its destination — so escaped files would have stayed outside the
|
|
446
|
+
* project while the command reported a clean rollback. Refusing first puts that path out of reach, which is
|
|
447
|
+
* why `scaffoldWorker` calls this before its `mkdir` and not after.
|
|
448
|
+
*/
|
|
449
|
+
export async function ensureEmptyTarget(root: string, targetDir: string): Promise<void> {
|
|
450
|
+
await ensureScaffoldPath(root, targetDir);
|
|
451
|
+
if (!(await occupied(targetDir))) return;
|
|
452
|
+
throw new ConflictError({
|
|
453
|
+
message: `${targetDir} isn't an empty directory.`,
|
|
454
|
+
action: "Move what's there aside, or pick another name, and run the command again.",
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* True if anything is at `path` — including a symlink whose target is gone.
|
|
460
|
+
*
|
|
461
|
+
* `lstat`, not `access`, for the reason {@link blocksDirectory} gives: the link itself is the thing in
|
|
462
|
+
* the way. `access` follows it, so a **dangling** symlink at a template file path answered "does not
|
|
463
|
+
* exist", cleared the gate, and was never named in the refusal — and then `cp` and `stampPackageName`
|
|
464
|
+
* both wrote *through* the link, landing the scaffolded file outside `targetDir` while the run reported
|
|
465
|
+
* success. Node and Bun do not even agree on that copy, which makes it worse rather than narrower: the
|
|
466
|
+
* unit tests and the shipped CLI would answer differently on one input.
|
|
467
|
+
*
|
|
468
|
+
* Exported for the same reason {@link ensureScaffoldPath} is: `scaffoldFiles` and `renameWorker` each had
|
|
469
|
+
* their own copy of this over `access`, and each got it wrong. One predicate, one place to get it wrong.
|
|
470
|
+
*/
|
|
471
|
+
export async function pathExists(path: string): Promise<boolean> {
|
|
472
|
+
try {
|
|
473
|
+
await lstat(path);
|
|
474
|
+
return true;
|
|
475
|
+
} catch {
|
|
476
|
+
return false;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* True if `path` is anything other than a real directory — a file, or a symlink, even one pointing at a
|
|
482
|
+
* directory. Missing is fine: the scaffold creates it.
|
|
483
|
+
*
|
|
484
|
+
* `lstat`, not `stat`, because the symlink itself is the problem. `cp` refuses to copy a directory onto
|
|
485
|
+
* a symlinked one (`ERR_FS_CP_DIR_TO_NON_DIR`) and `rename` onto one is `ENOTDIR`, so following the link
|
|
486
|
+
* would answer a question nobody asked.
|
|
487
|
+
*/
|
|
488
|
+
async function blocksDirectory(path: string): Promise<boolean> {
|
|
489
|
+
try {
|
|
490
|
+
return !(await lstat(path)).isDirectory();
|
|
491
|
+
} catch {
|
|
492
|
+
return false;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* True unless `path` is missing or a real, empty directory — the question to ask of a path Pithy takes
|
|
498
|
+
* over outright. {@link ensureEmptyTarget} is that question asked of `apps/<worker>` under
|
|
499
|
+
* `pithy worker add`; `ensureScaffoldable` asks it of the rename source and destination.
|
|
500
|
+
*
|
|
501
|
+
* `lstat` first, and that is the security half: a symlink is not a directory here however empty its
|
|
502
|
+
* destination reads, because the scaffold would write through it and land outside the project.
|
|
503
|
+
*
|
|
504
|
+
* Two `try`s, because the two calls fail for opposite reasons. A missing directory is nothing to take
|
|
505
|
+
* over; a directory that cannot be *read* is certainly occupied. The read used to sit outside any `try`
|
|
506
|
+
* at all, so an unreadable `apps/<worker>` threw a raw `node:fs` error straight through the `PithyError`
|
|
507
|
+
* contract this module and `withErrorReporting` both promise — `pithy init --json` printed a stack trace
|
|
508
|
+
* where a CI wrapper parses `{"error":{…}}`.
|
|
509
|
+
*/
|
|
510
|
+
async function occupied(path: string): Promise<boolean> {
|
|
511
|
+
try {
|
|
512
|
+
if (!(await lstat(path)).isDirectory()) return true;
|
|
513
|
+
} catch {
|
|
514
|
+
return false; // missing — nothing to take over
|
|
515
|
+
}
|
|
516
|
+
try {
|
|
517
|
+
return (await readdir(path)).length > 0;
|
|
518
|
+
} catch {
|
|
519
|
+
return true; // unreadable — not empty as far as anyone can tell, and not ours to take
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Template files that land under a different name, source → target.
|
|
525
|
+
*
|
|
526
|
+
* Two files cannot ship under the name they land as. `gitignore` because npm strips dotfiles from a
|
|
527
|
+
* published package. `biome.template.jsonc` because Biome discovers `biome.jsonc` by name and refuses a
|
|
528
|
+
* nested one inside a repository that already has a root config — shipping it as-is broke *this* repo's
|
|
529
|
+
* own `biome check .`, which is a fair warning about what it would do inside any monorepo that vendored
|
|
530
|
+
* the template.
|
|
531
|
+
*/
|
|
532
|
+
export const RENAMED_ON_LANDING: Record<string, string> = {
|
|
533
|
+
gitignore: ".gitignore",
|
|
534
|
+
"biome.template.jsonc": "biome.jsonc",
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* What the starter is allowed to hand an adopter: **its committed files, and the directories holding
|
|
539
|
+
* them** — relative to the template, sorted.
|
|
540
|
+
*
|
|
541
|
+
* The index is the allowlist, for the reason {@link committedFiles} gives. `pithy init` copied the
|
|
542
|
+
* template directory wholesale, so from a checkout it also copied whatever the maintainer's working tree
|
|
543
|
+
* happened to hold — and it held `.dev.vars`, the file `pithy add` and `pithy token mint` write
|
|
544
|
+
* `CLOUDFLARE_API_TOKEN` and `SECRETS_ENCRYPTION_KEYS` into. Reproduced: a maintainer's live token in a
|
|
545
|
+
* stranger's brand-new project, mode 0664 because `cp` copies the source's, and nothing downstream
|
|
546
|
+
* looked twice at a `.dev.vars` that was already there. `git status` said nothing: the file is ignored.
|
|
547
|
+
*
|
|
548
|
+
* #145 read the index for the *published tarball* and stopped at the packer. This is the same rule for
|
|
549
|
+
* the other reader of the same directory.
|
|
550
|
+
*
|
|
551
|
+
* **No index is two different facts, and only one of them is safe.** `committedFiles` answers `null` for
|
|
552
|
+
* "this is not a checkout" *and* for "git is not installed", "the repository is broken", "nothing here is
|
|
553
|
+
* tracked yet". #145's fix read them all as the first, so on a real checkout where git failed for any
|
|
554
|
+
* reason at all, `pithy init` went straight back to copying whatever the maintainer's working tree held —
|
|
555
|
+
* `.dev.vars` included — silently, and only on the machines where that is hardest to notice.
|
|
556
|
+
*
|
|
557
|
+
* {@link TemplateSource.vendored} is what tells them apart, and it comes from the layout rather than from
|
|
558
|
+
* a probe: the copy an installed `@pithy-sh/cli` carries was built by `prepack` from this same allowlist
|
|
559
|
+
* and has no `.git` beside it to ask, so it is taken as it stands. A checkout's template is tracked and
|
|
560
|
+
* always has an index — so `null` there means the question failed, and the run refuses rather than
|
|
561
|
+
* guessing. Refusing the vendored case too would break `pithy init` for every adopter to protect a
|
|
562
|
+
* checkout none of them have; excusing the checkout case is how the leak came back.
|
|
563
|
+
*
|
|
564
|
+
* Exported for the test: the branch that matters is the one that only happens on a machine where git is
|
|
565
|
+
* broken, and a suite that runs in a healthy checkout can never reach it any other way.
|
|
566
|
+
*/
|
|
567
|
+
export async function templateContents(source: TemplateSource): Promise<{ files: string[]; directories: string[] }> {
|
|
568
|
+
const committed = committedFiles(source.dir);
|
|
569
|
+
if (committed !== null) {
|
|
570
|
+
const directories = new Set<string>();
|
|
571
|
+
for (const path of committed) {
|
|
572
|
+
for (let parent = dirname(path); parent !== "."; parent = dirname(parent)) directories.add(parent);
|
|
573
|
+
}
|
|
574
|
+
return { files: committed, directories: [...directories].sort() };
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
if (!source.vendored) {
|
|
578
|
+
throw new InternalError({
|
|
579
|
+
message: "Pithy couldn't ask git what the starter template ships.",
|
|
580
|
+
action:
|
|
581
|
+
"Install git and run this from a complete checkout — or install @pithy-sh/cli, which carries its own template. Pithy won't copy the template directory unread.",
|
|
582
|
+
detail: `git listed nothing committed under ${source.dir}, and that path is a checkout's template, not a vendored one`,
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const entries = await readdir(source.dir, { recursive: true, withFileTypes: true });
|
|
587
|
+
const named = entries.map((entry) => ({
|
|
588
|
+
path: relative(source.dir, join(entry.parentPath, entry.name)),
|
|
589
|
+
directory: entry.isDirectory(),
|
|
590
|
+
}));
|
|
591
|
+
return {
|
|
592
|
+
files: named.filter((entry) => !entry.directory).map(({ path }) => path),
|
|
593
|
+
directories: named.filter((entry) => entry.directory).map(({ path }) => path),
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Every path {@link scaffoldProject} writes, relative to the target — read from the template rather
|
|
599
|
+
* than listed here, so a file added to the starter is covered without anyone remembering to.
|
|
600
|
+
*
|
|
601
|
+
* Files and directories are separated because the two ask different questions. A file that already
|
|
602
|
+
* exists is a clobber. A directory that already exists is fine — `cp` merges into it — but a *file* or a
|
|
603
|
+
* symlink where one belongs kills `mkdir` and `cp` outright, and the gate has to see that before the
|
|
604
|
+
* copy starts rather than halfway through it.
|
|
605
|
+
*
|
|
606
|
+
* Two adjustments, both because the copy is not a straight copy. Each file in {@link RENAMED_ON_LANDING}
|
|
607
|
+
* is checked under **both** names: the copy writes over the shipped name and the rename then moves it
|
|
608
|
+
* away, which destroyed an adopter's own undotted `gitignore` without ever naming it. And the first
|
|
609
|
+
* worker is copied to `apps/api` and *then* renamed, so a run naming another worker also collides on
|
|
610
|
+
* `apps/<worker>`.
|
|
611
|
+
*
|
|
612
|
+
* Nothing is added to the walk. It used to carry `apps/<worker>/.dev.vars`, because the scaffold wrote
|
|
613
|
+
* one there and the template ships only `.dev.vars.example` — so the one path `init` wrote that held
|
|
614
|
+
* secrets was the one path this walk could not see, and a pre-existing worker `.dev.vars` was replaced
|
|
615
|
+
* with a link to a file that was not theirs. `init` writes no `.dev.vars` at all now (#154): each one is
|
|
616
|
+
* generated, and the generator refuses any file it did not write itself, by name.
|
|
617
|
+
*/
|
|
618
|
+
async function templatePaths(worker: string): Promise<{ files: string[]; directories: string[] }> {
|
|
619
|
+
const contents = await templateContents(templateSource());
|
|
620
|
+
|
|
621
|
+
const files = contents.files.flatMap((path) => {
|
|
622
|
+
const landed = RENAMED_ON_LANDING[path];
|
|
623
|
+
return landed ? [path, landed] : [path];
|
|
624
|
+
});
|
|
625
|
+
const directories = [...contents.directories];
|
|
626
|
+
if (worker === DEFAULT_WORKER) return { files, directories };
|
|
627
|
+
|
|
628
|
+
const from = `apps${sep}${DEFAULT_WORKER}${sep}`;
|
|
629
|
+
const rename = (paths: string[]): string[] =>
|
|
630
|
+
paths.filter((path) => path.startsWith(from)).map((path) => `apps${sep}${worker}${sep}${path.slice(from.length)}`);
|
|
631
|
+
return {
|
|
632
|
+
files: [...files, ...rename(files)],
|
|
633
|
+
directories: [...directories, `apps${sep}${worker}`, ...rename(directories)],
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Copy the starter into `targetDir` — every path {@link templateContents} allows, and no other.
|
|
639
|
+
*
|
|
640
|
+
* The filter is the whole point: `cp` with `recursive` and nothing else copied the directory as it sits
|
|
641
|
+
* on the maintainer's disk. It runs on directories too, and a `false` there skips the subtree, so every
|
|
642
|
+
* ancestor of an allowed file has to be allowed with it — which is what {@link templateContents} returns
|
|
643
|
+
* the second list for.
|
|
644
|
+
*
|
|
645
|
+
* Held to the same allowlist {@link templatePaths} builds the collision gate from, and that is not a
|
|
646
|
+
* coincidence worth leaving to chance: a gate that refuses over a file the copy would never write is a
|
|
647
|
+
* `pithy init` that cannot run in a directory it has no quarrel with.
|
|
648
|
+
*/
|
|
649
|
+
async function copyTemplate(targetDir: string): Promise<void> {
|
|
650
|
+
const source = templateSource();
|
|
651
|
+
const contents = await templateContents(source);
|
|
652
|
+
const allowed = new Set([...contents.files, ...contents.directories]);
|
|
653
|
+
await cp(source.dir, targetDir, {
|
|
654
|
+
recursive: true,
|
|
655
|
+
filter: (candidate) => {
|
|
656
|
+
const path = relative(source.dir, candidate);
|
|
657
|
+
return path.length === 0 || allowed.has(path);
|
|
658
|
+
},
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Throw if the target already holds anything `pithy init` would write, naming what.
|
|
664
|
+
*
|
|
665
|
+
* **Collision, not emptiness.** A directory holding only `.git`, a README, a license, a CLAUDE.md, or an
|
|
666
|
+
* editor config is not a project — and refusing it meant `pithy init` could not scaffold into a repo the
|
|
667
|
+
* adopter had just cloned, which is how projects normally start. What actually protects them is the
|
|
668
|
+
* narrower question: is anything I am about to write already there. That still refuses to clobber a real
|
|
669
|
+
* project, and stops caring about the rest.
|
|
670
|
+
*
|
|
671
|
+
* **Except where the scaffold moves rather than copies.** Naming a worker other than the default makes
|
|
672
|
+
* `scaffoldProject` rename `apps/api` onto `apps/<worker>`, and a rename is not a merge: it fails on an
|
|
673
|
+
* occupied destination and carries an occupied source wholesale into the new name. So those two paths
|
|
674
|
+
* are held to emptiness, not to collision. Get that wrong and the run dies on a raw `ENOTEMPTY` from
|
|
675
|
+
* `node:fs` — after the copy, with the root half-written, and outside the `PithyError` contract every
|
|
676
|
+
* other refusal here honors.
|
|
677
|
+
*
|
|
678
|
+
* The precondition `pithy init` checks *before* prompting, so a doomed run fails fast instead of after
|
|
679
|
+
* the user answers. A missing directory passes — `scaffoldProject` creates it, and re-checks, so the
|
|
680
|
+
* guard holds even called direct. The worker name is validated first, because every path below is built
|
|
681
|
+
* out of it and an illegal one would send the probe walking outside the project.
|
|
682
|
+
*/
|
|
683
|
+
export async function ensureScaffoldable(targetDir: string, worker?: string): Promise<void> {
|
|
684
|
+
const name = worker ?? DEFAULT_WORKER;
|
|
685
|
+
assertWorkerName(name);
|
|
686
|
+
const { files, directories } = await templatePaths(name);
|
|
687
|
+
|
|
688
|
+
const collisions = new Set<string>();
|
|
689
|
+
for (const path of files) {
|
|
690
|
+
if (await pathExists(join(targetDir, path))) collisions.add(path);
|
|
691
|
+
}
|
|
692
|
+
for (const path of directories) {
|
|
693
|
+
if (await blocksDirectory(join(targetDir, path))) collisions.add(path);
|
|
694
|
+
}
|
|
695
|
+
if (name !== DEFAULT_WORKER) {
|
|
696
|
+
for (const path of [join("apps", DEFAULT_WORKER), join("apps", name)]) {
|
|
697
|
+
if (await occupied(join(targetDir, path))) collisions.add(path);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
if (collisions.size === 0) return;
|
|
702
|
+
throw new ConflictError({
|
|
703
|
+
message: `${targetDir} already has ${[...collisions].sort().join(", ")}.`,
|
|
704
|
+
action: "Move those aside, or pick a directory without them. Run pithy init again.",
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Refuse a project name inside the reserved test namespace, before anything is written.
|
|
710
|
+
*
|
|
711
|
+
* This is the one place the reservation is enforced. Every provisioned name leads with the project
|
|
712
|
+
* (`<project>-<env>-<thing>`, project verbatim), so a project outside the namespace cannot generate a
|
|
713
|
+
* name inside it — and the debris reaper, which deletes on that prefix alone, can never reach a real
|
|
714
|
+
* project's resources. Every future capability inherits the guarantee for free.
|
|
715
|
+
*
|
|
716
|
+
* **Creation only, never resolution.** The suites are *meant* to run as `pithy-int-test`, so every
|
|
717
|
+
* resolver — `requireProjectName` included — must keep accepting the name. It is only minting a new
|
|
718
|
+
* project under it that is refused.
|
|
719
|
+
*
|
|
720
|
+
* The predicate is {@link isReservedProjectName}, in `@pithy-sh/core` beside the composer, because the
|
|
721
|
+
* comparison it makes is a fact about how names are composed rather than about scaffolding.
|
|
722
|
+
*/
|
|
723
|
+
function assertNotReserved(appName: string): void {
|
|
724
|
+
if (!isReservedProjectName(appName)) return;
|
|
725
|
+
throw new ValidationError({
|
|
726
|
+
message: `"${RESERVED_TEST_PREFIX}" is reserved — Pithy's integration tests own that name, and their cleanup deletes everything under it.`,
|
|
727
|
+
action: "Pick a project name that doesn't start with pithy-int-. Run pithy init again.",
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/** The Worker `pithy init` scaffolds first. Every Worker lives in `apps/<name>/`; this is just the default one. */
|
|
732
|
+
/**
|
|
733
|
+
* The scaffolded app capability's name — which is also its **migration namespace**, and namespaces admit no
|
|
734
|
+
* separators (`NAMESPACE_PATTERN`, `^[a-z][a-z0-9]*$`). A worker directory is kebab-case, so the two cannot
|
|
735
|
+
* be the same string: stamping `admin-api` verbatim writes a config whose first migration is rejected.
|
|
736
|
+
*
|
|
737
|
+
* So the directory stays kebab-case and the namespace is derived from it — hyphens dropped, keeping the
|
|
738
|
+
* worker's identity (`admin-api` → `adminapi`, distinct from every sibling's). A name that starts with a
|
|
739
|
+
* digit cannot open a namespace, so it takes the `app` prefix the starter's own capability uses
|
|
740
|
+
* (`2fa-api` → `app2faapi`).
|
|
741
|
+
*/
|
|
742
|
+
export function workerNamespace(name: string): string {
|
|
743
|
+
const stripped = name.replace(/[^a-z0-9]/g, "");
|
|
744
|
+
return NAMESPACE_PATTERN.test(stripped) ? stripped : `app${stripped}`;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
export const DEFAULT_WORKER = "api";
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* A worker name is a kebab-case directory under `apps/` — the same shape a package name takes.
|
|
751
|
+
*
|
|
752
|
+
* **Deliberately looser than `NAME_SEGMENT`** (`@pithy-sh/core/src/naming/segment`), which every
|
|
753
|
+
* *Cloudflare* name segment answers to: this one allows a leading digit, because `apps/2fa` is a
|
|
754
|
+
* legitimate directory and a legitimate package name. It never leads a composed name — a worker's
|
|
755
|
+
* script name is `<app>-<worker>` and its feature name is `<project>-f<issue>-<slug>-<worker>`, both
|
|
756
|
+
* of which lead with a letter-leading project — so the strict rule would refuse a name that is legal
|
|
757
|
+
* everywhere it is actually used. Divergence on purpose, not a stale copy.
|
|
758
|
+
*/
|
|
759
|
+
export const WORKER_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* Refuse a worker name that could not be a directory under `apps/`.
|
|
763
|
+
*
|
|
764
|
+
* Called by {@link ensureScaffoldable} as well as by {@link scaffoldProject}, because the gate builds
|
|
765
|
+
* `apps/<worker>/…` out of the name before anything else has looked at it: `--worker ../../etc` had it
|
|
766
|
+
* probing paths outside the project and reporting the hits back.
|
|
767
|
+
*/
|
|
768
|
+
function assertWorkerName(worker: string): void {
|
|
769
|
+
if (WORKER_NAME.test(worker)) return;
|
|
770
|
+
throw new ValidationError({
|
|
771
|
+
message: `Worker name must be kebab-case (got "${worker}").`,
|
|
772
|
+
action: "Use lowercase words joined by hyphens, e.g. api or admin-api.",
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/** Stamp `appName` into a JSON file's `name` field, preserving the rest. */
|
|
777
|
+
async function stampPackageName(path: string, name: string): Promise<void> {
|
|
778
|
+
const pkg = JSON.parse(await readFile(path, "utf8")) as { name: string };
|
|
779
|
+
pkg.name = name;
|
|
780
|
+
await writeFile(path, `${JSON.stringify(pkg, null, 2)}\n`);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/** The version every package in this workspace carries until Changesets cuts a release. */
|
|
784
|
+
const UNPUBLISHED = "0.0.0";
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* The range a scaffolded Worker should declare for the kit at `version` — or **null**, meaning declare
|
|
788
|
+
* nothing at all.
|
|
789
|
+
*
|
|
790
|
+
* This is the {@link https://github.com/pithy-sh/pithy/issues/112 #112} rule, applied to the template.
|
|
791
|
+
* Nothing under `@pithy-sh/*` is published, so a range is a promise the registry cannot keep: the
|
|
792
|
+
* template's `"^0.0.0"` 404s the very first `bun install` a new project runs, before any of the tooling
|
|
793
|
+
* that would have helped gets to run at all. A project consuming the kit from a checkout resolves it
|
|
794
|
+
* from `node_modules` either way — the same reason `pithy ui add` omits `@pithy-sh/vite` — so the
|
|
795
|
+
* absent line costs that project nothing and the failed install costs it everything.
|
|
796
|
+
*
|
|
797
|
+
* **The day the packages publish this inverts, and the same line handles it.** `version` is core's own,
|
|
798
|
+
* stamped by `scripts/stampVersions.ts` from the package.json Changesets rewrites — so the first release
|
|
799
|
+
* makes it real, the range gets written, and a scaffolded project installs the kit from npm with no code
|
|
800
|
+
* change here. `0.0.0` is not a version anyone releases; it is precisely the marker for "not released",
|
|
801
|
+
* which is why the whole rule fits in one comparison.
|
|
802
|
+
*/
|
|
803
|
+
export function kitRange(version: string): string | null {
|
|
804
|
+
return version === UNPUBLISHED ? null : `^${version}`;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* What to tell an adopter whose freshly written manifest declares no `@pithy-sh/*` dependency — or
|
|
809
|
+
* **null**, meaning there is nothing to say because the range was written.
|
|
810
|
+
*
|
|
811
|
+
* Three commands scaffold a manifest {@link kitRange} can drop a line from, and all three must say the
|
|
812
|
+
* same thing: `pithy init` (the worker's `@pithy-sh/core`), `pithy worker add` (the same), and
|
|
813
|
+
* `pithy ui add` (`@pithy-sh/vite`). Only `init` did. The other two dropped the line and then said
|
|
814
|
+
* "Done." — `worker add` scaffolding a `src/index.ts` that imports a package its `package.json` does not
|
|
815
|
+
* declare, `ui add` following up with "Install the packages: npm install. Then pithy dev.", which
|
|
816
|
+
* installs cleanly and then fails the build on a Vite plugin nothing asked for. A silent gap is worse
|
|
817
|
+
* than a loud one: the adopter meets it as an unresolved import on an unrelated command.
|
|
818
|
+
*
|
|
819
|
+
* One function rather than three literals, because the wording is the contract. Lines rather than a
|
|
820
|
+
* string, so each caller dims and places them itself.
|
|
821
|
+
*/
|
|
822
|
+
export function unpublishedKitNotice(): string[] | null {
|
|
823
|
+
if (kitRange(PACKAGE_VERSION) !== null) return null;
|
|
824
|
+
return [
|
|
825
|
+
"@pithy-sh/* isn't published yet, so this worker declares no kit dependency.",
|
|
826
|
+
"Link the kit from a checkout, then install.",
|
|
827
|
+
];
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* Stamp the scaffolded Worker's manifest: the package name, and the kit dependency at a range that can
|
|
832
|
+
* actually resolve ({@link kitRange}).
|
|
833
|
+
*
|
|
834
|
+
* One read-modify-write for both, because they are one file — a second pass over it is a second chance to
|
|
835
|
+
* leave it half-stamped.
|
|
836
|
+
*
|
|
837
|
+
* Only core is touched, keyed on the name core reports for itself rather than a literal. Changesets
|
|
838
|
+
* versions these packages independently (`.changeset/config.json` links and fixes nothing), so core's
|
|
839
|
+
* version is a fact about core alone and there is no honest range to invent for a sibling from it.
|
|
840
|
+
* `scaffold.test.ts` holds the template to exactly that: declare a second `@pithy-sh/*` dependency and it
|
|
841
|
+
* fails until this function is taught that package's version.
|
|
842
|
+
*/
|
|
843
|
+
async function stampWorkerManifest(path: string, name: string): Promise<void> {
|
|
844
|
+
const pkg = JSON.parse(await readFile(path, "utf8")) as { name: string; dependencies?: Record<string, string> };
|
|
845
|
+
pkg.name = name;
|
|
846
|
+
const range = kitRange(PACKAGE_VERSION);
|
|
847
|
+
if (pkg.dependencies && PACKAGE_NAME in pkg.dependencies) {
|
|
848
|
+
if (range === null) delete pkg.dependencies[PACKAGE_NAME];
|
|
849
|
+
else pkg.dependencies[PACKAGE_NAME] = range;
|
|
850
|
+
}
|
|
851
|
+
await writeFile(path, `${JSON.stringify(pkg, null, 2)}\n`);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* The commented line the starter's root config ships, and the one this replaces when a project declares
|
|
856
|
+
* something other than the default. Matched literally: a scaffold that guesses at the shape of a file is
|
|
857
|
+
* how a scaffold eats an edit, and a miss here leaves the template's own comment rather than a wrong line.
|
|
858
|
+
*/
|
|
859
|
+
const ENVIRONMENTS_PLACEHOLDER = ` // environments: ${renderEnvironments(DEFAULT_ENVIRONMENTS)},`;
|
|
860
|
+
|
|
861
|
+
/** `["staging", "prod"]` — the form an adopter would type, not `JSON.stringify`'s comma-tight one. */
|
|
862
|
+
function renderEnvironments(environments: readonly string[]): string {
|
|
863
|
+
return `[${environments.map((environment) => JSON.stringify(environment)).join(", ")}]`;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* The `environments` line for the scaffolded root config — **uncommented only when it says something**.
|
|
868
|
+
*
|
|
869
|
+
* A project on the default pair gets the template's commented example, because a declaration that repeats
|
|
870
|
+
* the default is a line an adopter has to read and learn nothing from. A project that answered the prompt
|
|
871
|
+
* differently gets a real declaration, because from that moment the file is the only place the answer
|
|
872
|
+
* lives and every command reads it back.
|
|
873
|
+
*/
|
|
874
|
+
function renderEnvironmentsBlock(environments: readonly string[]): string {
|
|
875
|
+
const isDefault =
|
|
876
|
+
environments.length === DEFAULT_ENVIRONMENTS.length &&
|
|
877
|
+
environments.every((environment, index) => environment === DEFAULT_ENVIRONMENTS[index]);
|
|
878
|
+
return isDefault ? ENVIRONMENTS_PLACEHOLDER : ` environments: ${renderEnvironments(environments)},`;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Rewrite the first Worker's `env.<name>` stanzas to be the project's declared environments (#241).
|
|
883
|
+
*
|
|
884
|
+
* ## Why `init` still writes stanzas at all
|
|
885
|
+
*
|
|
886
|
+
* #241 proposes the invariant *a stanza exists if and only if that environment is provisioned*, with
|
|
887
|
+
* provisioning as the only writer. That is the right end state and it is #240's to build. It is not
|
|
888
|
+
* something this can do half of, because **`pithy add <capability>` writes a capability's bindings into
|
|
889
|
+
* the stanzas that already exist and creates none** (`capabilities/add.ts`). Ship a fresh project with no
|
|
890
|
+
* `env.*` and the very next `pithy add auth` binds `dev` and silently leaves staging and prod unbound —
|
|
891
|
+
* a worse silence than the one this issue set out to close, and one nothing would report.
|
|
892
|
+
*
|
|
893
|
+
* So the stanzas stay, and what changes is where their *names* come from: the declaration, rather than
|
|
894
|
+
* whichever two the template happened to ship. The bindings inside them stay empty, which is the honest
|
|
895
|
+
* part — an empty `d1_databases` claims nothing, where a `database_name` with no `database_id` asserts a
|
|
896
|
+
* database that has never existed.
|
|
897
|
+
*
|
|
898
|
+
* Nothing here deletes a stanza on an existing project: this runs once, on a directory `init` just
|
|
899
|
+
* created, and #142's rule — an adopter's stanza is theirs — is untouched.
|
|
900
|
+
*/
|
|
901
|
+
async function stampEnvironmentStanzas(
|
|
902
|
+
workerDir: string,
|
|
903
|
+
identity: { project: string; worker: string; environments: readonly string[] },
|
|
904
|
+
): Promise<void> {
|
|
905
|
+
const config = (await readWranglerConfig(workerDir)) as { env?: Record<string, unknown> };
|
|
906
|
+
const declared = [...identity.environments];
|
|
907
|
+
// The template already ships the default pair, comments and all. Rewriting it to produce the same
|
|
908
|
+
// names would only cost the prose that explains them.
|
|
909
|
+
if (JSON.stringify(Object.keys(config.env ?? {})) === JSON.stringify(declared)) return;
|
|
910
|
+
// The template's note above `env` names staging and production. For any other set it would be a false
|
|
911
|
+
// sentence in a checked-in file, which is the failure mode this whole issue is about.
|
|
912
|
+
delete (config as Record<symbol, unknown>)[Symbol.for("before:env")];
|
|
913
|
+
config.env = Object.fromEntries(
|
|
914
|
+
declared.map((environment) => [
|
|
915
|
+
environment,
|
|
916
|
+
{
|
|
917
|
+
// All three repeat per stanza: `env.<name>.vars` REPLACES the top-level block, never merges it.
|
|
918
|
+
vars: { ENVIRONMENT: environment, PROJECT: identity.project, WORKER: identity.worker },
|
|
919
|
+
d1_databases: [],
|
|
920
|
+
kv_namespaces: [],
|
|
921
|
+
},
|
|
922
|
+
]),
|
|
923
|
+
);
|
|
924
|
+
await writeWranglerConfig(workerDir, config);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* Copy the starter template into `targetDir` and stamp the app name — the pure logic behind `pithy init`.
|
|
929
|
+
*
|
|
930
|
+
* The scaffold is the `apps/` layout: the root carries project identity and policy (`pithy.config.ts`,
|
|
931
|
+
* `package.json` with the `apps/*` workspace), and the first Worker lives in `apps/api/` with its own
|
|
932
|
+
* `pithy.config.ts`, `wrangler.jsonc`, and `pithy.worker.jsonc`. There is no root Worker — `pithy worker add`
|
|
933
|
+
* is then purely additive, and each Worker's capabilities, bindings, and DO class migrations attach to it.
|
|
934
|
+
*
|
|
935
|
+
* It also carries the project's gates — a root `tsconfig.json` solution file, a split Vitest config, a
|
|
936
|
+
* Biome config, and the `typecheck`/`test`/`lint` scripts that run them. A scaffold that can be deployed
|
|
937
|
+
* but not checked is a scaffold whose adopter builds the checking themselves, every time.
|
|
938
|
+
*
|
|
939
|
+
* Two files land under a different name than they ship as — see {@link RENAMED_ON_LANDING}.
|
|
940
|
+
*/
|
|
941
|
+
export async function scaffoldProject(options: ScaffoldOptions): Promise<void> {
|
|
942
|
+
// Both name guards run before the directory is created, let alone copied into: a refusal must leave
|
|
943
|
+
// nothing behind. Legality first — an illegal name is not a name, reserved or not.
|
|
944
|
+
//
|
|
945
|
+
// `assertValidProjectName` is here rather than only downstream because the namespaces disagree and the
|
|
946
|
+
// permissive ones come first. A digit-leading project scaffolds, adds capabilities, and provisions real
|
|
947
|
+
// D1, KV, and R2; it is the first host-worker deploy that refuses it, and by then renaming the project —
|
|
948
|
+
// the only fix — orphans everything already created. The one moment it costs nothing is this one.
|
|
949
|
+
assertValidProjectName(options.appName);
|
|
950
|
+
assertNotReserved(options.appName);
|
|
951
|
+
|
|
952
|
+
// The declaration is checked with the names, before the directory exists: an environment name reaches
|
|
953
|
+
// Cloudflare resource names verbatim, and `init` is the one moment a project can still be told no.
|
|
954
|
+
// Through the config loader, so a name refused here is refused with the same sentence `loadProject`
|
|
955
|
+
// would give it the next time anyone opened the file.
|
|
956
|
+
const environments = loadProjectEnvironments({ environments: options.environments });
|
|
957
|
+
|
|
958
|
+
/**
|
|
959
|
+
* The one form of the name that gets written anywhere.
|
|
960
|
+
*
|
|
961
|
+
* `assertValidProjectName` accepts what `kebab` *would* normalize to a legal segment, so `Acme` clears
|
|
962
|
+
* it — and every command that later composes a resource name reads the project back through
|
|
963
|
+
* `requireProjectName`, which kebabs. Stamping the raw string would therefore write a project name no
|
|
964
|
+
* resource carries, and one Cloudflare refuses outright: wrangler rejects `"name": "Acme-api"` at
|
|
965
|
+
* config-parse time ("alphanumeric and lowercase with dashes only"), so an uppercase `--name` scaffolded
|
|
966
|
+
* a project that could not deploy or even run `wrangler dev`.
|
|
967
|
+
*
|
|
968
|
+
* Normalizing here rather than at each stamp is what keeps the config honest: what the adopter reads in
|
|
969
|
+
* `pithy.config.ts` is the exact first segment of every Cloudflare resource this project provisions.
|
|
970
|
+
*/
|
|
971
|
+
const project = kebab(options.appName);
|
|
972
|
+
|
|
973
|
+
// The template ships its first worker as `apps/<DEFAULT_WORKER>`; rename it when the caller chose
|
|
974
|
+
// another name, so the directory, the deploy name, and the capability namespace all agree. Resolved
|
|
975
|
+
// *before* the collision check, because the check has to know which `apps/<name>` the copy ends at.
|
|
976
|
+
const worker = options.worker ?? DEFAULT_WORKER;
|
|
977
|
+
assertWorkerName(worker);
|
|
978
|
+
|
|
979
|
+
await mkdir(options.targetDir, { recursive: true });
|
|
980
|
+
await ensureScaffoldable(options.targetDir, worker);
|
|
981
|
+
|
|
982
|
+
await copyTemplate(options.targetDir);
|
|
983
|
+
for (const [shipped, landed] of Object.entries(RENAMED_ON_LANDING)) {
|
|
984
|
+
await rename(join(options.targetDir, shipped), join(options.targetDir, landed));
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
const workerDir = join(options.targetDir, "apps", worker);
|
|
988
|
+
if (worker !== DEFAULT_WORKER) {
|
|
989
|
+
await rename(join(options.targetDir, "apps", DEFAULT_WORKER), workerDir);
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
await stampPackageName(join(options.targetDir, "package.json"), project);
|
|
993
|
+
await stampWorkerManifest(join(workerDir, "package.json"), `${project}-${worker}`);
|
|
994
|
+
|
|
995
|
+
// The project's identity — the prefix every feature resource name derives from.
|
|
996
|
+
const configPath = join(options.targetDir, "pithy.config.ts");
|
|
997
|
+
const config = await readFile(configPath, "utf8");
|
|
998
|
+
// A replacement *function*, not a replacement string, so `$&` and `$1` could never be read as patterns.
|
|
999
|
+
// `project` is kebabed and cannot contain either today; the function stays because it costs nothing and
|
|
1000
|
+
// the guard, not the call site, is what makes that true.
|
|
1001
|
+
await writeFile(
|
|
1002
|
+
configPath,
|
|
1003
|
+
config
|
|
1004
|
+
.replace('name: "pithy-app"', () => `name: "${project}"`)
|
|
1005
|
+
.replace(ENVIRONMENTS_PLACEHOLDER, () => renderEnvironmentsBlock(environments)),
|
|
1006
|
+
);
|
|
1007
|
+
|
|
1008
|
+
// Three stamps into the worker's wrangler.jsonc. `name` is the deploy name (project + worker);
|
|
1009
|
+
// `PROJECT` is the project alone; `WORKER` is this Worker's own directory name. `PROJECT` and the
|
|
1010
|
+
// deploy name are the kebabed form — the string `requireProjectName` hands every command that composes
|
|
1011
|
+
// a `<project>-<env>-<thing>` name. A `PROJECT` that differed would attribute the Worker's
|
|
1012
|
+
// Images/Stream assets to a project no sweep filters on, and a `name` that differed would not deploy.
|
|
1013
|
+
//
|
|
1014
|
+
// `WORKER` is keyed off `DEFAULT_WORKER` rather than a literal, because the template ships that name
|
|
1015
|
+
// and the directory has just been renamed to `worker` above — a literal here would be two places to
|
|
1016
|
+
// change and one of them would be forgotten.
|
|
1017
|
+
//
|
|
1018
|
+
// `replaceAll`, because `env.<name>.vars` replaces rather than merges, so each placeholder appears once
|
|
1019
|
+
// per environment stanza and a first-occurrence replace would leave staging and prod owned by `pithy-app`.
|
|
1020
|
+
const wranglerPath = join(workerDir, "wrangler.jsonc");
|
|
1021
|
+
const wrangler = await readFile(wranglerPath, "utf8");
|
|
1022
|
+
await writeFile(
|
|
1023
|
+
wranglerPath,
|
|
1024
|
+
wrangler
|
|
1025
|
+
.replace('"name": "pithy-app"', () => `"name": "${project}-${worker}"`)
|
|
1026
|
+
.replaceAll('"PROJECT": "pithy-app"', () => `"PROJECT": "${project}"`)
|
|
1027
|
+
.replaceAll(`"WORKER": "${DEFAULT_WORKER}"`, () => `"WORKER": "${worker}"`),
|
|
1028
|
+
);
|
|
1029
|
+
|
|
1030
|
+
await stampEnvironmentStanzas(workerDir, { project, worker, environments });
|
|
1031
|
+
|
|
1032
|
+
// The worker's own `pithy.config.ts` was copied verbatim, so on any project not scaffolded with the
|
|
1033
|
+
// default worker it named a worker that does not exist — three times. The header pointed at
|
|
1034
|
+
// `apps/api/`, the comment above the managed region told the adopter to run
|
|
1035
|
+
// `pithy add <capability> --worker api`, and the app capability took the template's flat `"app"` where
|
|
1036
|
+
// `pithy worker add` derives a namespace from the worker's own name.
|
|
1037
|
+
//
|
|
1038
|
+
// `pithy worker add` got all three right by generating the file rather than copying one. Two producers
|
|
1039
|
+
// of the same file disagreeing is the shape #136 and #144 were both about, and `scaffoldParity.test.ts`
|
|
1040
|
+
// now holds these two to each other.
|
|
1041
|
+
const workerConfigPath = join(workerDir, "pithy.config.ts");
|
|
1042
|
+
const workerConfig = await readFile(workerConfigPath, "utf8");
|
|
1043
|
+
await writeFile(
|
|
1044
|
+
workerConfigPath,
|
|
1045
|
+
workerConfig
|
|
1046
|
+
.replaceAll(`apps/${DEFAULT_WORKER}/pithy.config.ts`, () => `apps/${worker}/pithy.config.ts`)
|
|
1047
|
+
.replaceAll(`--worker ${DEFAULT_WORKER}`, () => `--worker ${worker}`)
|
|
1048
|
+
.replace('name: "app"', () => `name: "${workerNamespace(worker)}"`),
|
|
1049
|
+
);
|
|
1050
|
+
|
|
1051
|
+
await stampWorkerPrograms(options.targetDir, workerDir, worker);
|
|
1052
|
+
|
|
1053
|
+
// The worker's `.dev.vars` is generated, not copied and not linked (#154). `pithy init` used to seed
|
|
1054
|
+
// one at the root from the committed example and symlink it into `apps/<worker>/`, which is the design
|
|
1055
|
+
// that produced #137, #139, #142 and #146. Nothing is written here at all: `pithy dev` and `pithy seed`
|
|
1056
|
+
// build each worker's file from the machine-local sources on every run, so a freshly scaffolded project
|
|
1057
|
+
// and a fresh clone of it reach the same state by the same path, with no postinstall and nothing to
|
|
1058
|
+
// remember.
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
/**
|
|
1062
|
+
* Point the solution file at the Worker's real directory, and give its build state a name no sibling
|
|
1063
|
+
* Worker will take.
|
|
1064
|
+
*
|
|
1065
|
+
* Both strings name `apps/<DEFAULT_WORKER>` in the template and both would otherwise survive a rename:
|
|
1066
|
+
* the root `tsconfig.json` would reference a path that no longer exists — `tsc -b` fails outright on that,
|
|
1067
|
+
* so the whole `typecheck` gate would be broken by the one flag that renames the Worker — and every
|
|
1068
|
+
* Worker's `tsBuildInfoFile` would resolve to the same file under the project's `dist/`, where two
|
|
1069
|
+
* composite programs overwriting each other's state makes incremental builds silently wrong.
|
|
1070
|
+
*
|
|
1071
|
+
* Keyed off {@link DEFAULT_WORKER} rather than a literal, for the reason the wrangler stamps above are:
|
|
1072
|
+
* the template ships that name, and a literal here is a second place to change.
|
|
1073
|
+
*/
|
|
1074
|
+
async function stampWorkerPrograms(targetDir: string, workerDir: string, worker: string): Promise<void> {
|
|
1075
|
+
const solutionPath = join(targetDir, "tsconfig.json");
|
|
1076
|
+
const solution = await readFile(solutionPath, "utf8");
|
|
1077
|
+
await writeFile(
|
|
1078
|
+
solutionPath,
|
|
1079
|
+
solution.replaceAll(`./apps/${DEFAULT_WORKER}/`, () => `./apps/${worker}/`),
|
|
1080
|
+
);
|
|
1081
|
+
|
|
1082
|
+
const programPath = join(workerDir, "tsconfig.json");
|
|
1083
|
+
const program = await readFile(programPath, "utf8");
|
|
1084
|
+
await writeFile(
|
|
1085
|
+
programPath,
|
|
1086
|
+
program.replaceAll(`/${DEFAULT_WORKER}.server.tsbuildinfo`, () => `/${worker}.server.tsbuildinfo`),
|
|
1087
|
+
);
|
|
1088
|
+
}
|