@pithy-sh/core 0.9.0 → 0.10.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 (162) hide show
  1. package/dist/asyncapi/document.d.ts +46 -0
  2. package/dist/asyncapi/document.d.ts.map +1 -0
  3. package/dist/asyncapi/document.js +115 -0
  4. package/dist/capability/capability.d.ts +7 -0
  5. package/dist/capability/capability.d.ts.map +1 -1
  6. package/dist/capability/composition.d.ts.map +1 -1
  7. package/dist/capability/composition.js +5 -1
  8. package/dist/capability/settings.d.ts +10 -2
  9. package/dist/capability/settings.d.ts.map +1 -1
  10. package/dist/capability/settings.js +5 -3
  11. package/dist/cli/command.d.ts +364 -0
  12. package/dist/cli/command.d.ts.map +1 -0
  13. package/dist/cli/command.js +418 -0
  14. package/dist/cli/commandCatalog.generated.d.ts +2275 -0
  15. package/dist/cli/commandCatalog.generated.d.ts.map +1 -0
  16. package/dist/cli/commandCatalog.generated.js +2390 -0
  17. package/dist/cli/commandFacts.d.ts +164 -0
  18. package/dist/cli/commandFacts.d.ts.map +1 -0
  19. package/dist/cli/commandFacts.js +215 -0
  20. package/dist/controlPlane/error/errors.d.ts.map +1 -1
  21. package/dist/controlPlane/error/errors.js +4 -3
  22. package/dist/controlPlane/http/guard.d.ts.map +1 -1
  23. package/dist/controlPlane/http/guard.js +11 -1
  24. package/dist/controlPlane/http/routes.d.ts +1 -1
  25. package/dist/controlPlane/http/routes.d.ts.map +1 -1
  26. package/dist/controlPlane/http/routes.js +34 -7
  27. package/dist/createBackend.d.ts +7 -0
  28. package/dist/createBackend.d.ts.map +1 -1
  29. package/dist/createBackend.js +34 -4
  30. package/dist/entitlement/require.js +1 -1
  31. package/dist/error/cause.d.ts +16 -116
  32. package/dist/error/cause.d.ts.map +1 -1
  33. package/dist/error/cause.js +19 -153
  34. package/dist/error/http.d.ts +40 -0
  35. package/dist/error/http.d.ts.map +1 -1
  36. package/dist/error/payload.d.ts +80 -0
  37. package/dist/error/payload.d.ts.map +1 -1
  38. package/dist/error/payload.js +26 -0
  39. package/dist/error/pithyError.d.ts +44 -0
  40. package/dist/error/pithyError.d.ts.map +1 -1
  41. package/dist/error/pithyError.js +56 -1
  42. package/dist/error/unresolved.d.ts +265 -0
  43. package/dist/error/unresolved.d.ts.map +1 -0
  44. package/dist/error/unresolved.js +599 -0
  45. package/dist/error/wire.d.ts +20 -0
  46. package/dist/error/wire.d.ts.map +1 -0
  47. package/dist/error/wire.js +19 -0
  48. package/dist/http/oidcWebhook.d.ts +3 -3
  49. package/dist/http/oidcWebhook.js +3 -3
  50. package/dist/http/routeContract.js +1 -1
  51. package/dist/http/routeSpec.d.ts +66 -0
  52. package/dist/http/routeSpec.d.ts.map +1 -0
  53. package/dist/http/routeSpec.js +40 -0
  54. package/dist/http/sameOrigin.d.ts +1 -1
  55. package/dist/http/sameOrigin.js +1 -1
  56. package/dist/http/signedWebhook.d.ts +3 -3
  57. package/dist/http/signedWebhook.js +3 -3
  58. package/dist/http/socket.d.ts +52 -0
  59. package/dist/http/socket.d.ts.map +1 -0
  60. package/dist/http/socket.js +2 -0
  61. package/dist/http/subprotocol.d.ts +29 -0
  62. package/dist/http/subprotocol.d.ts.map +1 -0
  63. package/dist/http/subprotocol.js +37 -0
  64. package/dist/http/validation.d.ts +40 -8
  65. package/dist/http/validation.d.ts.map +1 -1
  66. package/dist/http/validation.js +25 -1
  67. package/dist/index.js +2 -2
  68. package/dist/logger/record.d.ts +23 -0
  69. package/dist/logger/record.d.ts.map +1 -1
  70. package/dist/migrations/bookkeeping.d.ts +21 -0
  71. package/dist/migrations/bookkeeping.d.ts.map +1 -1
  72. package/dist/migrations/bookkeeping.js +32 -3
  73. package/dist/migrations/groups.d.ts +140 -0
  74. package/dist/migrations/groups.d.ts.map +1 -0
  75. package/dist/migrations/groups.js +191 -0
  76. package/dist/migrations/migrateStep.d.ts +13 -0
  77. package/dist/migrations/migrateStep.d.ts.map +1 -0
  78. package/dist/migrations/migrateStep.js +20 -0
  79. package/dist/migrations/owner.d.ts +7 -0
  80. package/dist/migrations/owner.d.ts.map +1 -1
  81. package/dist/migrations/owner.js +11 -7
  82. package/dist/migrations/retained.js +2 -2
  83. package/dist/migrations/runner.d.ts +76 -5
  84. package/dist/migrations/runner.d.ts.map +1 -1
  85. package/dist/migrations/runner.js +130 -19
  86. package/dist/naming/domains.d.ts +17 -0
  87. package/dist/naming/domains.d.ts.map +1 -1
  88. package/dist/naming/domains.js +62 -4
  89. package/dist/naming/environment.d.ts.map +1 -1
  90. package/dist/naming/environment.js +2 -1
  91. package/dist/naming/packageName.d.ts +17 -0
  92. package/dist/naming/packageName.d.ts.map +1 -0
  93. package/dist/naming/packageName.js +22 -0
  94. package/dist/naming/provisionScope.d.ts.map +1 -1
  95. package/dist/naming/provisionScope.js +2 -1
  96. package/dist/openapi/document.d.ts +106 -0
  97. package/dist/openapi/document.d.ts.map +1 -0
  98. package/dist/openapi/document.js +314 -0
  99. package/dist/openapi/routeGroups.d.ts +8 -0
  100. package/dist/openapi/routeGroups.d.ts.map +1 -0
  101. package/dist/openapi/routeGroups.js +23 -0
  102. package/dist/text/comments.js +66 -4
  103. package/dist/text/literals.d.ts +43 -0
  104. package/dist/text/literals.d.ts.map +1 -0
  105. package/dist/text/literals.js +293 -0
  106. package/dist/version.generated.d.ts +1 -1
  107. package/dist/version.generated.d.ts.map +1 -1
  108. package/dist/version.generated.js +1 -1
  109. package/dist/workflow/dispatchRoute.d.ts.map +1 -1
  110. package/dist/workflow/dispatchRoute.js +12 -6
  111. package/dist/workflow/faults.d.ts.map +1 -1
  112. package/dist/workflow/faults.js +2 -3
  113. package/dist/workflow/hostEnv.d.ts +1 -0
  114. package/dist/workflow/hostEnv.d.ts.map +1 -1
  115. package/dist/workflow/hostEnv.js +4 -2
  116. package/dist/workflow/loopback.d.ts.map +1 -1
  117. package/dist/workflow/loopback.js +2 -1
  118. package/package.json +3 -3
  119. package/src/asyncapi/document.ts +164 -0
  120. package/src/capability/capability.ts +7 -0
  121. package/src/capability/composition.ts +2 -1
  122. package/src/capability/settings.ts +19 -2
  123. package/src/cli/command.ts +865 -0
  124. package/src/cli/commandCatalog.generated.ts +2533 -0
  125. package/src/cli/commandFacts.ts +219 -0
  126. package/src/controlPlane/error/errors.ts +10 -3
  127. package/src/controlPlane/http/guard.ts +14 -2
  128. package/src/controlPlane/http/routes.ts +57 -9
  129. package/src/createBackend.ts +35 -2
  130. package/src/entitlement/require.ts +1 -1
  131. package/src/error/cause.ts +18 -181
  132. package/src/error/payload.ts +59 -0
  133. package/src/error/pithyError.ts +76 -0
  134. package/src/error/unresolved.ts +853 -0
  135. package/src/error/wire.ts +40 -0
  136. package/src/http/oidcWebhook.ts +3 -3
  137. package/src/http/routeContract.ts +1 -1
  138. package/src/http/routeSpec.ts +81 -0
  139. package/src/http/sameOrigin.ts +1 -1
  140. package/src/http/signedWebhook.ts +3 -3
  141. package/src/http/socket.ts +55 -0
  142. package/src/http/subprotocol.ts +47 -0
  143. package/src/http/validation.ts +50 -8
  144. package/src/migrations/bookkeeping.ts +39 -2
  145. package/src/migrations/groups.ts +295 -0
  146. package/src/migrations/migrateStep.ts +19 -0
  147. package/src/migrations/owner.ts +10 -6
  148. package/src/migrations/retained.ts +2 -2
  149. package/src/migrations/runner.ts +214 -22
  150. package/src/naming/domains.ts +75 -16
  151. package/src/naming/environment.ts +2 -1
  152. package/src/naming/packageName.ts +20 -0
  153. package/src/naming/provisionScope.ts +2 -1
  154. package/src/openapi/document.ts +412 -0
  155. package/src/openapi/routeGroups.ts +25 -0
  156. package/src/text/comments.ts +56 -4
  157. package/src/text/literals.ts +294 -0
  158. package/src/version.generated.ts +1 -1
  159. package/src/workflow/dispatchRoute.ts +13 -6
  160. package/src/workflow/faults.ts +5 -3
  161. package/src/workflow/hostEnv.ts +9 -1
  162. package/src/workflow/loopback.ts +2 -1
