@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,397 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * One named import found in a `pithy.config.ts`. The three commands that write that file — `add`,
6
+ * `remove`, and `eject` — all need the same answer about it, and each used to hand-build the exact
7
+ * line it expected instead. That is why `pithy add secrets` could write an import `remove` would not
8
+ * take out and `eject` would not find.
9
+ */
10
+ export interface ConfigImport {
11
+ /** The statement exactly as it appears — leading indentation and trailing `;` included. */
12
+ statement: string;
13
+ /** The module specifier it imports from, unquoted. */
14
+ specifier: string;
15
+ /** Where the statement starts in the source it was found in — the anchor every edit splices at. */
16
+ start: number;
17
+ /**
18
+ * The span to cut to drop this one binding: the name, plus the comma joining it to its neighbor.
19
+ * Meaningless on its own — {@link withoutBinding} is what reads it.
20
+ */
21
+ bindingStart: number;
22
+ bindingEnd: number;
23
+ /** True when the clause binds nothing else, so dropping the name drops the whole statement. */
24
+ soleBinding: boolean;
25
+ }
26
+
27
+ /**
28
+ * A named import statement — `import { a, type B, c as d } from "…";`.
29
+ *
30
+ * Leading whitespace is tolerated: indentation is cosmetic, and a config formatted with an indented
31
+ * import is still one. `import type { … }` does not match — it binds no value, so it cannot be the
32
+ * import a registration call needs. Neither do the default and namespace forms (`import auth from …`,
33
+ * `import * as auth from …`): a capability factory is a named export, and an adopter who binds the
34
+ * name that way collides at typecheck, loudly, rather than getting the wrong thing composed.
35
+ *
36
+ * The pattern is only ever run over {@link scanConfig}'s output, never over raw file text.
37
+ */
38
+ const NAMED_IMPORT = /^[ \t]*import\s*\{([^}]*)\}\s*from\s*(["'])([^"']+)\2;?/gm;
39
+
40
+ /**
41
+ * A named re-export statement — `export { MultiplayerSession } from "…";`.
42
+ *
43
+ * The same shape one statement keyword over, and read by the same machinery below: a Durable Object's
44
+ * export in a Worker entry is the second half of a binding `pithy add` writes (#428), and it is found,
45
+ * skipped and cut by exactly the rules an import is. A second scanner would be a second idea of what a
46
+ * comment is.
47
+ *
48
+ * `export { a }` with no `from` is not matched: it re-exports nothing, so it cannot be the line that
49
+ * puts a capability's class on the module.
50
+ */
51
+ const NAMED_EXPORT = /^[ \t]*export\s*\{([^}]*)\}\s*from\s*(["'])([^"']+)\2;?/gm;
52
+
53
+ /**
54
+ * Any named export clause — `export { X };` as well as `export { X } from "…";`.
55
+ *
56
+ * The pattern above answers "which statement points at that module", which is what an edit needs. This
57
+ * one answers the wider question `pithy add` asks before writing anything: **is that name already on this
58
+ * module**, however it got there. A bare clause re-exports nothing, so it is never a line we would write,
59
+ * and it still makes a second `export { X } from "…"` a duplicate the build refuses.
60
+ */
61
+ const EXPORT_CLAUSE = /^[ \t]*export\s*\{([^}]*)\}(?:\s*from\s*(["'])([^"']+)\2)?;?/gm;
62
+
63
+ /**
64
+ * A value declaration exported where it is declared — `export class X {}`, `export const X = …`,
65
+ * `export async function* X() {}`.
66
+ *
67
+ * The third way a name lands on a module, and the one that made the claim above untrue: an adopter who
68
+ * wrote their own Durable Object into the entry got ours appended beside it and a duplicate export (#428).
69
+ *
70
+ * `default` is deliberately absent from the alternation. `export default class X {}` puts `default` on
71
+ * the module and nothing else, so wrangler's `class_name` still resolves to nothing. So are `type`,
72
+ * `interface` and `enum`: the first two are erased by `verbatimModuleSyntax`, and neither is a class.
73
+ */
74
+ const EXPORTED_DECLARATION =
75
+ /^[ \t]*export\s+(?:declare\s+)?(?:abstract\s+)?(?:async\s+)?(?:class|function\s*\*?|const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)/gm;
76
+
77
+ /** Which statement form {@link findNamedBinding} reads. Both bind names from another module. */
78
+ export type ClauseKind = "import" | "export";
79
+
80
+ /** A source file's text with everything that only *looks* like code taken out of the way. */
81
+ interface ScannedConfig {
82
+ /**
83
+ * The source with every comment blanked to spaces — same length, same offsets, same line breaks, so
84
+ * a match here indexes straight back into the original.
85
+ */
86
+ code: string;
87
+ /** True at each offset that sits inside a string or template literal. */
88
+ inLiteral: boolean[];
89
+ }
90
+
91
+ /**
92
+ * Blank the comments and mark the literals in a `pithy.config.ts`.
93
+ *
94
+ * The pattern above is a regex over file text, and text does not know what a comment is: a
95
+ * commented-out `import { auth } from "@pithy-sh/auth/src/index";` above the real binding answered
96
+ * the "is this already imported?" question on its behalf, so `pithy add` wrote the registration and
97
+ * no import. A one-character scanner is the bounded fix. **It is not a parser**, and two things it
98
+ * deliberately does not understand: a regex literal (telling `/…/` from division needs the grammar,
99
+ * so an import inside one still reads as code), and `${…}` inside a template literal (treated as more
100
+ * literal, which can only make it blind, never fooled). The real answer is a TypeScript parse, and
101
+ * TS 7 ships no `createSourceFile` to do it with — `catalog.test.ts` records the same constraint.
102
+ */
103
+ function scanConfig(source: string): ScannedConfig {
104
+ type State = "code" | "line-comment" | "block-comment" | "string" | "template";
105
+ let code = "";
106
+ const inLiteral = new Array<boolean>(source.length).fill(false);
107
+ let state: State = "code";
108
+ let quote = "";
109
+ let index = 0;
110
+
111
+ while (index < source.length) {
112
+ const char = source[index] ?? "";
113
+ const next = source[index + 1] ?? "";
114
+
115
+ if (state === "code") {
116
+ if (char === "/" && (next === "/" || next === "*")) {
117
+ state = next === "/" ? "line-comment" : "block-comment";
118
+ code += " ";
119
+ index += 2;
120
+ continue;
121
+ }
122
+ if (char === '"' || char === "'" || char === "`") {
123
+ state = char === "`" ? "template" : "string";
124
+ quote = char;
125
+ }
126
+ code += char;
127
+ index += 1;
128
+ continue;
129
+ }
130
+
131
+ if (state === "line-comment") {
132
+ // A newline ends it, and is kept: the pattern is line-anchored, so line breaks have to survive.
133
+ if (char === "\n") state = "code";
134
+ code += char === "\n" ? "\n" : " ";
135
+ index += 1;
136
+ continue;
137
+ }
138
+
139
+ if (state === "block-comment") {
140
+ if (char === "*" && next === "/") {
141
+ state = "code";
142
+ code += " ";
143
+ index += 2;
144
+ continue;
145
+ }
146
+ code += char === "\n" ? "\n" : " ";
147
+ index += 1;
148
+ continue;
149
+ }
150
+
151
+ // A string or template literal, kept verbatim: an import's own specifier is one, so blanking it
152
+ // would take the answer with it. The flag is what rejects a match that *starts* inside one.
153
+ inLiteral[index] = true;
154
+ code += char;
155
+ if (char === "\\" && index + 1 < source.length) {
156
+ inLiteral[index + 1] = true;
157
+ code += next;
158
+ index += 2;
159
+ continue;
160
+ }
161
+ if (char === quote) state = "code";
162
+ index += 1;
163
+ }
164
+
165
+ return { code, inLiteral };
166
+ }
167
+
168
+ /**
169
+ * Every named import statement in a config, in source order, with the comments and literals already
170
+ * ruled out. The one place file text becomes imports — `findNamedImport` and `parseEjectedCapabilities`
171
+ * both read a config through it, so neither can quietly grow its own idea of what an import is.
172
+ */
173
+ function* namedClauses(source: string, pattern: RegExp): Generator<RegExpExecArray> {
174
+ const { code, inLiteral } = scanConfig(source);
175
+ for (const match of code.matchAll(pattern)) {
176
+ if (inLiteral[match.index]) continue; // an import inside a string is a string
177
+ yield match;
178
+ }
179
+ }
180
+
181
+ /** The module specifier of every named import in a config, in source order. Duplicates included. */
182
+ export function importedSpecifiers(source: string): string[] {
183
+ return [...namedClauses(source, NAMED_IMPORT)].map((match) => match[3] ?? "");
184
+ }
185
+
186
+ /** One comma-separated name in an import clause, and where it sits in the source. */
187
+ interface ClauseSegment {
188
+ /** The segment verbatim, padding and all — `" auth "` in `{ a, auth }`. */
189
+ text: string;
190
+ /** Its absolute span in the source. The commas are the gaps between one segment and the next. */
191
+ start: number;
192
+ end: number;
193
+ }
194
+
195
+ /** Split a clause on its commas, carrying each piece's offset. `offset` is where the clause starts. */
196
+ function clauseSegments(clause: string, offset: number): ClauseSegment[] {
197
+ const segments: ClauseSegment[] = [];
198
+ let at = offset;
199
+ for (const text of clause.split(",")) {
200
+ segments.push({ text, start: at, end: at + text.length });
201
+ at += text.length + 1; // the comma
202
+ }
203
+ return segments;
204
+ }
205
+
206
+ /**
207
+ * The named import that binds `name`, or `undefined` when nothing does.
208
+ *
209
+ * **Keyed on the binding, not the specifier.** Where a capability is imported from is the adopter's
210
+ * business — a deep path, an ejected copy — but the *name* is the thing a registration call resolves
211
+ * through, so that is what identifies the import. Where that specifier actually points is a separate
212
+ * question each caller answers with {@link importOrigin}.
213
+ */
214
+ export function findNamedImport(source: string, name: string): ConfigImport | undefined {
215
+ return findNamedBinding(source, name, "import");
216
+ }
217
+
218
+ /**
219
+ * The named import **or** re-export that binds `name`, or `undefined` when nothing does.
220
+ *
221
+ * One search for both keywords, because the two questions are the same one: which statement puts this
222
+ * name on this module, and what does it point at. `pithy add` asks it of a capability's import in
223
+ * `pithy.config.ts` and of a Durable Object's export in the Worker entry, and a second implementation
224
+ * would be a second answer to "is that a comment" — the bug the scanner exists for.
225
+ */
226
+ export function findNamedBinding(source: string, name: string, kind: ClauseKind): ConfigImport | undefined {
227
+ for (const match of namedClauses(source, kind === "import" ? NAMED_IMPORT : NAMED_EXPORT)) {
228
+ const clause = match[1] ?? "";
229
+ const segments = clauseSegments(clause, match.index + match[0].indexOf("{") + 1);
230
+ for (const [index, segment] of segments.entries()) {
231
+ const trimmed = segment.text.trim();
232
+ if (trimmed === "" || trimmed.startsWith("type ")) continue;
233
+ // `x as y` binds `y`; an alias means the adopter is using a different name, and this is not it.
234
+ const parts = trimmed.split(/\s+as\s+/);
235
+ if ((parts[1] ?? parts[0] ?? "").trim() !== name) continue;
236
+
237
+ // What an edit would cut. The first name takes the comma after it (`{ auth, b }` → `{ b }`); a
238
+ // later one takes the comma before it and stops at the name, so the clause keeps its padding
239
+ // (`{ a, auth }` → `{ a }`). A sole binding is cut whole and the statement goes with it.
240
+ const soleBinding = segments.every((other, at) => at === index || other.text.trim() === "");
241
+ const trailing = segment.text.length - segment.text.trimEnd().length;
242
+ const after = segments[index + 1]?.start ?? segment.end;
243
+ const before = segments[index - 1]?.end ?? segment.start;
244
+ // Sliced from the source, not taken from the match: a comment inside the statement is blank in
245
+ // `code`, and the statement callers edit is the one the adopter wrote.
246
+ return {
247
+ statement: source.slice(match.index, match.index + match[0].length),
248
+ specifier: match[3] ?? "",
249
+ start: match.index,
250
+ bindingStart: soleBinding || index === 0 ? segment.start : before,
251
+ bindingEnd: soleBinding ? segment.end : index === 0 ? after : segment.end - trailing,
252
+ soleBinding,
253
+ };
254
+ }
255
+ }
256
+ return undefined;
257
+ }
258
+
259
+ /**
260
+ * Every named re-export in a module — the statement verbatim, the module it points at, and where it
261
+ * starts — in source order.
262
+ *
263
+ * Keyed on the specifier rather than on a name, because `pithy add --eject` asks the opposite question
264
+ * from the rest of this file: not "which statement binds this name" but "which statements still point into
265
+ * the package we have just forked". A fork whose entry re-exports the package runs the package's class
266
+ * (#428).
267
+ *
268
+ * **`start` is not decoration.** The pattern is anchored at `^[ \t]*`, so a statement begins at the
269
+ * `export` keyword — and a line reading `// export { X } from "…";` contains that statement as a literal
270
+ * substring. An edit that found its target with `String.replace` repointed the adopter's commented-out
271
+ * copy and left the live export pointing at the package, which is the one outcome repointing exists to
272
+ * prevent. The offset is the anchor: a caller splices at it, exactly as {@link withoutBinding} does.
273
+ */
274
+ export function namedReexports(source: string): { statement: string; specifier: string; start: number }[] {
275
+ return [...namedClauses(source, NAMED_EXPORT)].map((match) => ({
276
+ statement: source.slice(match.index, match.index + match[0].length),
277
+ specifier: match[3] ?? "",
278
+ start: match.index,
279
+ }));
280
+ }
281
+
282
+ /**
283
+ * Whether the module puts `name` on itself at runtime, in any spelling that survives to the bundle.
284
+ *
285
+ * The question `pithy add` asks before writing a Durable Object's export: a class already exported is a
286
+ * class wrangler's `class_name` resolves, whoever put it there, and appending a second statement for the
287
+ * same name is a duplicate export the build stops on. So all three spellings count — a re-export, a bare
288
+ * clause over a local declaration, and a declaration exported in place.
289
+ *
290
+ * **Runtime, not syntax.** `export type { X }` and `export { type X }` are erased by
291
+ * `verbatimModuleSyntax`, so they leave the module carrying nothing and are not this. Same fact
292
+ * `hasDefaultExport` turns on for #426, one keyword over.
293
+ *
294
+ * A scanner, not a parser — {@link scanConfig} says what that costs.
295
+ */
296
+ export function exportsName(source: string, name: string): boolean {
297
+ for (const match of namedClauses(source, EXPORT_CLAUSE)) {
298
+ for (const segment of (match[1] ?? "").split(",")) {
299
+ const trimmed = segment.trim();
300
+ if (trimmed === "" || trimmed.startsWith("type ")) continue;
301
+ // `X as Y` puts `Y` on the module; `X` alone puts `X`.
302
+ const parts = trimmed.split(/\s+as\s+/);
303
+ if ((parts[1] ?? parts[0] ?? "").trim() === name) return true;
304
+ }
305
+ }
306
+ for (const match of namedClauses(source, EXPORTED_DECLARATION)) {
307
+ if (match[1] === name) return true;
308
+ }
309
+ return false;
310
+ }
311
+
312
+ /**
313
+ * The module specifier a capability's factory is imported from — the package's `src/index` barrel.
314
+ *
315
+ * Exported because it is a claim about another package's files, and `catalog.test.ts` checks it
316
+ * against them. `@pithy-sh/secrets` shipped with no `src/index.ts`, so `pithy add secrets` wrote a
317
+ * specifier nothing answered and every later `pithy` command failed loading the config. The test
318
+ * reads this string rather than a copy of it, so the two cannot drift apart again.
319
+ */
320
+ export function capabilityImportSpecifier(pkg: string): string {
321
+ return `${pkg}/src/index`;
322
+ }
323
+
324
+ /** Where an import that binds a capability's name actually points. */
325
+ export type ImportOrigin =
326
+ /** The capability itself: its package barrel, a deeper path into it, or the ejected local copy. */
327
+ | "capability"
328
+ /** Ours, but pointing at nothing that loads — the bare package specifier. */
329
+ | "unresolvable"
330
+ /** Someone else's module that happens to bind the same name. Not ours to rewrite or delete. */
331
+ | "foreign";
332
+
333
+ /**
334
+ * Whether `specifier` is `base` itself or a path *inside* it, with no way back out.
335
+ *
336
+ * The `..` check is the point. The specifier is unresolved text, and Bun — which is what actually
337
+ * `import()`s a `pithy.config.ts` (`project/config.ts`) — resolves `@pithy-sh/auth/../../elsewhere`
338
+ * to wherever that lands. A `startsWith` prefix test called that the capability's own import, so the
339
+ * guard on "is this really ours?" answered yes for anything that walked straight back out of the
340
+ * package. Deciding on the shape of the resolved path instead means no traversal can change the
341
+ * answer. Exported because `eject.ts` asks it of the fork directory, and the two must agree.
342
+ */
343
+ export function isInside(specifier: string, base: string): boolean {
344
+ if (specifier === base) return true;
345
+ if (!specifier.startsWith(`${base}/`)) return false;
346
+ const rest = specifier.slice(base.length + 1);
347
+ return rest !== "" && !rest.split("/").includes("..");
348
+ }
349
+
350
+ /**
351
+ * Where an import of the capability's name points: the capability, the capability by a specifier that
352
+ * resolves to nothing, or somebody else's module.
353
+ *
354
+ * Deep paths are the capability because an adopter reaching past the barrel is still importing *this
355
+ * capability*, and the three commands have to agree on that. They did not: `add` blessed a hand-edited
356
+ * deep import, `remove` left it behind while uninstalling the package, and `eject` refused to find it.
357
+ *
358
+ * The bare package is its own verdict because it is **ours and broken**: every capability package
359
+ * exports `./src/*` and nothing else, so `import { auth } from "@pithy-sh/auth"` throws at load
360
+ * (`catalog.test.ts` holds that against all fifteen). `remove` and `eject` still own it — leaving it
361
+ * behind while the package is uninstalled is the unloadable config `remove` exists to undo — but `add`
362
+ * must not read it as wiring already done.
363
+ */
364
+ export function importOrigin(specifier: string, pkg: string, ejectPath: string): ImportOrigin {
365
+ if (specifier === pkg) return "unresolvable";
366
+ if (isInside(specifier, pkg) || isInside(specifier, ejectPath)) return "capability";
367
+ return "foreign";
368
+ }
369
+
370
+ /**
371
+ * Whether the import is the capability's to rewrite or take out — every origin but `foreign`.
372
+ *
373
+ * The question `remove` and `eject` ask. Whether it also *loads* is a separate one, and only `add`
374
+ * needs it: see {@link importOrigin}.
375
+ */
376
+ export function isCapabilityImport(specifier: string, pkg: string, ejectPath: string): boolean {
377
+ return importOrigin(specifier, pkg, ejectPath) !== "foreign";
378
+ }
379
+
380
+ /**
381
+ * The source with one **binding** taken out — the whole statement only when that binding was the last
382
+ * name in the clause, including the newline it sat on so no blank line is left behind.
383
+ *
384
+ * The binding, not the statement, because `findNamedImport` deliberately matches one name inside a
385
+ * multi-name clause: deleting the statement took `import { auth, hashPassword } from …` down to
386
+ * nothing and left a config that no longer compiles, over bindings that were never the capability's.
387
+ * What remains may still import a package that is about to be uninstalled — that is a typecheck
388
+ * failure the adopter can read, which beats silently deleting code they wrote.
389
+ *
390
+ * A no-op if the statement is no longer where {@link ConfigImport} says it is.
391
+ */
392
+ export function withoutBinding(source: string, found: ConfigImport): string {
393
+ const end = found.start + found.statement.length;
394
+ if (source.slice(found.start, end) !== found.statement) return source;
395
+ if (!found.soleBinding) return source.slice(0, found.bindingStart) + source.slice(found.bindingEnd);
396
+ return source.slice(0, found.start) + source.slice(source.startsWith("\n", end) ? end + 1 : end);
397
+ }