@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.
Files changed (234) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +72 -0
  3. package/scripts/templateManifest.ts +49 -0
  4. package/scripts/tsconfig.json +26 -0
  5. package/scripts/vendorTemplate.ts +84 -0
  6. package/scripts/verifyPack.ts +88 -0
  7. package/src/audit/cliAudit.ts +406 -0
  8. package/src/bin.ts +111 -0
  9. package/src/capabilities/add.ts +288 -0
  10. package/src/capabilities/addBootstrap.ts +275 -0
  11. package/src/capabilities/catalog.ts +175 -0
  12. package/src/capabilities/compose.ts +39 -0
  13. package/src/capabilities/configConstants.ts +74 -0
  14. package/src/capabilities/configImports.ts +397 -0
  15. package/src/capabilities/eject.ts +331 -0
  16. package/src/capabilities/emailProvisioner.ts +346 -0
  17. package/src/capabilities/entitlementGap.ts +70 -0
  18. package/src/capabilities/entryExports.ts +162 -0
  19. package/src/capabilities/flow.ts +550 -0
  20. package/src/capabilities/hostRegistry.ts +368 -0
  21. package/src/capabilities/loadFailure.ts +208 -0
  22. package/src/capabilities/manifests.ts +238 -0
  23. package/src/capabilities/mediaProvisioner.ts +471 -0
  24. package/src/capabilities/mintSecrets.ts +306 -0
  25. package/src/capabilities/paymentsProvisioner.ts +207 -0
  26. package/src/capabilities/prerequisites.ts +168 -0
  27. package/src/capabilities/r2Bucket.ts +113 -0
  28. package/src/capabilities/reconcile.ts +1483 -0
  29. package/src/capabilities/remove.ts +597 -0
  30. package/src/capabilities/requiredOptions.ts +92 -0
  31. package/src/capabilities/rotateSecrets.ts +305 -0
  32. package/src/capabilities/secrets.ts +178 -0
  33. package/src/capabilities/secretsDispatcher.ts +29 -0
  34. package/src/capabilities/secretsProvisioner.ts +389 -0
  35. package/src/capabilities/storageProvisioner.ts +414 -0
  36. package/src/capabilities/supportProvisioner.ts +515 -0
  37. package/src/capabilities/testersLoader.ts +52 -0
  38. package/src/capabilities/testersProvisioner.ts +236 -0
  39. package/src/capabilities/turnstileProvisioner.ts +347 -0
  40. package/src/capabilities/vectorProvisioner.ts +260 -0
  41. package/src/ci/fileModes.ts +223 -0
  42. package/src/ci/sourceFiles.ts +200 -0
  43. package/src/ci/workflowDrivers.ts +524 -0
  44. package/src/cloudflare/accountAnswer.ts +110 -0
  45. package/src/cloudflare/config.ts +685 -0
  46. package/src/cloudflare/storeId.ts +129 -0
  47. package/src/commands/add.ts +372 -0
  48. package/src/commands/alias.ts +205 -0
  49. package/src/commands/dashboard.ts +651 -0
  50. package/src/commands/deploy.ts +150 -0
  51. package/src/commands/dev.ts +37 -0
  52. package/src/commands/doctor.ts +2059 -0
  53. package/src/commands/email.ts +425 -0
  54. package/src/commands/env.ts +155 -0
  55. package/src/commands/feature.ts +359 -0
  56. package/src/commands/init.ts +538 -0
  57. package/src/commands/media.ts +303 -0
  58. package/src/commands/migrate.ts +129 -0
  59. package/src/commands/payments.ts +336 -0
  60. package/src/commands/provision.ts +368 -0
  61. package/src/commands/remove.ts +151 -0
  62. package/src/commands/secrets.ts +652 -0
  63. package/src/commands/seed.ts +229 -0
  64. package/src/commands/storage.ts +309 -0
  65. package/src/commands/support.ts +331 -0
  66. package/src/commands/testers.ts +1020 -0
  67. package/src/commands/token.ts +364 -0
  68. package/src/commands/turnstile.ts +271 -0
  69. package/src/commands/ui.ts +222 -0
  70. package/src/commands/upgrade.ts +517 -0
  71. package/src/commands/vector.ts +390 -0
  72. package/src/commands/worker.ts +295 -0
  73. package/src/dashboard/api.ts +323 -0
  74. package/src/dashboard/connect.ts +758 -0
  75. package/src/dashboard/contract.ts +289 -0
  76. package/src/dashboard/grant.ts +124 -0
  77. package/src/dashboard/registry.ts +519 -0
  78. package/src/dashboard/resolveTarget.ts +119 -0
  79. package/src/dev/delivery.ts +174 -0
  80. package/src/dev/devLogin.ts +155 -0
  81. package/src/dev/devLoginTargets.ts +91 -0
  82. package/src/dev/env.ts +206 -0
  83. package/src/dev/hostWorkers.ts +290 -0
  84. package/src/dev/keys.ts +111 -0
  85. package/src/dev/logging.ts +87 -0
  86. package/src/dev/openUrl.ts +75 -0
  87. package/src/dev/orchestrator.ts +1014 -0
  88. package/src/dev/ports.ts +220 -0
  89. package/src/dev/readyWatch.ts +142 -0
  90. package/src/dev/state.ts +90 -0
  91. package/src/devSecrets/bootstrapVars.ts +265 -0
  92. package/src/devSecrets/devVars.ts +240 -0
  93. package/src/devSecrets/edit.ts +256 -0
  94. package/src/devSecrets/file.ts +277 -0
  95. package/src/devSecrets/generate.ts +428 -0
  96. package/src/devSecrets/location.ts +80 -0
  97. package/src/devSecrets/mode.ts +71 -0
  98. package/src/devSecrets/records.ts +30 -0
  99. package/src/devSecrets/report.ts +99 -0
  100. package/src/devSecrets/seed.ts +344 -0
  101. package/src/devSecrets/store.ts +262 -0
  102. package/src/devSecrets/targets.ts +204 -0
  103. package/src/dispatch.ts +147 -0
  104. package/src/docs/catalog.ts +246 -0
  105. package/src/docs/writeCatalog.ts +45 -0
  106. package/src/doctor/cloudflare.ts +287 -0
  107. package/src/doctor/devPreferences.ts +155 -0
  108. package/src/doctor/devSecrets.ts +464 -0
  109. package/src/doctor/devVars.ts +414 -0
  110. package/src/doctor/devVarsLocal.ts +138 -0
  111. package/src/doctor/environments.ts +155 -0
  112. package/src/doctor/health.ts +354 -0
  113. package/src/doctor/localDelivery.ts +91 -0
  114. package/src/doctor/portsRegistry.ts +252 -0
  115. package/src/doctor/projectName.ts +584 -0
  116. package/src/doctor/secretBindings.ts +166 -0
  117. package/src/doctor/settings.ts +274 -0
  118. package/src/doctor/settingsSources.ts +202 -0
  119. package/src/doctor/workerName.ts +174 -0
  120. package/src/doctor/wranglerVars.ts +33 -0
  121. package/src/feature/bindings.ts +93 -0
  122. package/src/feature/create.ts +179 -0
  123. package/src/feature/destroy.ts +160 -0
  124. package/src/feature/devConfig.ts +201 -0
  125. package/src/feature/identity.ts +100 -0
  126. package/src/feature/manifest.ts +132 -0
  127. package/src/feature/ports.ts +615 -0
  128. package/src/feature/provision.ts +362 -0
  129. package/src/feature/sync.ts +148 -0
  130. package/src/feature/worktree.ts +282 -0
  131. package/src/help/groups.ts +47 -0
  132. package/src/help/rootUsage.ts +135 -0
  133. package/src/main.ts +73 -0
  134. package/src/migrations/ledger.ts +129 -0
  135. package/src/migrations/registry.ts +47 -0
  136. package/src/migrations/run.ts +1066 -0
  137. package/src/notifier/check.ts +129 -0
  138. package/src/notifier/installer.ts +48 -0
  139. package/src/notifier/notify.ts +152 -0
  140. package/src/notifier/state.ts +248 -0
  141. package/src/notifier/version.ts +59 -0
  142. package/src/platform/editor.ts +333 -0
  143. package/src/platform/rc.ts +118 -0
  144. package/src/platform/shell.ts +83 -0
  145. package/src/project/appBindings.ts +184 -0
  146. package/src/project/appWorkflows.ts +266 -0
  147. package/src/project/applyDomains.ts +166 -0
  148. package/src/project/askDomains.ts +220 -0
  149. package/src/project/atomic.ts +466 -0
  150. package/src/project/bindingEntries.ts +425 -0
  151. package/src/project/config.ts +701 -0
  152. package/src/project/dashboard.ts +118 -0
  153. package/src/project/deploy.ts +364 -0
  154. package/src/project/devVars.ts +113 -0
  155. package/src/project/domainPrompt.ts +191 -0
  156. package/src/project/domains.ts +386 -0
  157. package/src/project/envInventory.ts +356 -0
  158. package/src/project/environment.ts +125 -0
  159. package/src/project/extensions.ts +69 -0
  160. package/src/project/jsonc.ts +289 -0
  161. package/src/project/packageManager.ts +238 -0
  162. package/src/project/readOptionalFile.ts +342 -0
  163. package/src/project/rollback.ts +145 -0
  164. package/src/project/scaffold.ts +1088 -0
  165. package/src/project/templateFiles.ts +53 -0
  166. package/src/project/verifyDeploy.ts +230 -0
  167. package/src/project/versionMetadata.ts +77 -0
  168. package/src/project/workerAddress.ts +176 -0
  169. package/src/project/workerCommand.ts +564 -0
  170. package/src/project/workerIdentity.ts +50 -0
  171. package/src/project/workerManifest.ts +135 -0
  172. package/src/project/workerScaffold.ts +289 -0
  173. package/src/project/workerScope.ts +394 -0
  174. package/src/project/workers.ts +86 -0
  175. package/src/project/workflows.ts +281 -0
  176. package/src/project/wrangler.ts +168 -0
  177. package/src/provision/confirm.ts +86 -0
  178. package/src/provision/environment.ts +407 -0
  179. package/src/provision/featureConfig.ts +98 -0
  180. package/src/provision/mode.ts +62 -0
  181. package/src/provision/pendingSecrets.ts +96 -0
  182. package/src/provision/resources.ts +126 -0
  183. package/src/provision/secretBindings.ts +149 -0
  184. package/src/provision/store.ts +33 -0
  185. package/src/provision/unprovisioned.ts +114 -0
  186. package/src/provision/wranglerEnv.ts +220 -0
  187. package/src/rootFlags.ts +48 -0
  188. package/src/seed/drivers.ts +423 -0
  189. package/src/seed/media.ts +187 -0
  190. package/src/seed/plan.ts +137 -0
  191. package/src/seed/prepare.ts +224 -0
  192. package/src/seed/registry.ts +25 -0
  193. package/src/seed/run.ts +793 -0
  194. package/src/seed/safety.ts +206 -0
  195. package/src/terminal/logger.ts +42 -0
  196. package/src/terminal/output.ts +64 -0
  197. package/src/terminal/style.ts +132 -0
  198. package/src/test-utils/doctorHarness.ts +190 -0
  199. package/src/test-utils/migrateHarness.ts +126 -0
  200. package/src/test-utils/seedHarness.ts +173 -0
  201. package/src/test-utils/tempRepo.ts +45 -0
  202. package/src/tokens/config.ts +16 -0
  203. package/src/tokens/engine.ts +345 -0
  204. package/src/tokens/mintedTokens.ts +233 -0
  205. package/src/tokens/sinks.ts +84 -0
  206. package/src/ui/flow.ts +451 -0
  207. package/src/ui/react.ts +112 -0
  208. package/src/ui/routeAllowlist.ts +208 -0
  209. package/src/ui/scaffold.ts +113 -0
  210. package/src/ui/screenStyles.ts +127 -0
  211. package/src/ui/stubs.ts +135 -0
  212. package/src/ui/templates.ts +52 -0
  213. package/src/ui/wire.ts +311 -0
  214. package/src/ui/workerUi.ts +172 -0
  215. package/templates/starter/.dev.secrets.example.jsonc +43 -0
  216. package/templates/starter/.dev.vars.example +30 -0
  217. package/templates/starter/apps/api/package.json +22 -0
  218. package/templates/starter/apps/api/pithy.config.ts +65 -0
  219. package/templates/starter/apps/api/pithy.worker.jsonc +11 -0
  220. package/templates/starter/apps/api/src/bindings.workers.test.ts +18 -0
  221. package/templates/starter/apps/api/src/cloudflare-test.d.ts +11 -0
  222. package/templates/starter/apps/api/src/index.ts +8 -0
  223. package/templates/starter/apps/api/tsconfig.json +26 -0
  224. package/templates/starter/apps/api/wrangler.jsonc +68 -0
  225. package/templates/starter/biome.template.jsonc +75 -0
  226. package/templates/starter/gitignore +37 -0
  227. package/templates/starter/package.json +28 -0
  228. package/templates/starter/pithy.config.ts +67 -0
  229. package/templates/starter/plugins/no-console.grit +25 -0
  230. package/templates/starter/plugins/no-process-io.grit +25 -0
  231. package/templates/starter/tsconfig.json +14 -0
  232. package/templates/starter/tsconfig.tools.json +30 -0
  233. package/templates/starter/vitest.config.ts +124 -0
  234. package/templates/starter/vitest.workers.config.ts +26 -0