@@ -1,9 +1,11 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
- import { InternalError } from "../error/pithyError.js";
3
+ import { InternalError, ValidationError } from "../error/pithyError.js";
4
+ import { pithyCommand } from "../cli/command.js";
4
5
  import { causeMessage } from "../error/cause.js";
5
- import { MIGRATION_LOCK_TABLE, MIGRATION_TABLE, migrationKysely } from "./bookkeeping.js";
6
+ import { MIGRATION_LOCK_TABLE, MIGRATION_TABLE, appliedMigrationChain, migrationKysely } from "./bookkeeping.js";
6
7
  import { batchedProvider } from "./batch.js";
8
+ import { forgetMigrationGroups, generatedMigrationGroup, readGroupPosition, recordMigrationGroup } from "./groups.js";
7
9
  import { RetainedBudget, guardRetained, isDownRefusal } from "./retained.js";
8
10
  import { sql } from "kysely";
9
11
  import { Migrator, NO_MIGRATIONS } from "kysely/migration";
@@ -31,6 +33,12 @@ import { Migrator, NO_MIGRATIONS } from "kysely/migration";
31
33
  * are argued.** The short version: a migration is now all-or-nothing where it used to be able to
32
34
  * half-apply, and nothing across a migration boundary changed, because the ledger names migrations
33
35
  * and a partial chain has to stay representable in it.
36
+ *
37
+ * **Every run belongs to a group, and a reversal names one — see `./groups` (#694).** A forward run
38
+ * records the group it applied each migration under, in a table beside the ledger;
39
+ * {@link reverseMigrationGroup} reverses one group's portion of one database, in reverse chain order and
40
+ * nothing outside it. {@link rollbackMigration} is still the single step underneath, for a caller holding
41
+ * one migration rather than a release — it is not what `pithy migrate --rollback` runs any more.
34
42
  */
35
43
  /**
36
44
  * **`allowUnorderedMigrations` is on, and it is not a loosening.** A composed key leads with its
@@ -63,9 +71,30 @@ function guardOptions(target, consent) {
63
71
  budget: consent?.budget ?? new RetainedBudget(void 0)
64
72
  };
65
73
  }
66
- /** Run every pending migration to latest. An empty provider resolves to `[]`. */
67
- async function runMigrations(database, provider, target) {
74
+ /**
75
+ * Run every pending migration to latest, recording the run's group against each one. An empty provider
76
+ * resolves to `[]` and records nothing.
77
+ *
78
+ * **The group is written from what Kysely hands back, and before the failure is raised.** A run that
79
+ * half-applies is retried with the key it already has, so the migrations that stuck have to be in the
80
+ * group — otherwise the retry's group would sit on top of an ungrouped migration, and reversing the
81
+ * retry would leave half a release applied with nothing recording it.
82
+ */
83
+ async function runMigrations(database, provider, target, options) {
68
84
  const { error, results } = await migrator(database, provider, target, void 0).migrateToLatest();
85
+ const applied = (results ?? []).filter((result) => result.direction === "Up" && result.status === "Success").map((result) => result.migrationName);
86
+ try {
87
+ await recordMigrationGroup(database, {
88
+ group: options?.group ?? generatedMigrationGroup(),
89
+ migrations: applied
90
+ });
91
+ } catch (cause) {
92
+ if (error === void 0) throw new InternalError({
93
+ message: `Applied ${applied.length === 1 ? "a migration" : `${applied.length} migrations`}${on(target)}, then couldn't record the group.`,
94
+ detail: `${where(target)} ${reasonOf(cause)}`,
95
+ action: "Check the database is writable. A second run records nothing for them, so they cannot be reversed by group."
96
+ }, { cause });
97
+ }
69
98
  return settle("run", error, results, target);
70
99
  }
71
100
  /**
@@ -94,15 +123,79 @@ async function readMigrationLedger(database, provider) {
94
123
  };
95
124
  }
96
125
  /**
97
- * Step this database's latest applied migration back — one step, per database. `pithy migrate --rollback`
98
- * calls it once for **every** database in its fan-out. Refused while a retained table here holds rows the
99
- * caller has not counted in `consent` (#588).
126
+ * Step this database's latest applied migration back — **one** step, in **one** database.
127
+ *
128
+ * It is no longer what `pithy migrate --rollback` runs: a rollback names a group and reverses that
129
+ * group's portion of each database ({@link reverseMigrationGroup}, #694). What this is, is the single
130
+ * step underneath — the primitive every capability's "its `down` is the inverse of its `up`" suite
131
+ * reverses one migration with, and the honest answer for a caller holding one migration rather than a
132
+ * release.
133
+ *
134
+ * It forgets the reversed migration's group row, so the group bookkeeping follows the ledger whichever
135
+ * way a migration came down. Refused while a retained table here holds rows the caller has not counted in
136
+ * `consent` (#588).
100
137
  */
101
138
  async function rollbackMigration(database, provider, target, consent) {
102
139
  const { error, results } = await migrator(database, provider, target, consent).migrateDown();
140
+ await forgetReversed(database, results);
103
141
  return settle("rollback", error, results, target);
104
142
  }
105
143
  /**
144
+ * **Reverse one group's portion of one database — the operation behind `pithy migrate --rollback
145
+ * --group` (#694).**
146
+ *
147
+ * Kysely steps down from the tip, so this is `migrateDown()` once per migration in the group, in reverse
148
+ * chain order, and **nothing outside it**. Both halves of that are read from the ledger here, not taken on
149
+ * the caller's word: the group has to be the ledger's own tail before the first `down`, and what each step
150
+ * brings down has to be one of the migrations that read said the group holds — anything else is an
151
+ * internal fault and throws rather than carrying on. **And a migration with no `down` stops the group**:
152
+ * Kysely reports one as `NotExecuted` and leaves it applied, so counting it as reversed is how a group
153
+ * reported itself fully rolled back with half of it still in the database. The caller's pre-flight is what
154
+ * makes a group reverse completely or not at all across databases; this is the floor under it.
155
+ *
156
+ * **One migrator for the whole group**, so the retained guard clears once and the caller's budget is spent
157
+ * once across every `down` it covers (#588). Each migration's group row is forgotten as it comes down —
158
+ * before the step's failure is raised — so a group reversal that dies partway can be re-run under the
159
+ * same group and reverses what is left.
160
+ */
161
+ async function reverseMigrationGroup(database, provider, step, target, consent) {
162
+ if (step.migrations.length === 0) return [];
163
+ const position = await readGroupPosition(database, step.group);
164
+ if (position.state !== "top") throw new InternalError({
165
+ message: `Group "${step.group}" is not the top of ${target?.binding ?? "this database"}'s chain.`,
166
+ detail: `${where(target)} ${position.state === "absent" ? "It records nothing under that group." : `Applied over it: ${overIt(position.above, position.ungrouped)}.`}`,
167
+ action: `Run ${pithyCommand("doctor")} to see where each database stands.`
168
+ });
169
+ const remaining = new Set(position.migrations);
170
+ const runner = migrator(database, provider, target, consent);
171
+ const reversed = [];
172
+ while (remaining.size > 0) {
173
+ const { error, results } = await runner.migrateDown();
174
+ await forgetReversed(database, results);
175
+ const settled = settle("rollback", error, results, target);
176
+ if (settled.length === 0) break;
177
+ for (const result of settled) {
178
+ if (result.status !== "Success") throw new ValidationError({
179
+ message: `Migration "${result.migrationName}" has no down, so group "${step.group}" cannot be reversed${on(target)}.`,
180
+ action: `Give ${result.migrationName} a down, then reverse the group again.`,
181
+ detail: [where(target), reversed.length === 0 ? "Nothing was reversed. A group reverses completely or not at all." : `Reversed before the refusal: ${reversed.map((entry) => `"${entry.migrationName}"`).join(", ")}. The rest of the group is still applied.`].filter(Boolean).join(" ")
182
+ });
183
+ if (!remaining.delete(result.migrationName)) throw new InternalError({
184
+ message: `Reversing group "${step.group}"${on(target)} would have reversed "${result.migrationName}", which is not in it.`,
185
+ detail: `${where(target)} The group was not the top of this database's chain. Kysely steps down from the tip, so only the newest group can be reversed.`,
186
+ action: `Run ${pithyCommand("doctor")} to see where each database stands.`
187
+ });
188
+ reversed.push(result);
189
+ }
190
+ }
191
+ return reversed;
192
+ }
193
+ /** Forget the group rows of whatever actually came down, whether or not the step then failed. */
194
+ async function forgetReversed(database, results) {
195
+ const names = (results ?? []).filter((result) => result.direction === "Down" && result.status === "Success").map((result) => result.migrationName);
196
+ await forgetMigrationGroups(database, names);
197
+ }
198
+ /**
106
199
  * Fully reset one database's schema: every applied migration's `down` runs, in one pass, in reverse
107
200
  * chronological order — Kysely's `NO_MIGRATIONS` target, not just the latest — then every migration's
108
201
  * `up` reapplies from empty. The seam behind `pithy seed --redo`'s destructive rebuild: because the
@@ -110,6 +203,16 @@ async function rollbackMigration(database, provider, target, consent) {
110
203
  * skip-if-exists) simply work afterward — there is no per-row identity problem to solve. An empty
111
204
  * ledger rolls back nothing; an empty provider reapplies nothing. Refused, before the first `down`, while a
112
205
  * retained table here holds rows the caller has not counted in `consent` (#588).
206
+ *
207
+ * **It leaves the group bookkeeping exactly as it was (#694).** The table is not a migration, so the `down`
208
+ * pass cannot remove it, and rewriting the rows would restamp somebody's release as having happened during
209
+ * a `seed --redo`. Every group row still describes a migration this reset reapplied.
210
+ *
211
+ * **What it does not do is record a group for the rest.** A reset reapplies everything *declared*, which
212
+ * includes migrations that were pending when it started, and those come back applied under no group — this
213
+ * takes no group and invents none. Such a migration sits over the groups below it, so a `--rollback
214
+ * --group` of one of them is refused by name (`./groups`, `readGroupPosition`) rather than reversing a
215
+ * migration nobody recorded.
113
216
  */
114
217
  async function resetMigrations(database, provider, target, consent) {
115
218
  const runner = migrator(database, provider, target, consent);
@@ -120,14 +223,12 @@ async function resetMigrations(database, provider, target, consent) {
120
223
  return [...downResults, ...upResults];
121
224
  }
122
225
  /**
123
- * The applied migration names recorded in the ledger — empty when the ledger table doesn't exist yet.
124
- * Existence is checked against `sqlite_master` (a plain select D1 permits) rather than by catching the
125
- * read's error, so a genuine read failure surfaces instead of being silently treated as "none applied".
226
+ * The applied migration names recorded in the ledger — empty when the ledger table doesn't exist yet. The
227
+ * set half of `appliedMigrationChain`, which is the one read of the ledger: order matters to a group
228
+ * rollback and not at all to a membership test, and two reads of one table would drift.
126
229
  */
127
230
  async function appliedMigrationNames(db) {
128
- if ((await sql`select name from sqlite_master where type = 'table' and name = ${"pithy_migrations"}`.execute(db)).rows.length === 0) return /* @__PURE__ */ new Set();
129
- const { rows } = await sql`select name from ${sql.table(MIGRATION_TABLE)}`.execute(db);
130
- return new Set(rows.map((row) => row.name));
231
+ return new Set(await appliedMigrationChain(db));
131
232
  }
132
233
  /**
133
234
  * Surgically drop **one capability's** migrations: run each of `drop.reverse`'s `down` functions in
@@ -160,6 +261,7 @@ async function dropMigrations(database, drop, target, consent) {
160
261
  try {
161
262
  await down(db);
162
263
  await sql`delete from ${sql.table(MIGRATION_TABLE)} where name = ${name}`.execute(db);
264
+ await forgetMigrationGroups(database, [name]);
163
265
  results.push({
164
266
  migrationName: name,
165
267
  direction: "Down",
@@ -171,7 +273,7 @@ async function dropMigrations(database, drop, target, consent) {
171
273
  throw new InternalError({
172
274
  message: `Couldn't drop "${name}"${on(target)}. ${reasonOf(error)}`,
173
275
  detail: `${where(target)}${dropped.length ? ` Dropped before the failure: ${dropped.join(", ")}.` : ""}`,
174
- action: "Fix the migration's down, or drop its table by hand. Run pithy remove --drop again."
276
+ action: "Fix the migration's down, or drop its table by hand. Then run this again."
175
277
  }, { cause: error });
176
278
  }
177
279
  }
@@ -182,24 +284,33 @@ const VOICE = {
182
284
  run: {
183
285
  failed: (key) => `Couldn't apply "${key}"`,
184
286
  fallback: "The migration run failed",
185
- action: "Fix the migration. Run pithy migrate again."
287
+ action: "Fix the migration, then run this again."
186
288
  },
187
289
  rollback: {
188
290
  failed: (key) => `Couldn't roll back "${key}"`,
189
291
  fallback: "The rollback failed",
190
- action: "Fix the migration's down. Run pithy doctor to see where each database stands."
292
+ action: `Fix the migration's down. Run ${pithyCommand("doctor")} to see where each database stands.`
191
293
  },
192
294
  resetDown: {
193
295
  failed: (key) => `Couldn't roll back "${key}" during reset`,
194
296
  fallback: "The schema reset failed while rolling back",
195
- action: "Fix the migration. Run pithy seed --redo again."
297
+ action: "Fix the migration, then run this again."
196
298
  },
197
299
  resetUp: {
198
300
  failed: (key) => `Couldn't reapply "${key}" during reset`,
199
301
  fallback: "The schema reset failed while reapplying",
200
- action: "Fix the migration. Run pithy seed --redo again."
302
+ action: "Fix the migration, then run this again."
201
303
  }
202
304
  };
305
+ /**
306
+ * What sits over a buried group, groups and ungrouped migrations alike, newest first.
307
+ *
308
+ * An ungrouped migration is named with its state, because the two are different problems: another group is
309
+ * something to reverse first, while a migration no group claims cannot be reversed by group at all.
310
+ */
311
+ function overIt(above, ungrouped) {
312
+ return [...above, ...ungrouped.map((name) => `${name} (ungrouped)`)].join(", ");
313
+ }
203
314
  /** ` on DB`, or nothing at all — every problem line here ends with this and then a period. */
204
315
  function on(target) {
205
316
  return target ? ` on ${target.binding}` : "";
@@ -243,4 +354,4 @@ function settle(verb, error, results, target) {
243
354
  return results ?? [];
244
355
  }
245
356
  //#endregion
246
- export { dropMigrations, readMigrationLedger, resetMigrations, rollbackMigration, runMigrations };
357
+ export { dropMigrations, readMigrationLedger, resetMigrations, reverseMigrationGroup, rollbackMigration, runMigrations };
@@ -24,6 +24,23 @@ import { type AmbientEnv } from "../env/ambient";
24
24
  * them rather than a side effect of a parser version.
25
25
  */
26
26
  export declare function isPublicHostname(hostname: string): boolean;
27
+ /**
28
+ * Why a string is not a hostname a Worker can answer on — the reason, not a boolean.
29
+ *
30
+ * **It exists so a refusal can say which rule it broke.** {@link isPublicHostname} is the answer when the
31
+ * caller only needs yes or no; `WorkerDomain` needs the reason, because the three are not interchangeable
32
+ * to the adopter reading the message. A name over the limit is a typo. A name with a scheme on it is a
33
+ * misunderstanding. An internationalized name is neither — it is a thing this kit cannot do, for a reason
34
+ * that is not this kit's, and a message about bare hostnames would send that adopter looking for a typo
35
+ * they do not have (#665).
36
+ *
37
+ * Ordered by how specific the answer is. The length check is first because a name that long is over the
38
+ * limit whatever else is wrong with it, and `punycode` last because it is only reachable for a string that
39
+ * is otherwise a well-formed hostname.
40
+ */
41
+ export declare function publicHostnameProblem(hostname: string): HostnameProblem | null;
42
+ /** The three ways a string fails {@link publicHostnameProblem}, each with its own sentence to say. */
43
+ export type HostnameProblem = "too-long" | "shape" | "punycode";
27
44
  /** The environments a domain may be declared for — every managed one, never `dev`. */
28
45
  export declare const DOMAIN_ENVIRONMENTS: ("prod" | "staging")[];
29
46
  /** One environment's public address for one Worker. */
@@ -1 +1 @@
1
- {"version":3,"file":"domains.d.ts","sourceRoot":"","sources":["../../src/naming/domains.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EAAE,KAAK,UAAU,EAAc,MAAM,gBAAgB,CAAC;AAgD7D;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAE1D;AAED,sFAAsF;AACtF,eAAO,MAAM,mBAAmB,wBAA8D,CAAC;AAE/F,uDAAuD;AACvD,eAAO,MAAM,YAAY;;;iBA+BrB,CAAC;AACL,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,YAAY,CAAC,CAAC;AAExD;;;;;GAKG;AACH,eAAO,MAAM,aAAa;;;;;;;;;iBAOvB,CAAC;AACJ,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,aAAa,CAAC,CAAC;AAE1D;;;;;;GAMG;AACH,wBAAgB,SAAS,CAAC,OAAO,EAAE,aAAa,GAAG,SAAS,EAAE,WAAW,EAAE,MAAM,GAAG,SAAS,GAAG,YAAY,GAAG,IAAI,CAKlH;AAED,gHAAgH;AAChH,wBAAgB,UAAU,CAAC,MAAM,EAAE,YAAY,GAAG,MAAM,CAEvD;AAED;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,YAAY,qBAAqB,CAAC;AAE/C,yFAAyF;AACzF,MAAM,WAAW,cAAc;IAC7B,qEAAqE;IACrE,MAAM,EAAE,MAAM,CAAC;IACf,iFAAiF;IACjF,QAAQ,EAAE,MAAM,CAAC;IACjB;;;OAGG;IACH,QAAQ,EAAE,OAAO,CAAC;CACnB;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,GAAG;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAetG;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,aAAa,CAC3B,WAAW,EAAE,MAAM,GAAG,SAAS,EAC/B,OAAO,EAAE,aAAa,GAAG,SAAS,EAClC,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,GAC3B,cAAc,CAShB;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,SAAS,CACvB,WAAW,EAAE,MAAM,GAAG,SAAS,EAC/B,OAAO,EAAE,aAAa,GAAG,SAAS,EAClC,GAAG,GAAE,UAAyB,GAC7B,MAAM,CAER"}
1
+ {"version":3,"file":"domains.d.ts","sourceRoot":"","sources":["../../src/naming/domains.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EAAE,KAAK,UAAU,EAAc,MAAM,gBAAgB,CAAC;AAgD7D;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAE1D;AAKD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,MAAM,GAAG,eAAe,GAAG,IAAI,CAK9E;AAED,sGAAsG;AACtG,MAAM,MAAM,eAAe,GAAG,UAAU,GAAG,OAAO,GAAG,UAAU,CAAC;AAyChE,sFAAsF;AACtF,eAAO,MAAM,mBAAmB,wBAA8D,CAAC;AAE/F,uDAAuD;AACvD,eAAO,MAAM,YAAY;;;iBAwBrB,CAAC;AACL,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,YAAY,CAAC,CAAC;AAExD;;;;;GAKG;AACH,eAAO,MAAM,aAAa;;;;;;;;;iBAOvB,CAAC;AACJ,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,aAAa,CAAC,CAAC;AAE1D;;;;;;GAMG;AACH,wBAAgB,SAAS,CAAC,OAAO,EAAE,aAAa,GAAG,SAAS,EAAE,WAAW,EAAE,MAAM,GAAG,SAAS,GAAG,YAAY,GAAG,IAAI,CAKlH;AAED,gHAAgH;AAChH,wBAAgB,UAAU,CAAC,MAAM,EAAE,YAAY,GAAG,MAAM,CAEvD;AAED;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,YAAY,qBAAqB,CAAC;AAE/C,yFAAyF;AACzF,MAAM,WAAW,cAAc;IAC7B,qEAAqE;IACrE,MAAM,EAAE,MAAM,CAAC;IACf,iFAAiF;IACjF,QAAQ,EAAE,MAAM,CAAC;IACjB;;;OAGG;IACH,QAAQ,EAAE,OAAO,CAAC;CACnB;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,GAAG;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAetG;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,aAAa,CAC3B,WAAW,EAAE,MAAM,GAAG,SAAS,EAC/B,OAAO,EAAE,aAAa,GAAG,SAAS,EAClC,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,GAC3B,cAAc,CAShB;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,SAAS,CACvB,WAAW,EAAE,MAAM,GAAG,SAAS,EAC/B,OAAO,EAAE,aAAa,GAAG,SAAS,EAClC,GAAG,GAAE,UAAyB,GAC7B,MAAM,CAER"}
@@ -68,14 +68,72 @@ const PUNYCODE_LABEL = /(^|\.)xn--/;
68
68
  * them rather than a side effect of a parser version.
69
69
  */
70
70
  function isPublicHostname(hostname) {
71
- return hostname.length <= 253 && HOSTNAME_PATTERN.test(hostname) && !PUNYCODE_LABEL.test(hostname);
71
+ return publicHostnameProblem(hostname) === null;
72
+ }
73
+ /** The DNS limit on a whole name, and the only length any caller measures. */
74
+ const MAX_HOSTNAME_LENGTH = 253;
75
+ /**
76
+ * Why a string is not a hostname a Worker can answer on — the reason, not a boolean.
77
+ *
78
+ * **It exists so a refusal can say which rule it broke.** {@link isPublicHostname} is the answer when the
79
+ * caller only needs yes or no; `WorkerDomain` needs the reason, because the three are not interchangeable
80
+ * to the adopter reading the message. A name over the limit is a typo. A name with a scheme on it is a
81
+ * misunderstanding. An internationalized name is neither — it is a thing this kit cannot do, for a reason
82
+ * that is not this kit's, and a message about bare hostnames would send that adopter looking for a typo
83
+ * they do not have (#665).
84
+ *
85
+ * Ordered by how specific the answer is. The length check is first because a name that long is over the
86
+ * limit whatever else is wrong with it, and `punycode` last because it is only reachable for a string that
87
+ * is otherwise a well-formed hostname.
88
+ */
89
+ function publicHostnameProblem(hostname) {
90
+ if (hostname.length > MAX_HOSTNAME_LENGTH) return "too-long";
91
+ if (!HOSTNAME_PATTERN.test(hostname)) return "shape";
92
+ if (PUNYCODE_LABEL.test(hostname)) return "punycode";
93
+ return null;
94
+ }
95
+ /**
96
+ * What an adopter is told, per reason, for a hostname this kit refuses.
97
+ *
98
+ * `shape` is the caller's to say, because what a bare hostname is differs between a `pattern` and a
99
+ * `zone` and each already had a sentence worth keeping. The other two are the same wherever they are hit.
100
+ *
101
+ * **The internationalized-domain sentence names Cloudflare**, and that is deliberate: the limit is not
102
+ * ours. `pithy-sh/pithy#691` tested it against a real zone — adding `tëst.pithy-sh.com` as a Worker
103
+ * Custom Domain returns *Internationalized Domain Names not supported* — so the kit refuses what the
104
+ * platform will not route rather than inventing a rule of its own. `docs/ACCEPTED-LIMITS.md` carries the
105
+ * evidence and the condition that would lift it.
106
+ */
107
+ const HOSTNAME_PROBLEM_MESSAGES = {
108
+ "too-long": `A hostname is at most ${MAX_HOSTNAME_LENGTH} characters — the DNS limit on a whole name.`,
109
+ punycode: "Internationalized domains are not supported: Cloudflare Workers will not route one, so a Worker could never answer on it. Use an ASCII hostname."
110
+ };
111
+ /**
112
+ * One `pattern` or `zone` field, held to the one hostname rule.
113
+ *
114
+ * **Through {@link publicHostnameProblem}, never `HOSTNAME_PATTERN` directly.** A configured domain was
115
+ * the third caller of that rule and the only one that did not go through the function — it handed the
116
+ * pattern to `.regex()`, so it took a punycode A-label and a 267-character name that `pithy seed --host`
117
+ * refused for the same string. Two answers to one question, forty lines apart (#665). A rule added to the
118
+ * function now reaches a configured domain without anyone remembering to.
119
+ */
120
+ function hostnameField(shapeMessage) {
121
+ return z.string().check((ctx) => {
122
+ const problem = publicHostnameProblem(ctx.value);
123
+ if (problem === null) return;
124
+ ctx.issues.push({
125
+ code: "custom",
126
+ input: ctx.value,
127
+ message: problem === "shape" ? shapeMessage : HOSTNAME_PROBLEM_MESSAGES[problem]
128
+ });
129
+ });
72
130
  }
73
131
  /** The environments a domain may be declared for — every managed one, never `dev`. */
74
132
  const DOMAIN_ENVIRONMENTS = ENVIRONMENTS.filter((environment) => environment !== "dev");
75
133
  /** One environment's public address for one Worker. */
76
134
  const WorkerDomain = z.object({
77
- pattern: z.string().regex(HOSTNAME_PATTERN, "A domain is a bare hostname — no scheme, no path, no port (e.g. `api.example.com`, not `https://api.example.com/`).").describe("The hostname this Worker answers on in this environment, e.g. `api.example.com`. Written into `wrangler.jsonc` as a `routes` entry with `custom_domain: true`, and into `vars.BASE_URL` as `https://<pattern>`. A bare hostname rather than a URL, because that is what wrangler's route matcher takes — a scheme here would be silently wrong."),
78
- zone: z.string().regex(HOSTNAME_PATTERN, "A zone is the registrable domain on your Cloudflare account, e.g. `example.com`.").describe("The Cloudflare zone `pattern` sits under — the registrable domain as it appears on your account, e.g. `example.com` for `api.example.com`. Cloudflare needs it to attach a custom domain, and it is not always derivable from the hostname: a zone can be a subdomain, and a public-suffix guess would be wrong for exactly the adopters who are hardest to debug.")
135
+ pattern: hostnameField("A domain is a bare hostname — no scheme, no path, no port (e.g. `api.example.com`, not `https://api.example.com/`).").describe("The hostname this Worker answers on in this environment, e.g. `api.example.com`. Written into `wrangler.jsonc` as a `routes` entry with `custom_domain: true`, and into `vars.BASE_URL` as `https://<pattern>`. A bare hostname rather than a URL, because that is what wrangler's route matcher takes — a scheme here would be silently wrong."),
136
+ zone: hostnameField("A zone is the registrable domain on your Cloudflare account, e.g. `example.com`.").describe("The Cloudflare zone `pattern` sits under — the registrable domain as it appears on your account, e.g. `example.com` for `api.example.com`. Cloudflare needs it to attach a custom domain, and it is not always derivable from the hostname: a zone can be a subdomain, and a public-suffix guess would be wrong for exactly the adopters who are hardest to debug.")
79
137
  }).describe("Where one Worker answers in one environment: the hostname, and the Cloudflare zone it sits under.").check((ctx) => {
80
138
  const { pattern, zone } = ctx.value;
81
139
  if (pattern !== zone && !pattern.endsWith(`.${zone}`)) ctx.issues.push({
@@ -234,4 +292,4 @@ function originFor(environment, domains, env = ambientEnv()) {
234
292
  return resolveOrigin(environment, domains, env[BASE_URL_VAR]).origin;
235
293
  }
236
294
  //#endregion
237
- export { DOMAIN_ENVIRONMENTS, LOCAL_ORIGIN, WorkerDomain, WorkerDomains, baseUrlFor, domainFor, featureOrigin, isPublicHostname, originFor, resolveOrigin };
295
+ export { DOMAIN_ENVIRONMENTS, LOCAL_ORIGIN, WorkerDomain, WorkerDomains, baseUrlFor, domainFor, featureOrigin, isPublicHostname, originFor, publicHostnameProblem, resolveOrigin };
@@ -1 +1 @@
1
- {"version":3,"file":"environment.d.ts","sourceRoot":"","sources":["../../src/naming/environment.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAIxB;;;;;;;;GAQG;AAEH;;;;;GAKG;AACH,eAAO,MAAM,YAAY,YAAI,KAAK,EAAE,SAAS,EAAE,MAAM,CAAU,CAAC;AAEhE,wGAAwG;AACxG,MAAM,MAAM,WAAW,GAAG,CAAC,OAAO,YAAY,CAAC,CAAC,MAAM,CAAC,CAAC;AAExD;;;;;;GAMG;AACH,eAAO,MAAM,YAAY,WAAW,CAAC;AAErC;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,mBAAmB,YAAY,CAAC;AAE7C;;;;;;;;GAQG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEnD;AAKD,kHAAkH;AAClH,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAExD;AAgBD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,oBAAoB,QAAqE,CAAC;AAEvG;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAIxD;AAED;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAmCzD;AAED;;;;;;GAMG;AACH,eAAO,MAAM,oBAAoB,YAAI,SAAS,EAAE,MAAM,CAAU,CAAC;AAEjE;;;;;;;GAOG;AACH,eAAO,MAAM,iBAAiB,QAAQ,CAAC;AAEvC;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,eAAO,MAAM,oBAAoB,yBA2D9B,CAAC;AAEJ,sGAAsG;AACtG,MAAM,MAAM,oBAAoB,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,oBAAoB,CAAC,CAAC"}
1
+ {"version":3,"file":"environment.d.ts","sourceRoot":"","sources":["../../src/naming/environment.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAKxB;;;;;;;;GAQG;AAEH;;;;;GAKG;AACH,eAAO,MAAM,YAAY,YAAI,KAAK,EAAE,SAAS,EAAE,MAAM,CAAU,CAAC;AAEhE,wGAAwG;AACxG,MAAM,MAAM,WAAW,GAAG,CAAC,OAAO,YAAY,CAAC,CAAC,MAAM,CAAC,CAAC;AAExD;;;;;;GAMG;AACH,eAAO,MAAM,YAAY,WAAW,CAAC;AAErC;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,mBAAmB,YAAY,CAAC;AAE7C;;;;;;;;GAQG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEnD;AAKD,kHAAkH;AAClH,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAExD;AAgBD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,oBAAoB,QAAqE,CAAC;AAEvG;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAIxD;AAED;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAmCzD;AAED;;;;;;GAMG;AACH,eAAO,MAAM,oBAAoB,YAAI,SAAS,EAAE,MAAM,CAAU,CAAC;AAEjE;;;;;;;GAOG;AACH,eAAO,MAAM,iBAAiB,QAAQ,CAAC;AAEvC;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,eAAO,MAAM,oBAAoB,yBA2D9B,CAAC;AAEJ,sGAAsG;AACtG,MAAM,MAAM,oBAAoB,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,oBAAoB,CAAC,CAAC"}
@@ -1,6 +1,7 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
3
  import { PithyError, ValidationError } from "../error/pithyError.js";
4
+ import { commandName } from "../cli/command.js";
4
5
  import { NAME_SEGMENT } from "./segment.js";
5
6
  import { z } from "zod";
6
7
  //#region src/naming/environment.ts
@@ -129,7 +130,7 @@ function assertValidEnvironment(name) {
129
130
  if (startsWithFeatureMarker(name)) throw new ValidationError({
130
131
  message: `"${name}" starts the way a feature's name does.`,
131
132
  action: "Start the environment with something other than f and a number.",
132
- detail: `A feature takes the environment's slot with f<issue>, so <project>-${name}-<worker> can be a feature's Worker name exactly, and \`pithy feature destroy\` deletes that Worker.`
133
+ detail: `A feature takes the environment's slot with f<issue>, so <project>-${name}-<worker> can be a feature's Worker name exactly, and \`${commandName("feature destroy")}\` deletes that Worker.`
133
134
  });
134
135
  if (name.length > MAX_ENVIRONMENT_NAME && NAME_SEGMENT.test(name)) throw new ValidationError({
135
136
  message: `"${name}" is ${name.length} characters. An environment stops at ${MAX_ENVIRONMENT_NAME}.`,
@@ -0,0 +1,17 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * The package a bare specifier names: `@pithy-sh/payments` from `@pithy-sh/payments/src/capability`, and
5
+ * `stripe` from `stripe/lib/x` — **scope-aware, and the only copy in the kit (#709)**.
6
+ *
7
+ * There were five. Three took the first segment or two depending on a leading `@`; two took the first two
8
+ * segments whatever they were, which is right for every `@pithy-sh/*` name they were written against and
9
+ * wrong for the first unscoped dependency anyone passed them. A rule that small drifts precisely because
10
+ * nobody thinks it needs a home, so it has one. That it is the only copy is a gate, not a claim: `packages/cli/src/ci/packageOfCopies.test.ts` fails on any source that writes the rule out by hand, because the first migration left three behind and said it had not.
11
+ *
12
+ * Total, and deliberately unopinionated about what it is handed: a caller with a relative path, a `#`
13
+ * import or a URL gets the first segment back. Deciding whether a specifier is a package at all is the
14
+ * unresolved-import classifier's job (`error/unresolved.ts`), not this function's.
15
+ */
16
+ export declare function packageOf(specifier: string): string;
17
+ //# sourceMappingURL=packageName.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"packageName.d.ts","sourceRoot":"","sources":["../../src/naming/packageName.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;GAYG;AACH,wBAAgB,SAAS,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAGnD"}
@@ -0,0 +1,22 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ //#region src/naming/packageName.ts
4
+ /**
5
+ * The package a bare specifier names: `@pithy-sh/payments` from `@pithy-sh/payments/src/capability`, and
6
+ * `stripe` from `stripe/lib/x` — **scope-aware, and the only copy in the kit (#709)**.
7
+ *
8
+ * There were five. Three took the first segment or two depending on a leading `@`; two took the first two
9
+ * segments whatever they were, which is right for every `@pithy-sh/*` name they were written against and
10
+ * wrong for the first unscoped dependency anyone passed them. A rule that small drifts precisely because
11
+ * nobody thinks it needs a home, so it has one. That it is the only copy is a gate, not a claim: `packages/cli/src/ci/packageOfCopies.test.ts` fails on any source that writes the rule out by hand, because the first migration left three behind and said it had not.
12
+ *
13
+ * Total, and deliberately unopinionated about what it is handed: a caller with a relative path, a `#`
14
+ * import or a URL gets the first segment back. Deciding whether a specifier is a package at all is the
15
+ * unresolved-import classifier's job (`error/unresolved.ts`), not this function's.
16
+ */
17
+ function packageOf(specifier) {
18
+ const parts = specifier.split("/");
19
+ return specifier.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0] ?? specifier;
20
+ }
21
+ //#endregion
22
+ export { packageOf };
@@ -1 +1 @@
1
- {"version":3,"file":"provisionScope.d.ts","sourceRoot":"","sources":["../../src/naming/provisionScope.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,oBAAoB,CAAC;AAEhE,OAAO,EACL,KAAK,eAAe,EACpB,KAAK,mBAAmB,EAIzB,MAAM,WAAW,CAAC;AAInB;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH;;;;;;GAMG;AACH,MAAM,MAAM,eAAe,GAAG,aAAa,GAAG,QAAQ,CAAC;AAEvD;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,aAAa;IAC5B;;;;;OAKG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,eAAe,CAAC;IACjC;;;OAGG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,mBAAoB,SAAQ,aAAa;IACxD,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAC;CAC1B;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,WAAW,oBAAoB;IACnC,2GAA2G;IAC3G,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,oGAAoG;IACpG,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED,uFAAuF;AACvF,MAAM,WAAW,cAAc;IAC7B,6FAA6F;IAC7F,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB;;;;;;;;;;;;;;OAcG;IACH,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IACzB;;;;;;;;;;;OAWG;IACH,QAAQ,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,mBAAmB,EAAE,MAAM,EAAE,aAAa,GAAG,MAAM,CAAC;IACpF;;;;;;;;;OASG;IACH,QAAQ,CAAC,YAAY,EAAE,OAAO,CAAC;IAC/B;;;;;;;;;;OAUG;IACH,MAAM,CAAC,MAAM,EAAE,oBAAoB,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IAChE;;;;;;;OAOG;IACH,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,eAAe,GAAG,MAAM,CAAC;IAClE;;;;;;;;OAQG;IACH,QAAQ,CAAC,YAAY,EAAE,IAAI,CAAC,qBAAqB,EAAE,YAAY,CAAC,CAAC;CAClE;AAiBD;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,mBAAmB,EACzB,MAAM,EAAE,mBAAmB,GAC1B,MAAM,CAAC;AACV,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,mBAAmB,EACzB,MAAM,EAAE,aAAa,EACrB,MAAM,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,GAChC,MAAM,CAAC;AAsBV;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAKpE;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,cAAc,CA8BrF;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,eAAe,GAAG,cAAc,CAiBtE;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,eAAe,EAAE,MAAM,EAAE,oBAAoB,GAAG,MAAM,EAAE,CAY1G"}
1
+ {"version":3,"file":"provisionScope.d.ts","sourceRoot":"","sources":["../../src/naming/provisionScope.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,oBAAoB,CAAC;AAEhE,OAAO,EACL,KAAK,eAAe,EACpB,KAAK,mBAAmB,EAIzB,MAAM,WAAW,CAAC;AAInB;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH;;;;;;GAMG;AACH,MAAM,MAAM,eAAe,GAAG,aAAa,GAAG,QAAQ,CAAC;AAEvD;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,aAAa;IAC5B;;;;;OAKG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,eAAe,CAAC;IACjC;;;OAGG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,mBAAoB,SAAQ,aAAa;IACxD,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAC;CAC1B;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,WAAW,oBAAoB;IACnC,2GAA2G;IAC3G,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,oGAAoG;IACpG,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED,uFAAuF;AACvF,MAAM,WAAW,cAAc;IAC7B,6FAA6F;IAC7F,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB;;;;;;;;;;;;;;OAcG;IACH,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IACzB;;;;;;;;;;;OAWG;IACH,QAAQ,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,mBAAmB,EAAE,MAAM,EAAE,aAAa,GAAG,MAAM,CAAC;IACpF;;;;;;;;;OASG;IACH,QAAQ,CAAC,YAAY,EAAE,OAAO,CAAC;IAC/B;;;;;;;;;;OAUG;IACH,MAAM,CAAC,MAAM,EAAE,oBAAoB,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IAChE;;;;;;;OAOG;IACH,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,eAAe,GAAG,MAAM,CAAC;IAClE;;;;;;;;OAQG;IACH,QAAQ,CAAC,YAAY,EAAE,IAAI,CAAC,qBAAqB,EAAE,YAAY,CAAC,CAAC;CAClE;AAiBD;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,mBAAmB,EACzB,MAAM,EAAE,mBAAmB,GAC1B,MAAM,CAAC;AACV,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,mBAAmB,EACzB,MAAM,EAAE,aAAa,EACrB,MAAM,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,GAChC,MAAM,CAAC;AAsBV;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAKpE;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,cAAc,CA8BrF;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,eAAe,GAAG,cAAc,CAiBtE;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,eAAe,EAAE,MAAM,EAAE,oBAAoB,GAAG,MAAM,EAAE,CAY1G"}
@@ -1,6 +1,7 @@
1
1
  // SPDX-FileCopyrightText: 2026 Pithy
2
2
  // SPDX-License-Identifier: MIT
3
3
  import { InternalError, ValidationError } from "../error/pithyError.js";
4
+ import { commandName } from "../cli/command.js";
4
5
  import { FEATURE_ENVIRONMENT, assertValidEnvironment, isFeatureMarker } from "./environment.js";
5
6
  import { kebab } from "./resource.js";
6
7
  import { featureResourceName, featureSecretEntryName, featureWorkerName } from "./feature.js";
@@ -93,7 +94,7 @@ function environmentScope(project, environment) {
93
94
  if (isFeatureName(project, name)) throw new ValidationError({
94
95
  message: `The ${environment} Worker "${name}" is named the way a feature's Worker is.`,
95
96
  action: `Rename it in env.${environment}.name, or the Worker's top-level name, so it does not follow ${kebab(project)}- with f and a number.`,
96
- detail: `${declared === void 0 ? "Composed by wrangler from the deploy name" : `Declared in env.${environment}.name`}. A feature's Worker is <project>-f<issue>-<slug>-<app>, and \`pithy feature destroy\` deletes it by that exact name.`
97
+ detail: `${declared === void 0 ? "Composed by wrangler from the deploy name" : `Declared in env.${environment}.name`}. A feature's Worker is <project>-f<issue>-<slug>-<app>, and \`${commandName("feature destroy")}\` deletes it by that exact name.`
97
98
  });
98
99
  return name;
99
100
  },
@@ -0,0 +1,106 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import type { Hono } from "hono";
4
+ import { z } from "zod";
5
+ /**
6
+ * **The OpenAPI contract, read off a composed app.**
7
+ *
8
+ * Nothing here is written by hand. A route's request schemas come from its `validate` middleware, its
9
+ * summary, verification and responses from its `describeRoute`, and both are found by walking
10
+ * `app.routes` — the same table Hono dispatches from. So the document describes exactly what the Worker
11
+ * serves, the adopter's own routes included, and a schema edit reaches it with no second step.
12
+ *
13
+ * Zod is already the source of truth for every request and response, and every field carries a
14
+ * `.describe()` (CLAUDE.md §Zod). `z.toJSONSchema` keeps those descriptions, which is what lets a client
15
+ * generated in Swift or Kotlin carry the same documentation as the TypeScript one.
16
+ *
17
+ * OpenAPI 3.1, whose schema dialect is JSON Schema 2020-12 — the dialect `z.toJSONSchema` writes — so
18
+ * every schema crosses unchanged.
19
+ */
20
+ /** The OpenAPI version this module writes. */
21
+ export declare const OPENAPI_VERSION = "3.1.0";
22
+ /** One entry of a Hono app's route table: a method, a path and one handler. */
23
+ export type RouterRoute = Hono["routes"][number];
24
+ /** One capability's routes, as registered by its `routes` hook. Becomes one tag. */
25
+ export interface RouteGroup {
26
+ /** The tag, normally the capability's name. */
27
+ readonly tag: string;
28
+ /** What the capability does, for the tag's description. */
29
+ readonly description?: string;
30
+ /** The routes it registered, in registration order. */
31
+ readonly routes: readonly RouterRoute[];
32
+ }
33
+ /** The document's `info` block. */
34
+ export interface ContractInfo {
35
+ readonly title: string;
36
+ readonly version: string;
37
+ readonly description?: string;
38
+ }
39
+ /** A JSON Schema object, as `z.toJSONSchema` writes one. */
40
+ export type JsonSchema = Record<string, unknown>;
41
+ /** The subset of an OpenAPI 3.1 document this module writes. */
42
+ export interface OpenApiDocument {
43
+ openapi: string;
44
+ info: ContractInfo;
45
+ tags: {
46
+ name: string;
47
+ description?: string;
48
+ }[];
49
+ paths: Record<string, Record<string, unknown>>;
50
+ components: {
51
+ schemas: Record<string, JsonSchema>;
52
+ responses: Record<string, unknown>;
53
+ securitySchemes: Record<string, unknown>;
54
+ };
55
+ }
56
+ /** A route that registered no `describeRoute`, so the contract can say nothing true about it. */
57
+ export interface UndescribedRoute {
58
+ readonly method: string;
59
+ readonly path: string;
60
+ }
61
+ /** Hono's `/a/:id{[0-9]+}/*` as OpenAPI's `/a/{id}/{path}`. */
62
+ export declare function openApiPath(path: string): string;
63
+ /**
64
+ * `get` + `/leaderboard/{board}/top` → `getLeaderboardByBoardTop`. A parameter reads as `By<Name>`, so
65
+ * `/items/{id}` and `/items/id` do not meet. Stable, but not unique on its own — `/a-b` and `/a/b` still
66
+ * share one — so {@link openApiDocument} numbers a repeat rather than writing an invalid document.
67
+ */
68
+ export declare function operationId(method: string, path: string): string;
69
+ /** A Zod schema as JSON Schema, without the `$schema` key OpenAPI does not want inside a document. */
70
+ export declare function jsonSchema(schema: z.ZodType, io: "input" | "output"): JsonSchema;
71
+ /** Every route in these groups that registered no `describeRoute`. Empty is the contract being met. */
72
+ export declare function undescribedRoutes(groups: readonly RouteGroup[]): UndescribedRoute[];
73
+ /** How {@link openApiDocument} treats a route with no `describeRoute`. */
74
+ export interface DocumentOptions {
75
+ /**
76
+ * `omit` (the default) leaves it out, which is right for the kit: {@link undescribedRoutes} keeps that
77
+ * set empty, so anything missing is a bug the gate names. `include` writes what the validators alone
78
+ * can say — the path, its parameters and body — marked `x-pithy-undescribed`, which is right for an
79
+ * adopter's own routes: a client generated from the contract still reaches them, and the mark says
80
+ * which ones to describe.
81
+ */
82
+ readonly undescribed?: "omit" | "include";
83
+ }
84
+ /**
85
+ * The OpenAPI document for these route groups. Undescribed routes are left out rather than guessed at,
86
+ * unless {@link DocumentOptions.undescribed} says otherwise; {@link undescribedRoutes} is the check that
87
+ * keeps that set empty.
88
+ */
89
+ export declare function openApiDocument(info: ContractInfo, groups: readonly RouteGroup[], options?: DocumentOptions): OpenApiDocument;
90
+ /** One schema property somewhere in the document that carries no `description`. */
91
+ export interface UndescribedField {
92
+ /** A JSON pointer to the property, e.g. `/paths/~1leaderboard/get/responses/200/…/properties/rank`. */
93
+ readonly pointer: string;
94
+ }
95
+ /**
96
+ * Every object property and every parameter in the document without a description. The kit holds every
97
+ * Zod field to `.describe()`, and this is how that rule is checked on the wire shape a client actually
98
+ * generates from, rather than on the source it came from.
99
+ */
100
+ export declare function undescribedFields(document: OpenApiDocument): UndescribedField[];
101
+ /**
102
+ * The same sweep over any part of a contract document, from a JSON pointer: every object property and every
103
+ * OpenAPI parameter below `node` without a description. The AsyncAPI document runs it over its messages.
104
+ */
105
+ export declare function undescribedFieldsIn(node: unknown, at: string): UndescribedField[];
106
+ //# sourceMappingURL=document.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"document.d.ts","sourceRoot":"","sources":["../../src/openapi/document.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AACjC,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAQxB;;;;;;;;;;;;;;GAcG;AAEH,8CAA8C;AAC9C,eAAO,MAAM,eAAe,UAAU,CAAC;AAEvC,+EAA+E;AAC/E,MAAM,MAAM,WAAW,GAAG,IAAI,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,CAAC;AAEjD,oFAAoF;AACpF,MAAM,WAAW,UAAU;IACzB,+CAA+C;IAC/C,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,2DAA2D;IAC3D,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,uDAAuD;IACvD,QAAQ,CAAC,MAAM,EAAE,SAAS,WAAW,EAAE,CAAC;CACzC;AAED,mCAAmC;AACnC,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC/B;AAED,4DAA4D;AAC5D,MAAM,MAAM,UAAU,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAEjD,gEAAgE;AAChE,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,YAAY,CAAC;IACnB,IAAI,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IAC/C,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IAC/C,UAAU,EAAE;QACV,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;QACpC,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;QACnC,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;KAC1C,CAAC;CACH;AAED,iGAAiG;AACjG,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAkCD,+DAA+D;AAC/D,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAKhD;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAUhE;AAED,sGAAsG;AACtG,wBAAgB,UAAU,CAAC,MAAM,EAAE,CAAC,CAAC,OAAO,EAAE,EAAE,EAAE,OAAO,GAAG,QAAQ,GAAG,UAAU,CAGhF;AA6BD,uGAAuG;AACvG,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,UAAU,EAAE,GAAG,gBAAgB,EAAE,CAOnF;AAkHD,0EAA0E;AAC1E,MAAM,WAAW,eAAe;IAC9B;;;;;;OAMG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAC3C;AASD;;;;GAIG;AACH,wBAAgB,eAAe,CAC7B,IAAI,EAAE,YAAY,EAClB,MAAM,EAAE,SAAS,UAAU,EAAE,EAC7B,OAAO,GAAE,eAAoB,GAC5B,eAAe,CA+CjB;AAED,mFAAmF;AACnF,MAAM,WAAW,gBAAgB;IAC/B,uGAAuG;IACvG,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B;AAID;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,eAAe,GAAG,gBAAgB,EAAE,CAE/E;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,OAAO,EAAE,EAAE,EAAE,MAAM,GAAG,gBAAgB,EAAE,CA4BjF"}