@@ -0,0 +1,282 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { execFile } from "node:child_process";
5
+ import { existsSync, readdirSync, rmSync } from "node:fs";
6
+ import { realpath } from "node:fs/promises";
7
+ import { join } from "node:path";
8
+ import { promisify } from "node:util";
9
+ import { ConflictError, InternalError } from "@pithy-sh/core/src/error/pithyError";
10
+
11
+ const run = promisify(execFile);
12
+
13
+ /**
14
+ * The git worktree/branch core behind `pithy feature create`/`destroy`. It is the same proven shape as
15
+ * the repo's `scripts/worktree.ts` — compose `feature/<issue>-<slug>` + `.worktrees/<issue>-<slug>` from
16
+ * the issue and slug, attach-or-cut the branch, and tear down the Linux-safe way (`rm .git` +
17
+ * `git worktree prune`, never `rm -rf`/`git worktree remove`, which trigger inotify storms on Linux) —
18
+ * ported into the CLI: async (no `execFileSync`), runtime- and package-manager-agnostic, and free of the
19
+ * `.dev.vars` symlinking, which the richer consolidated composition (`devVars.ts`) supersedes.
20
+ */
21
+
22
+ /** Runs git and returns trimmed stdout; throws on a non-zero exit. Injectable so tests fake git. */
23
+ export type GitRunner = (args: string[], cwd?: string) => Promise<string>;
24
+
25
+ /** The default git runner — shells out to the system `git`. */
26
+ export const defaultGit: GitRunner = async (args, cwd) => {
27
+ const { stdout } = await run("git", args, cwd ? { cwd } : {});
28
+ return stdout.trim();
29
+ };
30
+
31
+ /** Run git, swallow the error, and report whether it succeeded — for best-effort steps. */
32
+ async function gitTry(git: GitRunner, args: string[], cwd?: string): Promise<boolean> {
33
+ try {
34
+ await git(args, cwd);
35
+ return true;
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ /** The composed names for a feature: the branch, the worktree dir name, and its absolute path. */
42
+ export interface FeatureNames {
43
+ /** The feature branch, `feature/<issue>-<slug>`. */
44
+ branch: string;
45
+ /** The worktree directory name, `<issue>-<slug>`. */
46
+ dir: string;
47
+ /** The absolute worktree path, `<root>/.worktrees/<issue>-<slug>`. */
48
+ wtPath: string;
49
+ }
50
+
51
+ /** Compose the branch, dir, and worktree path for an issue + slug under a main-checkout root. */
52
+ export function featureNames(issue: string, slug: string, root: string): FeatureNames {
53
+ const dir = `${issue}-${slug}`;
54
+ return { branch: `feature/${issue}-${slug}`, dir, wtPath: join(root, ".worktrees", dir) };
55
+ }
56
+
57
+ /**
58
+ * One spelling of a repository path, whoever asked and however they got there.
59
+ *
60
+ * **Two things derive the main checkout's root and they must agree, because it is a registry key now**
61
+ * (#435): {@link mainRepoRoot} below, off `git worktree list`, and `resolveMainRepoRoot` in `ports.ts`,
62
+ * off `git rev-parse --git-common-dir`. Under the old design both were a *place to put a file* and any
63
+ * two spellings of one directory addressed the same file, so a difference could not be observed. As keys
64
+ * they are two entries for one repository — `pithy feature create` reserving under one while `destroy`
65
+ * frees under the other, `freePortBlock` no-opping, and `portsFreed: true` reported over a block that
66
+ * leaks forever.
67
+ *
68
+ * They diverge two ways, one per platform, and neither shows up in CI (every job is `ubuntu-24.04`).
69
+ * On POSIX, `--git-common-dir` answers `.git` and resolving that against the working directory keeps
70
+ * whatever symlinks were walked to get there, while `worktree list` always reports the real path. On
71
+ * **Windows** git emits forward slashes — `C:/code/app` — while `dirname`/`realpath` give `C:\code\app`.
72
+ * `realpath` settles both: it resolves the links and returns the platform's own separators.
73
+ *
74
+ * A failure is not worth refusing a command over — the path came from git, so it exists — and the
75
+ * uncanonicalised answer stands in.
76
+ */
77
+ export function canonicalRepoPath(path: string): Promise<string> {
78
+ return realpath(path).catch(() => path);
79
+ }
80
+
81
+ /**
82
+ * The main checkout's root. The first `git worktree list` entry is always the primary worktree, so this
83
+ * resolves the same path whether invoked from the root or from inside another worktree.
84
+ *
85
+ * Canonicalised through {@link canonicalRepoPath}, which is what makes it the *same string* as the other
86
+ * derivation rather than merely the same directory.
87
+ */
88
+ export async function mainRepoRoot(git: GitRunner = defaultGit): Promise<string> {
89
+ const first = (await git(["worktree", "list", "--porcelain"])).split("\n")[0] ?? "";
90
+ const path = first.startsWith("worktree ") ? first.slice("worktree ".length) : "";
91
+ if (!path) {
92
+ throw new InternalError({
93
+ message: "Could not resolve the main repository root.",
94
+ action: "Run pithy feature from inside a git repository.",
95
+ });
96
+ }
97
+ return canonicalRepoPath(path);
98
+ }
99
+
100
+ /** Whether a worktree is registered at this absolute path. */
101
+ async function isRegistered(git: GitRunner, wtPath: string): Promise<boolean> {
102
+ return (await git(["worktree", "list", "--porcelain"])).split("\n").some((line) => line === `worktree ${wtPath}`);
103
+ }
104
+
105
+ /**
106
+ * Whether a non-empty, unregistered directory already sits at `wtPath` — the leftover files a prior
107
+ * {@link teardownWorktree} deliberately left behind. `git worktree add` refuses to write into such a
108
+ * directory, so this is checked first to fail with an actionable error instead of a raw git one.
109
+ */
110
+ function hasLeftoverFiles(wtPath: string): boolean {
111
+ if (!existsSync(wtPath)) return false;
112
+ return readdirSync(wtPath).length > 0;
113
+ }
114
+
115
+ /** Run git for its output, or `null` when it fails. The `gitTry` of answers rather than of exit codes. */
116
+ async function gitOut(git: GitRunner, args: string[], cwd?: string): Promise<string | null> {
117
+ try {
118
+ const out = (await git(args, cwd)).trim();
119
+ return out === "" ? null : out;
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
124
+
125
+ /** Whether a ref exists locally or on origin. */
126
+ async function branchExists(git: GitRunner, branch: string, cwd?: string): Promise<boolean> {
127
+ return (
128
+ (await gitTry(git, ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`], cwd)) ||
129
+ (await gitTry(git, ["rev-parse", "--verify", "--quiet", `refs/remotes/origin/${branch}`], cwd))
130
+ );
131
+ }
132
+
133
+ /**
134
+ * The name of this repository's trunk — `main` unless the remote says otherwise.
135
+ *
136
+ * **Read from `origin/HEAD`, which is the remote's answer to "what is the default branch".** Only the
137
+ * *name* comes from the remote; the ref cut from is always the local branch of that name. A repository
138
+ * whose trunk is `master` and which also carries a stale local `main` — a rename left behind, a fork —
139
+ * would otherwise have every feature cut from the stale one, silently, which is `#454` again in a
140
+ * different shape.
141
+ *
142
+ * `main` when there is no remote to ask. That is this project family's convention and what
143
+ * `scripts/worktree.ts` has always assumed; a repository with no remote and a non-`main` trunk is the
144
+ * one case still open, and it resolves through {@link baseRef}'s fallback rather than guessing.
145
+ */
146
+ async function trunkName(git: GitRunner): Promise<string> {
147
+ const named = await gitOut(git, ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]);
148
+ return named === null ? "main" : named.replace(/^origin\//, "");
149
+ }
150
+
151
+ /**
152
+ * The trunk to cut a fresh feature branch from: the local trunk branch when it exists, else `HEAD`.
153
+ *
154
+ * **Local, never `origin/<trunk>` — `#454`.** It preferred the remote whenever the ref existed, which meant
155
+ * a feature cut on a repository holding unpushed work started before that work. On `pithy-sh/dashboard`
156
+ * that was 159 commits, and the symptom was a config error naming a field the branch was too old to have —
157
+ * a sentence that says nothing about the base it was cut from. Where the old config still parses there is
158
+ * no symptom at all: the branch is simply rooted in the past, and the operator learns at merge.
159
+ *
160
+ * A remote that is *ahead* is the ordinary case and not this function's business: cutting from a trunk that
161
+ * is a few commits behind is usually fine and sometimes deliberate. {@link behindRemote} is how the operator
162
+ * gets told, because being told is what stops it becoming a surprise at merge time.
163
+ */
164
+ async function baseRef(git: GitRunner): Promise<string> {
165
+ const trunk = await trunkName(git);
166
+ return (await gitTry(git, ["rev-parse", "--verify", "--quiet", `refs/heads/${trunk}`])) ? trunk : "HEAD";
167
+ }
168
+
169
+ /**
170
+ * How many commits the local trunk is behind its remote, or `null` when the question does not arise —
171
+ * no remote ref, no local trunk, or nothing behind.
172
+ *
173
+ * Reported rather than refused, and the caller decides how to say it. A repository with no `origin` is the
174
+ * ordinary case for a fresh `pithy init`, and a count of zero is the ordinary case for everybody else.
175
+ */
176
+ export async function behindRemote(git: GitRunner = defaultGit): Promise<number | null> {
177
+ const trunk = await trunkName(git);
178
+ if (!(await gitTry(git, ["rev-parse", "--verify", "--quiet", `refs/remotes/origin/${trunk}`]))) return null;
179
+ if (!(await gitTry(git, ["rev-parse", "--verify", "--quiet", `refs/heads/${trunk}`]))) return null;
180
+ const behind = Number.parseInt(await git(["rev-list", "--count", `${trunk}..origin/${trunk}`]), 10);
181
+ return Number.isFinite(behind) && behind > 0 ? behind : null;
182
+ }
183
+
184
+ /** The outcome of {@link createWorktree}: the composed names and whether a new worktree was created. */
185
+ export interface CreateWorktreeResult extends FeatureNames {
186
+ /** The main checkout root the worktree lives under. */
187
+ root: string;
188
+ /** True when a new worktree was created; false when one already existed (idempotent re-run). */
189
+ created: boolean;
190
+ /**
191
+ * The ref a fresh branch was cut from — the trunk's name, or `"HEAD"`. **Null when nothing was cut**:
192
+ * an already-registered worktree, or a branch that already existed and was attached to.
193
+ *
194
+ * A caller reporting a base has to know that difference. `feature create` prints how far the trunk is
195
+ * behind its remote, and on the attach path that sentence would be about a branch somebody else cut
196
+ * months ago — a false sentence about a base, which is the thing `#454` is about.
197
+ */
198
+ base: string | null;
199
+ }
200
+
201
+ /**
202
+ * Create the feature's branch and worktree, or no-op if the worktree is already registered. Attaches to
203
+ * the branch when it already exists (a re-run after teardown left the branch behind); otherwise cuts a
204
+ * fresh one from the trunk. Idempotent **only** while the worktree stays registered — a re-run after
205
+ * {@link teardownWorktree} (which deliberately leaves the files on disk) fails with an actionable
206
+ * `ConflictError` instead of a raw git error, because those leftover files must not be recursively
207
+ * deleted on Linux (CLAUDE.md).
208
+ */
209
+ export async function createWorktree(options: {
210
+ issue: string;
211
+ slug: string;
212
+ git?: GitRunner;
213
+ }): Promise<CreateWorktreeResult> {
214
+ const git = options.git ?? defaultGit;
215
+ const root = await mainRepoRoot(git);
216
+ const names = featureNames(options.issue, options.slug, root);
217
+
218
+ if (await isRegistered(git, names.wtPath)) {
219
+ return { ...names, root, created: false, base: null };
220
+ }
221
+
222
+ if (hasLeftoverFiles(names.wtPath)) {
223
+ throw new ConflictError({
224
+ message: `${names.wtPath} already exists and is not empty.`,
225
+ action:
226
+ "A previous 'pithy feature destroy' left these files on disk by design. Once no file watcher or editor " +
227
+ `has it open, remove the directory yourself (rm -r ${names.wtPath}) and re-run 'pithy feature create'.`,
228
+ detail: `git worktree add would fail: ${names.wtPath} is an unregistered, non-empty directory.`,
229
+ });
230
+ }
231
+
232
+ if (await branchExists(git, names.branch)) {
233
+ // Attached, not cut. The branch already exists — pushed by a colleague, or left behind by a teardown —
234
+ // so its base is whatever it was cut from, months ago and by somebody else. Nothing about this trunk.
235
+ await git(["worktree", "add", names.wtPath, names.branch]);
236
+ return { ...names, root, created: true, base: null };
237
+ }
238
+ const base = await baseRef(git);
239
+ await git(["worktree", "add", names.wtPath, "-b", names.branch, base]);
240
+ return { ...names, root, created: true, base };
241
+ }
242
+
243
+ /** The outcome of {@link teardownWorktree}: what was actually removed. */
244
+ export interface TeardownWorktreeResult extends FeatureNames {
245
+ /** True when a registered worktree was pruned. */
246
+ pruned: boolean;
247
+ /** True when the local branch was deleted (a merged branch); false when kept (unmerged) or absent. */
248
+ branchDeleted: boolean;
249
+ }
250
+
251
+ /**
252
+ * Tear down the feature's worktree the Linux-safe way and drop its branch. Drops the gitlink then prunes
253
+ * the registration — never `rm -rf` or `git worktree remove`, whose recursive delete over a node_modules
254
+ * tree triggers inotify storms that crash the box (CLAUDE.md). Files remain on disk by design (the dir is
255
+ * git-ignored). The lowercase `-d` refuses an unmerged branch, so an open feature keeps its branch.
256
+ * Idempotent for repeated teardowns: nothing registered / no branch is a clean no-op. Recreating the same
257
+ * feature afterwards is **not** automatically idempotent — the leftover files on disk make
258
+ * {@link createWorktree} fail loudly until an operator clears them; see its docstring.
259
+ */
260
+ export async function teardownWorktree(options: {
261
+ issue: string;
262
+ slug: string;
263
+ git?: GitRunner;
264
+ }): Promise<TeardownWorktreeResult> {
265
+ const git = options.git ?? defaultGit;
266
+ const root = await mainRepoRoot(git);
267
+ const names = featureNames(options.issue, options.slug, root);
268
+
269
+ let pruned = false;
270
+ if (await isRegistered(git, names.wtPath)) {
271
+ const gitlink = join(names.wtPath, ".git");
272
+ if (existsSync(gitlink)) rmSync(gitlink);
273
+ await git(["worktree", "prune"], root);
274
+ pruned = true;
275
+ }
276
+
277
+ let branchDeleted = false;
278
+ if (await gitTry(git, ["rev-parse", "--verify", "--quiet", `refs/heads/${names.branch}`], root)) {
279
+ branchDeleted = await gitTry(git, ["branch", "-d", names.branch], root);
280
+ }
281
+ return { ...names, pruned, branchDeleted };
282
+ }
@@ -0,0 +1,47 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { SubCommandsDef } from "citty";
5
+
6
+ /**
7
+ * The groups the root help screen prints, in the order it prints them (#407).
8
+ *
9
+ * **A presentation grouping, and only that.** Nothing dispatches through it, no invocation carries a
10
+ * group segment, and `pithy email provision` is still `pithy email provision`. Putting the group in the
11
+ * command path would rename nine commands, add a segment to every doc page and every agent's call, and
12
+ * buy a screen what a blank line already buys it. It is also not the same concept as `dispatch.test.ts`'s
13
+ * groups — commands that declare subcommands and cannot act — and neither is derived from the other.
14
+ *
15
+ * **The group is declared on the command, not in a list beside it.** A second list of the command set is
16
+ * a list that drifts, and the drift has a direction that matters: a command added to the tree and
17
+ * forgotten here would not break, it would *disappear* — from the one screen whose whole job is to say
18
+ * what the CLI can do. So `main.ts` carries `group` as a **required** field on every entry, this type is
19
+ * what that field must be, and a command with no group or a misspelled one is a compile error rather
20
+ * than something a test has to notice. There is no catch-all group, because there is nothing to catch.
21
+ */
22
+ export const HELP_GROUP_ORDER = ["Project", "Develop", "Operate", "Capabilities", "Toolchain"] as const;
23
+
24
+ /** One of {@link HELP_GROUP_ORDER}. The type `main.ts`'s `group` field takes. */
25
+ export type HelpGroup = (typeof HELP_GROUP_ORDER)[number];
26
+
27
+ /**
28
+ * How a command module is loaded — a thunk, so the tree stays as lazy as it was.
29
+ *
30
+ * Typed through citty's own `SubCommandsDef` rather than `CommandDef`. A command declares its own `args`,
31
+ * so its type is `CommandDef<ThoseArgs>`, and `run` puts that parameter in contravariant position — which
32
+ * makes `CommandDef<SpecificArgs>` unassignable to `CommandDef<ArgsDef>`. citty resolves this in the one
33
+ * place it has to, on the element type of `subCommands`; this borrows that resolution instead of writing
34
+ * a second one — `CittyCommand` lifts that element type out without this file naming `any` itself.
35
+ * Keeping the `() => Promise<…>` wrapper is what still requires a thunk.
36
+ */
37
+ type CittyCommand = Awaited<Extract<SubCommandsDef[string], Promise<unknown>>>;
38
+
39
+ export type CommandLoader = () => Promise<CittyCommand>;
40
+
41
+ /** One command in the root's registry: which group it prints under, and how to load it. */
42
+ export interface CommandEntry {
43
+ /** The heading this command prints under. Required: that is the whole mechanism. */
44
+ group: HelpGroup;
45
+ /** The command module, imported on demand. */
46
+ load: CommandLoader;
47
+ }
@@ -0,0 +1,135 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { ArgsDef, CommandDef } from "citty";
5
+ import { COMMAND_REGISTRY } from "../main";
6
+ import { bold, cyan, dim, heading } from "../terminal/style";
7
+ import { HELP_GROUP_ORDER } from "./groups";
8
+
9
+ /**
10
+ * The root help screen — the one screen of help Pithy renders rather than hosts (#407).
11
+ *
12
+ * citty listed twenty-six commands in declaration order under one heading, behind a `USAGE` line that
13
+ * alternated every name before a single description appeared. Nothing on it said `email`, `media` and
14
+ * `turnstile` are the same kind of thing and `deploy` is not, and the list only grows: every capability
15
+ * that lands adds a row. So the root screen groups them, and every screen below it stays citty's.
16
+ *
17
+ * **Only the root.** `pithy add --help`, `pithy secrets`, and the screen after an unknown name one level
18
+ * down are citty's `renderUsage`, byte for byte — §4.2's transcript is pinned against it. The seam is
19
+ * `parent === undefined`, which `dispatch.ts` establishes is true of the root and nothing else.
20
+ *
21
+ * **The grouping is read off the command registry, not off a table beside it.** `main.ts` declares every
22
+ * command with a required `group`, so there is no ungrouped state to render and no second list to drift
23
+ * from the first — see `groups.ts`. A group with no members prints no heading rather than an empty one.
24
+ *
25
+ * **The shapes are citty's on purpose.** Same right-aligned name column, same four-space gutter, same
26
+ * closing pointer. The two screens sit one keystroke apart, and a root screen that repainted itself
27
+ * would read as a different program. What changes is the grouping and the `USAGE` line.
28
+ *
29
+ * **Nothing here reads the terminal.** No `process.stdout.columns`, no `COLUMNS`: the layout is a pure
30
+ * function of the command tree, which is what lets `binDocs.test.ts` pin the whole screen byte for byte
31
+ * against `docs/CLI.md` §4.1 and get the same answer on every machine.
32
+ *
33
+ * **Color goes through `terminal/style.ts` and nowhere else** (docs/CLI.md §3.4). That is also the whole
34
+ * mechanism behind §4.3's claim that piped root help is plain: `bin.test.ts` asserts no escape byte with
35
+ * every color signal scrubbed, and an escape byte under `FORCE_COLOR`, and only the seam satisfies both.
36
+ */
37
+
38
+ /** Two spaces of indent, then the name column, then the gutter — citty's `formatLineColumns` shape. */
39
+ const INDENT = " ";
40
+ const GUTTER = " ";
41
+
42
+ /** A resolved subcommand: the label its row prints, and every name the `USAGE` line must list. */
43
+ interface Entry {
44
+ /** The row's left column — the name, plus any aliases, comma-joined. citty's rule. */
45
+ label: string;
46
+ /** The command's own name, for grouping. */
47
+ name: string;
48
+ /** `meta.description`, or empty when it declares none. */
49
+ description: string;
50
+ }
51
+
52
+ /** citty allows `meta` to be a value, a promise, or a thunk. Resolve all three the way it does. */
53
+ async function resolve<T>(value: T | Promise<T> | (() => T | Promise<T>)): Promise<T> {
54
+ return typeof value === "function" ? await (value as () => T | Promise<T>)() : await value;
55
+ }
56
+
57
+ /** Everything citty would put in the `COMMANDS` block, resolved from the lazy tree, in declaration order. */
58
+ async function entries<T extends ArgsDef>(cmd: CommandDef<T>): Promise<Entry[]> {
59
+ const subCommands = await resolve(cmd.subCommands);
60
+ if (subCommands === undefined) return [];
61
+ const resolved = await Promise.all(
62
+ Object.entries(subCommands).map(async ([name, value]) => {
63
+ const sub = await resolve(value as CommandDef | (() => Promise<CommandDef>));
64
+ const meta = (await resolve(sub.meta)) ?? {};
65
+ // `hidden` is skipped from the rows *and* from the USAGE line — citty's `continue` precedes its
66
+ // own `commandNames.push`. Nothing declares it today; the rule is preserved rather than dropped.
67
+ if (meta.hidden === true) return null;
68
+ const alias = meta.alias === undefined ? [] : Array.isArray(meta.alias) ? meta.alias : [meta.alias];
69
+ return { label: [name, ...alias].join(", "), name, description: meta.description ?? "" };
70
+ }),
71
+ );
72
+ return resolved.filter((entry): entry is Entry => entry !== null);
73
+ }
74
+
75
+ /**
76
+ * The whole screen, as one string with no trailing newline.
77
+ *
78
+ * A pure function of the tree so a test can call it without spawning anything, and so the byte-for-byte
79
+ * pin has one thing to compare. Exported for `rootUsage.test.ts`; `bin.ts` goes through {@link showRootUsage}.
80
+ */
81
+ export async function renderRootUsage<T extends ArgsDef = ArgsDef>(cmd: CommandDef<T>): Promise<string> {
82
+ const meta = (await resolve(cmd.meta)) ?? {};
83
+ const name = meta.name ?? "pithy";
84
+ const found = await entries(cmd);
85
+
86
+ // Width is the widest label across every command, not per group: the columns line up down the whole
87
+ // screen, which is what makes it read as one table with headings rather than five small ones.
88
+ const width = found.reduce((max, entry) => Math.max(max, entry.label.length), 0);
89
+
90
+ const lines: string[] = [
91
+ dim(`${meta.description ?? ""} (${name}${meta.version === undefined ? "" : ` v${meta.version}`})`),
92
+ "",
93
+ // No alternation. Twenty-six names before the first description was never information, and the line
94
+ // was already wider than a terminal.
95
+ `${bold("USAGE")} ${cyan(`${name} <command> [OPTIONS]`)}`,
96
+ "",
97
+ bold("COMMANDS"),
98
+ ];
99
+
100
+ const row = (entry: Entry): string =>
101
+ // Pad the plain label and colorize after. citty pads the already-colored string; the visible result
102
+ // is identical and the plain bytes — the only ones pinned — are identical too.
103
+ `${INDENT}${" ".repeat(width - entry.label.length)}${cyan(entry.label)}${GUTTER}${entry.description}`;
104
+
105
+ for (const group of HELP_GROUP_ORDER) {
106
+ // Registry order inside a group, which is `main.ts`'s declaration order — the file is written in the
107
+ // order this prints, so the screen and the registry read the same way down the page.
108
+ const members = found.filter((entry) => COMMAND_REGISTRY[entry.name]?.group === group);
109
+ if (members.length === 0) continue;
110
+ lines.push("", `${INDENT}${heading(group)}`);
111
+ for (const entry of members) lines.push(row(entry));
112
+ }
113
+
114
+ lines.push("", `Use ${cyan(`${name} <command> --help`)} for more information about a command.`);
115
+ return lines.join("\n");
116
+ }
117
+
118
+ /**
119
+ * Print the root screen. `showUsage`-shaped so `bin.ts` can hand one function to both of citty's paths.
120
+ *
121
+ * `process.stdout.write` rather than `console.log`: `plugins/no-console.grit` covers `packages/*​/src/**`
122
+ * and citty's own `showUsage` is a `console.log`, so copying it would fail the Lint job.
123
+ */
124
+ export async function showRootUsage<T extends ArgsDef = ArgsDef>(cmd: CommandDef<T>): Promise<void> {
125
+ // Caught for the same reason citty catches in its own `showUsage`, and it matters more here: this is
126
+ // installed as `runMain`'s `showUsage`, so it runs *inside* citty's `CLIError` handler. Rendering
127
+ // resolves all twenty-six command modules, and a module that rejects at import would throw out of that
128
+ // handler — turning `pithy` and `pithy nonsense` into an unhandled-rejection stack instead of a help
129
+ // screen, which is the crash-banner failure #329 removed. stderr, and the run still ends.
130
+ try {
131
+ process.stdout.write(`${await renderRootUsage(cmd)}\n`);
132
+ } catch (error) {
133
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
134
+ }
135
+ }
package/src/main.ts ADDED
@@ -0,0 +1,73 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { readFileSync } from "node:fs";
5
+ import { defineCommand } from "citty";
6
+ import type { CommandEntry } from "./help/groups";
7
+
8
+ const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as {
9
+ version: string;
10
+ };
11
+
12
+ /**
13
+ * Every command the CLI has, the group it prints under, and how to load it. One list, not two.
14
+ *
15
+ * **The group is a required field, so a command cannot be added without deciding where it belongs.**
16
+ * The root help screen groups its commands (§4.3), and the obvious way to spell that — a table of names
17
+ * per group beside this one — is a second list of the same set. Two lists drift, and this drift is the
18
+ * bad kind: a command added here and forgotten there would not fail, it would *vanish* from the one
19
+ * screen whose whole job is to say what the CLI can do. `satisfies` makes the omission a compile error
20
+ * and `HelpGroup` makes a typo one, which is why there is no gate for it and no catch-all group.
21
+ *
22
+ * **Written in the order the help screen prints**, so this file reads top-to-bottom like the output.
23
+ * Dispatch is keyed by name and does not care; the order is here for whoever is reading.
24
+ *
25
+ * Subcommands load lazily — cold start stays fast as the set grows, and `load` stays a thunk so nothing
26
+ * is imported until a name is walked into or the help screen resolves every description.
27
+ */
28
+ const COMMANDS = {
29
+ // Project — what the project is, and what it composes.
30
+ init: { group: "Project", load: () => import("./commands/init").then((m) => m.default) },
31
+ add: { group: "Project", load: () => import("./commands/add").then((m) => m.default) },
32
+ remove: { group: "Project", load: () => import("./commands/remove").then((m) => m.default) },
33
+ worker: { group: "Project", load: () => import("./commands/worker").then((m) => m.default) },
34
+ ui: { group: "Project", load: () => import("./commands/ui").then((m) => m.default) },
35
+ upgrade: { group: "Project", load: () => import("./commands/upgrade").then((m) => m.default) },
36
+
37
+ // Develop — the local loop.
38
+ dev: { group: "Develop", load: () => import("./commands/dev").then((m) => m.default) },
39
+ migrate: { group: "Develop", load: () => import("./commands/migrate").then((m) => m.default) },
40
+ seed: { group: "Develop", load: () => import("./commands/seed").then((m) => m.default) },
41
+ feature: { group: "Develop", load: () => import("./commands/feature").then((m) => m.default) },
42
+
43
+ // Operate — the deployed thing: its resources, its credentials, and who may manage it.
44
+ provision: { group: "Operate", load: () => import("./commands/provision").then((m) => m.default) },
45
+ deploy: { group: "Operate", load: () => import("./commands/deploy").then((m) => m.default) },
46
+ env: { group: "Operate", load: () => import("./commands/env").then((m) => m.default) },
47
+ token: { group: "Operate", load: () => import("./commands/token").then((m) => m.default) },
48
+ dashboard: { group: "Operate", load: () => import("./commands/dashboard").then((m) => m.default) },
49
+
50
+ // Capabilities — one command per capability that provisions infrastructure of its own.
51
+ secrets: { group: "Capabilities", load: () => import("./commands/secrets").then((m) => m.default) },
52
+ email: { group: "Capabilities", load: () => import("./commands/email").then((m) => m.default) },
53
+ media: { group: "Capabilities", load: () => import("./commands/media").then((m) => m.default) },
54
+ payments: { group: "Capabilities", load: () => import("./commands/payments").then((m) => m.default) },
55
+ storage: { group: "Capabilities", load: () => import("./commands/storage").then((m) => m.default) },
56
+ support: { group: "Capabilities", load: () => import("./commands/support").then((m) => m.default) },
57
+ testers: { group: "Capabilities", load: () => import("./commands/testers").then((m) => m.default) },
58
+ turnstile: { group: "Capabilities", load: () => import("./commands/turnstile").then((m) => m.default) },
59
+ vector: { group: "Capabilities", load: () => import("./commands/vector").then((m) => m.default) },
60
+
61
+ // Toolchain — the tool rather than the project.
62
+ doctor: { group: "Toolchain", load: () => import("./commands/doctor").then((m) => m.default) },
63
+ alias: { group: "Toolchain", load: () => import("./commands/alias").then((m) => m.default) },
64
+ } as const satisfies Record<string, CommandEntry>;
65
+
66
+ /** The registry, for the help screen. Every name, its group, and its loader. */
67
+ export const COMMAND_REGISTRY: Readonly<Record<string, CommandEntry>> = COMMANDS;
68
+
69
+ /** The root command. `subCommands` is projected from {@link COMMANDS} — citty sees the thunks it always did. */
70
+ export const main = defineCommand({
71
+ meta: { name: "pithy", version, description: "A backend kit for Cloudflare Workers." },
72
+ subCommands: Object.fromEntries(Object.entries(COMMANDS).map(([name, entry]) => [name, entry.load])),
73
+ });