@ontrails/trails 0.2.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 (121) hide show
  1. package/CHANGELOG.md +1906 -0
  2. package/README.md +48 -0
  3. package/bin/trails.ts +3 -0
  4. package/package.json +57 -0
  5. package/src/app.ts +167 -0
  6. package/src/clack.ts +111 -0
  7. package/src/cli.ts +308 -0
  8. package/src/completions.ts +431 -0
  9. package/src/lifecycle-source-io.ts +33 -0
  10. package/src/load-app-mirror.ts +202 -0
  11. package/src/local-state-io.ts +129 -0
  12. package/src/mcp-app.ts +42 -0
  13. package/src/mcp-options.ts +92 -0
  14. package/src/mcp.ts +8 -0
  15. package/src/project-writes.ts +377 -0
  16. package/src/regrade/audit.ts +571 -0
  17. package/src/regrade/config.ts +152 -0
  18. package/src/regrade/history.ts +636 -0
  19. package/src/regrade/lifecycle.ts +76 -0
  20. package/src/regrade/live-api-preserve.ts +123 -0
  21. package/src/regrade/plan-artifact.ts +515 -0
  22. package/src/regrade/plan-derivation.ts +301 -0
  23. package/src/regrade/prepared-run.ts +259 -0
  24. package/src/regrade/receipt-history.ts +446 -0
  25. package/src/regrade/source-transaction.ts +185 -0
  26. package/src/release/bindings.ts +58 -0
  27. package/src/release/changeset-packages.ts +99 -0
  28. package/src/release/check.ts +1191 -0
  29. package/src/release/cli-bundle.ts +575 -0
  30. package/src/release/config.ts +73 -0
  31. package/src/release/contract-facts.ts +425 -0
  32. package/src/release/homebrew.ts +221 -0
  33. package/src/release/index.ts +180 -0
  34. package/src/release/lock-roundtrip-smoke.ts +255 -0
  35. package/src/release/lock-roundtrip-workspace.ts +107 -0
  36. package/src/release/native-bun-publish.ts +964 -0
  37. package/src/release/native-bun-registry.ts +848 -0
  38. package/src/release/notes-cli.ts +171 -0
  39. package/src/release/notes.ts +390 -0
  40. package/src/release/pack-coherence.ts +455 -0
  41. package/src/release/package-route-facts.ts +146 -0
  42. package/src/release/packed-artifacts-smoke.ts +236 -0
  43. package/src/release/policy.ts +1780 -0
  44. package/src/release/semver.ts +104 -0
  45. package/src/release/smoke.ts +56 -0
  46. package/src/release/stable-version-release.ts +80 -0
  47. package/src/release/wayfinder-dogfood-smoke.ts +762 -0
  48. package/src/release/zero-line-transition.ts +68 -0
  49. package/src/retired-topo-command.ts +36 -0
  50. package/src/run-adapter-check.ts +76 -0
  51. package/src/run-argv.ts +133 -0
  52. package/src/run-collision.ts +126 -0
  53. package/src/run-completions-install.ts +179 -0
  54. package/src/run-example.ts +149 -0
  55. package/src/run-examples.ts +148 -0
  56. package/src/run-quiet.ts +75 -0
  57. package/src/run-regrade-progress.ts +47 -0
  58. package/src/run-release-check.ts +74 -0
  59. package/src/run-schema.ts +74 -0
  60. package/src/run-trace.ts +273 -0
  61. package/src/run-warden.ts +39 -0
  62. package/src/run-watch-project.ts +52 -0
  63. package/src/run-watch.ts +381 -0
  64. package/src/run-wayfind-outline.ts +170 -0
  65. package/src/scaffold-version-sync.ts +183 -0
  66. package/src/scaffold-versions.generated.ts +12 -0
  67. package/src/trails/adapter-check.ts +244 -0
  68. package/src/trails/add-surface.ts +816 -0
  69. package/src/trails/add-trail.ts +141 -0
  70. package/src/trails/add-verify.ts +252 -0
  71. package/src/trails/compile.ts +118 -0
  72. package/src/trails/completions-complete.ts +236 -0
  73. package/src/trails/completions.ts +47 -0
  74. package/src/trails/config-explain.ts +43 -0
  75. package/src/trails/create-adapter.ts +785 -0
  76. package/src/trails/create-scaffold.ts +1215 -0
  77. package/src/trails/create-versions.ts +62 -0
  78. package/src/trails/create.ts +652 -0
  79. package/src/trails/deprecate.ts +59 -0
  80. package/src/trails/dev-clean.ts +80 -0
  81. package/src/trails/dev-reset.ts +48 -0
  82. package/src/trails/dev-stats.ts +71 -0
  83. package/src/trails/dev-support.ts +360 -0
  84. package/src/trails/doctor.ts +77 -0
  85. package/src/trails/draft-promote.ts +949 -0
  86. package/src/trails/guide.ts +106 -0
  87. package/src/trails/load-app.ts +1145 -0
  88. package/src/trails/operator-context.ts +66 -0
  89. package/src/trails/project-context-output.ts +304 -0
  90. package/src/trails/project-context.ts +613 -0
  91. package/src/trails/project.ts +65 -0
  92. package/src/trails/regrade.ts +4951 -0
  93. package/src/trails/release-check.ts +113 -0
  94. package/src/trails/release-smoke.ts +49 -0
  95. package/src/trails/revise.ts +53 -0
  96. package/src/trails/root-dir.ts +21 -0
  97. package/src/trails/run-example.ts +592 -0
  98. package/src/trails/run-examples.ts +149 -0
  99. package/src/trails/run.ts +496 -0
  100. package/src/trails/scaffold-json.ts +60 -0
  101. package/src/trails/scaffold-topo-identity.ts +479 -0
  102. package/src/trails/survey.ts +990 -0
  103. package/src/trails/topo-activation.ts +14 -0
  104. package/src/trails/topo-constants.ts +2 -0
  105. package/src/trails/topo-history.ts +47 -0
  106. package/src/trails/topo-output-schemas.ts +259 -0
  107. package/src/trails/topo-pin.ts +38 -0
  108. package/src/trails/topo-read-support.ts +368 -0
  109. package/src/trails/topo-reports.ts +809 -0
  110. package/src/trails/topo-store-support.ts +323 -0
  111. package/src/trails/topo-support.ts +247 -0
  112. package/src/trails/topo-unpin.ts +61 -0
  113. package/src/trails/topo.ts +92 -0
  114. package/src/trails/validate.ts +348 -0
  115. package/src/trails/version-lifecycle-support.ts +936 -0
  116. package/src/trails/warden-guide.ts +134 -0
  117. package/src/trails/warden.ts +598 -0
  118. package/src/trails/wayfind-diff.ts +716 -0
  119. package/src/trails/wayfind-outline.ts +876 -0
  120. package/src/trails/wayfind.ts +1319 -0
  121. package/src/versions.ts +31 -0
@@ -0,0 +1,964 @@
1
+ /* oxlint-disable eslint-plugin-jest/require-hook, max-statements, func-style -- release script with module-level flow */
2
+ /**
3
+ * Built-in release flow for public `@ontrails/*` workspace publication.
4
+ *
5
+ * Auto-discovers workspaces from the root `package.json` `workspaces` field,
6
+ * topo-sorts them by `workspace:` dependency edges, enforces manifest-range
7
+ * cleanliness on the packed tarball (no `workspace:` / `catalog:` leakage),
8
+ * and respects the Changesets prerelease tag by default.
9
+ *
10
+ * @see docs/tenets.md for release posture.
11
+ */
12
+
13
+ import { mkdtemp, readdir, rm } from 'node:fs/promises';
14
+ import { tmpdir } from 'node:os';
15
+ import { join, relative, resolve } from 'node:path';
16
+
17
+ import {
18
+ checkRegistryPosture,
19
+ classifyPackageRegistryState,
20
+ factsFromRegistryResult,
21
+ npmRegistryVersionProofView,
22
+ npmRegistryView,
23
+ } from './native-bun-registry.js';
24
+ import type { PackageRegistryState } from './native-bun-registry.js';
25
+
26
+ const REPO_ROOT = resolve(process.cwd());
27
+
28
+ /** ANSI color helpers, disabled when stdout is not a TTY or `NO_COLOR` is set. */
29
+ const useColor = Boolean(process.stdout.isTTY) && !process.env['NO_COLOR'];
30
+ const color = (code: string, text: string): string =>
31
+ useColor ? `\u001B[${code}m${text}\u001B[0m` : text;
32
+ const blue = (t: string) => color('0;34', t);
33
+ const green = (t: string) => color('0;32', t);
34
+ const red = (t: string) => color('0;31', t);
35
+
36
+ const info = (msg: string) => console.log(`${blue('▸')} ${msg}`);
37
+ const success = (msg: string) => console.log(`${green('✓')} ${msg}`);
38
+ const fail = (msg: string) => console.error(`${red('✗')} ${msg}`);
39
+
40
+ /** Parsed CLI options. */
41
+ export interface NativeBunPublishOptions {
42
+ readonly mode: 'check' | 'publish';
43
+ readonly tag: string | undefined;
44
+ readonly otp: string | undefined;
45
+ readonly only: readonly string[] | undefined;
46
+ readonly trustedPublishing?: boolean;
47
+ }
48
+
49
+ /** Minimal shape of a workspace `package.json` we care about. */
50
+ export interface NativeBunPublishPackageJson {
51
+ name?: string;
52
+ version?: string;
53
+ private?: boolean;
54
+ dependencies?: Record<string, string>;
55
+ devDependencies?: Record<string, string>;
56
+ peerDependencies?: Record<string, string>;
57
+ optionalDependencies?: Record<string, string>;
58
+ scripts?: Record<string, string>;
59
+ repository?:
60
+ | string
61
+ | {
62
+ directory?: string;
63
+ type?: string;
64
+ url?: string;
65
+ };
66
+ }
67
+
68
+ type DependencyField =
69
+ | 'dependencies'
70
+ | 'devDependencies'
71
+ | 'peerDependencies'
72
+ | 'optionalDependencies';
73
+
74
+ /** A discovered, publishable workspace. */
75
+ export interface NativeBunPublishWorkspace {
76
+ readonly name: string;
77
+ readonly version: string;
78
+ readonly path: string;
79
+ readonly isPrivate: boolean;
80
+ readonly workspaceDeps: readonly string[];
81
+ readonly repository?: NativeBunPublishPackageJson['repository'];
82
+ readonly publishLifecycleScripts?: readonly string[];
83
+ }
84
+
85
+ const DEPENDENCY_FIELDS: readonly DependencyField[] = [
86
+ 'dependencies',
87
+ 'devDependencies',
88
+ 'peerDependencies',
89
+ 'optionalDependencies',
90
+ ];
91
+ const TRUSTED_REPOSITORY_URL =
92
+ 'git+https://github.com/outfitter-dev/trails.git';
93
+ const MINIMUM_TRUSTED_NODE = [22, 14, 0] as const;
94
+ const MINIMUM_TRUSTED_NPM = [11, 5, 1] as const;
95
+ const PUBLISH_ONLY_LIFECYCLE_SCRIPTS = [
96
+ 'prepublishOnly',
97
+ 'publish',
98
+ 'postpublish',
99
+ ] as const;
100
+
101
+ const USAGE = `Usage: bun scripts/publish.ts [options]
102
+
103
+ Publish all public @ontrails/* workspaces in dependency order. Bun packs and
104
+ validates each tarball; npm publishes the resolved tarball to the registry.
105
+
106
+ Options:
107
+ --check Pre-publish verification only. Runs \`bun pm pack --dry-run\`
108
+ (required so \`catalog:\` resolves) and asserts the packed
109
+ manifest has no \`workspace:\` or \`catalog:\` ranges and
110
+ validates trusted-publishing repository metadata. No publishing.
111
+ --dry-run Alias for --check.
112
+ --tag <tag> npm dist-tag. Defaults to .changeset/pre.json tag when in
113
+ prerelease mode, otherwise "latest".
114
+ --otp <code> Two-factor code. Also read from BUN_PUBLISH_OTP.
115
+ --trusted-publishing Require GitHub Actions OIDC and npm trusted-publishing
116
+ prerequisites before attempting the first package.
117
+ --only <name[,name]> Restrict to the named packages (repeatable). Useful for
118
+ partial reruns after a mid-matrix failure.
119
+ -h, --help Show this help and exit.
120
+
121
+ Exit codes: 0 success, 1 publish/check failure, 2 arg-parse error.`;
122
+
123
+ /** Alphabetical sort helper, hoisted for reuse. */
124
+ const sortAlpha = (names: string[]): string[] =>
125
+ names.sort((a, b) => a.localeCompare(b));
126
+
127
+ /** Parse CLI args with a tiny hand-rolled parser. Exits with code 2 on error. */
128
+ const parseArgs = (argv: readonly string[]): NativeBunPublishOptions => {
129
+ let mode: NativeBunPublishOptions['mode'] = 'publish';
130
+ let tag: string | undefined;
131
+ let otp: string | undefined = process.env['BUN_PUBLISH_OTP'] || undefined;
132
+ let trustedPublishing = false;
133
+ const only: string[] = [];
134
+
135
+ const needsValue = (flag: string, value: string | undefined): string => {
136
+ if (value === undefined || value.startsWith('--')) {
137
+ fail(`${flag} requires a value`);
138
+ console.error(USAGE);
139
+ process.exit(2);
140
+ }
141
+ return value;
142
+ };
143
+
144
+ let i = 0;
145
+ while (i < argv.length) {
146
+ const arg = argv[i] as string;
147
+ if (arg === '--check' || arg === '--dry-run') {
148
+ mode = 'check';
149
+ } else if (arg === '--tag') {
150
+ i += 1;
151
+ tag = needsValue('--tag', argv[i]);
152
+ } else if (arg === '--otp') {
153
+ i += 1;
154
+ otp = needsValue('--otp', argv[i]);
155
+ } else if (arg === '--trusted-publishing') {
156
+ trustedPublishing = true;
157
+ } else if (arg === '--only') {
158
+ i += 1;
159
+ const value = needsValue('--only', argv[i]);
160
+ for (const name of value
161
+ .split(',')
162
+ .map((s) => s.trim())
163
+ .filter(Boolean)) {
164
+ only.push(name);
165
+ }
166
+ } else if (arg === '-h' || arg === '--help') {
167
+ console.log(USAGE);
168
+ process.exit(0);
169
+ } else {
170
+ fail(`Unknown argument: ${arg}`);
171
+ console.error(USAGE);
172
+ process.exit(2);
173
+ }
174
+ i += 1;
175
+ }
176
+
177
+ return {
178
+ mode,
179
+ only: only.length > 0 ? only : undefined,
180
+ otp,
181
+ tag,
182
+ trustedPublishing,
183
+ };
184
+ };
185
+
186
+ /** Read and JSON-parse a file. Throws a readable error on failure. */
187
+ const readJson = async <T>(path: string): Promise<T> => {
188
+ const file = Bun.file(path);
189
+ if (!(await file.exists())) {
190
+ throw new Error(`File not found: ${path}`);
191
+ }
192
+ const text = await file.text();
193
+ try {
194
+ return JSON.parse(text) as T;
195
+ } catch (error) {
196
+ throw new Error(`Invalid JSON in ${path}: ${(error as Error).message}`, {
197
+ cause: error,
198
+ });
199
+ }
200
+ };
201
+
202
+ /**
203
+ * Resolve the default dist-tag by inspecting `.changeset/pre.json`.
204
+ * Returns the prerelease tag when Changesets is in `pre` mode, else `"latest"`.
205
+ */
206
+ const resolveDefaultTag = async (): Promise<string> => {
207
+ const prePath = join(REPO_ROOT, '.changeset', 'pre.json');
208
+ if (!(await Bun.file(prePath).exists())) {
209
+ return 'latest';
210
+ }
211
+ const pre = await readJson<{ mode?: string; tag?: string }>(prePath);
212
+ if (pre.mode !== 'pre') {
213
+ return 'latest';
214
+ }
215
+ if (typeof pre.tag === 'string' && pre.tag.length > 0) {
216
+ return pre.tag;
217
+ }
218
+ throw new Error(
219
+ `${prePath} has mode="pre" but no usable "tag" field. Set --tag explicitly or fix pre.json before publishing.`
220
+ );
221
+ };
222
+
223
+ /**
224
+ * Expand `workspaces` globs from the root `package.json` into absolute
225
+ * directory paths that contain a `package.json`.
226
+ *
227
+ * Handles the simple `dir/*` pattern actually used in this repo plus bare
228
+ * `dir` entries. Directory listing is cheaper and more predictable here than
229
+ * a full glob implementation.
230
+ */
231
+ const discoverWorkspaceDirs = async (
232
+ patterns: readonly string[]
233
+ ): Promise<string[]> => {
234
+ const dirs: string[] = [];
235
+ for (const pattern of patterns) {
236
+ if (pattern.endsWith('/*')) {
237
+ const parent = join(REPO_ROOT, pattern.slice(0, -2));
238
+ let names: string[] = [];
239
+ try {
240
+ const entries = await readdir(parent, { withFileTypes: true });
241
+ names = entries
242
+ .filter((e) => e.isDirectory())
243
+ .map((e) => (typeof e.name === 'string' ? e.name : String(e.name)));
244
+ } catch {
245
+ continue;
246
+ }
247
+ for (const name of names) {
248
+ const dir = join(parent, name);
249
+ if (await Bun.file(join(dir, 'package.json')).exists()) {
250
+ dirs.push(dir);
251
+ }
252
+ }
253
+ } else {
254
+ const dir = join(REPO_ROOT, pattern);
255
+ if (await Bun.file(join(dir, 'package.json')).exists()) {
256
+ dirs.push(dir);
257
+ }
258
+ }
259
+ }
260
+ return dirs;
261
+ };
262
+
263
+ /** Collect all `workspace:`-referenced dep names from a package.json. */
264
+ const collectWorkspaceDeps = (pkg: NativeBunPublishPackageJson): string[] => {
265
+ const deps = new Set<string>();
266
+ for (const field of DEPENDENCY_FIELDS) {
267
+ const map = pkg[field];
268
+ if (!map || typeof map !== 'object') {
269
+ continue;
270
+ }
271
+ for (const [name, range] of Object.entries(map)) {
272
+ if (typeof range === 'string' && range.startsWith('workspace:')) {
273
+ deps.add(name);
274
+ }
275
+ }
276
+ }
277
+ return [...deps];
278
+ };
279
+
280
+ const expectedPackedWorkspaceRange = (
281
+ sourceRange: string,
282
+ dep: NativeBunPublishWorkspace
283
+ ): string | undefined => {
284
+ if (!sourceRange.startsWith('workspace:')) {
285
+ return undefined;
286
+ }
287
+ const protocolRange = sourceRange.slice('workspace:'.length);
288
+ if (protocolRange === '^') {
289
+ return `^${dep.version}`;
290
+ }
291
+ if (protocolRange === '~') {
292
+ return `~${dep.version}`;
293
+ }
294
+ if (protocolRange === '*' || protocolRange === '') {
295
+ return dep.version;
296
+ }
297
+ return protocolRange;
298
+ };
299
+
300
+ export const findPackedFirstPartyDependencyMismatches = ({
301
+ packageName,
302
+ packagePath,
303
+ packedPackage,
304
+ sourcePackage,
305
+ workspacesByName,
306
+ }: {
307
+ readonly packageName: string;
308
+ readonly packagePath: string;
309
+ readonly packedPackage: NativeBunPublishPackageJson;
310
+ readonly sourcePackage: NativeBunPublishPackageJson;
311
+ readonly workspacesByName: ReadonlyMap<string, NativeBunPublishWorkspace>;
312
+ }): string[] => {
313
+ const mismatches: string[] = [];
314
+ for (const field of DEPENDENCY_FIELDS) {
315
+ const sourceDeps = sourcePackage[field];
316
+ if (!sourceDeps || typeof sourceDeps !== 'object') {
317
+ continue;
318
+ }
319
+ const packedDeps = packedPackage[field] ?? {};
320
+ for (const [depName, sourceRange] of Object.entries(sourceDeps)) {
321
+ const dep = workspacesByName.get(depName);
322
+ if (
323
+ !dep ||
324
+ !dep.name.startsWith('@ontrails/') ||
325
+ typeof sourceRange !== 'string'
326
+ ) {
327
+ continue;
328
+ }
329
+ const expected = expectedPackedWorkspaceRange(sourceRange, dep);
330
+ if (!expected) {
331
+ continue;
332
+ }
333
+ const actual = packedDeps[depName] ?? '(missing)';
334
+ if (actual !== expected) {
335
+ const depPath = relative(REPO_ROOT, dep.path);
336
+ mismatches.push(
337
+ `${packageName} packed ${field} ${depName} resolved to ${actual}, expected ${expected} from ${depPath}/package.json`
338
+ );
339
+ }
340
+ }
341
+ }
342
+ if (mismatches.length === 0) {
343
+ return [];
344
+ }
345
+ const relPath = relative(REPO_ROOT, packagePath);
346
+ return [
347
+ `Packed manifest for ${packageName} (${relPath}) contains stale first-party workspace dependency ranges:`,
348
+ ...mismatches.map((mismatch) => ` ${mismatch}`),
349
+ ];
350
+ };
351
+
352
+ /** Discover all workspace packages and enrich with dep edges. */
353
+ const discoverWorkspaces = async (): Promise<NativeBunPublishWorkspace[]> => {
354
+ const root = await readJson<{ workspaces?: string[] }>(
355
+ join(REPO_ROOT, 'package.json')
356
+ );
357
+ if (!root.workspaces || root.workspaces.length === 0) {
358
+ throw new Error('Root package.json has no "workspaces" field');
359
+ }
360
+ const dirs = await discoverWorkspaceDirs(root.workspaces);
361
+
362
+ const workspaces: NativeBunPublishWorkspace[] = [];
363
+ for (const dir of dirs) {
364
+ const pkg = await readJson<NativeBunPublishPackageJson>(
365
+ join(dir, 'package.json')
366
+ );
367
+ if (!pkg.name) {
368
+ continue;
369
+ }
370
+ workspaces.push({
371
+ isPrivate: pkg.private === true,
372
+ name: pkg.name,
373
+ path: dir,
374
+ publishLifecycleScripts: PUBLISH_ONLY_LIFECYCLE_SCRIPTS.filter(
375
+ (name) => pkg.scripts?.[name] !== undefined
376
+ ),
377
+ repository: pkg.repository,
378
+ version: pkg.version ?? '0.0.0',
379
+ workspaceDeps: collectWorkspaceDeps(pkg),
380
+ });
381
+ }
382
+ return workspaces;
383
+ };
384
+
385
+ /**
386
+ * Topologically sort workspaces so dependencies come before dependents.
387
+ * Ties broken alphabetically by name for deterministic output. Throws on cycles.
388
+ */
389
+ const topoSort = (
390
+ workspaces: readonly NativeBunPublishWorkspace[]
391
+ ): NativeBunPublishWorkspace[] => {
392
+ const byName = new Map(workspaces.map((w) => [w.name, w] as const));
393
+ const indegree = new Map<string, number>();
394
+ const reverseEdges = new Map<string, Set<string>>();
395
+
396
+ for (const w of workspaces) {
397
+ indegree.set(w.name, 0);
398
+ reverseEdges.set(w.name, new Set());
399
+ }
400
+ for (const w of workspaces) {
401
+ for (const dep of w.workspaceDeps) {
402
+ if (!byName.has(dep)) {
403
+ continue;
404
+ }
405
+ indegree.set(w.name, (indegree.get(w.name) ?? 0) + 1);
406
+ reverseEdges.get(dep)?.add(w.name);
407
+ }
408
+ }
409
+
410
+ const ready: string[] = sortAlpha(
411
+ [...indegree.entries()].filter(([, n]) => n === 0).map(([name]) => name)
412
+ );
413
+
414
+ const out: NativeBunPublishWorkspace[] = [];
415
+ while (ready.length > 0) {
416
+ const name = ready.shift() as string;
417
+ const ws = byName.get(name);
418
+ if (ws) {
419
+ out.push(ws);
420
+ }
421
+ const dependents = sortAlpha([...(reverseEdges.get(name) ?? [])]);
422
+ for (const dep of dependents) {
423
+ const next = (indegree.get(dep) ?? 0) - 1;
424
+ indegree.set(dep, next);
425
+ if (next === 0) {
426
+ const idx = ready.findIndex((n) => n.localeCompare(dep) > 0);
427
+ if (idx === -1) {
428
+ ready.push(dep);
429
+ } else {
430
+ ready.splice(idx, 0, dep);
431
+ }
432
+ }
433
+ }
434
+ }
435
+
436
+ if (out.length !== workspaces.length) {
437
+ const remaining = workspaces
438
+ .filter((w) => !out.includes(w))
439
+ .map((w) => w.name);
440
+ throw new Error(
441
+ `Dependency cycle detected among: ${remaining.toSorted().join(', ')}`
442
+ );
443
+ }
444
+ return out;
445
+ };
446
+
447
+ /** Spawn a child process inheriting stdio. Returns its exit code. */
448
+ const spawnInherit = async (
449
+ cmd: readonly string[],
450
+ cwd: string
451
+ ): Promise<number> => {
452
+ const proc = Bun.spawn(cmd as string[], {
453
+ cwd,
454
+ stderr: 'inherit',
455
+ stdin: 'inherit',
456
+ stdout: 'inherit',
457
+ });
458
+ return await proc.exited;
459
+ };
460
+
461
+ /** Spawn a child process and capture stdout (stderr inherited). */
462
+ const spawnCapture = async (
463
+ cmd: readonly string[],
464
+ cwd: string
465
+ ): Promise<{ exitCode: number; stdout: string }> => {
466
+ const proc = Bun.spawn(cmd as string[], {
467
+ cwd,
468
+ stderr: 'inherit',
469
+ stdin: 'ignore',
470
+ stdout: 'pipe',
471
+ });
472
+ const stdout = await new Response(proc.stdout).text();
473
+ const exitCode = await proc.exited;
474
+ return { exitCode, stdout };
475
+ };
476
+
477
+ interface PackedWorkspaceTarball {
478
+ readonly directory: string;
479
+ readonly path: string;
480
+ }
481
+
482
+ /** Pack through Bun so workspace and catalog ranges resolve before npm sees the manifest. */
483
+ const packWorkspaceTarball = async (
484
+ ws: NativeBunPublishWorkspace
485
+ ): Promise<PackedWorkspaceTarball> => {
486
+ const directory = await mkdtemp(join(tmpdir(), 'trails-publish-'));
487
+ try {
488
+ const pack = await spawnCapture(
489
+ ['bun', 'pm', 'pack', '--destination', directory],
490
+ ws.path
491
+ );
492
+ if (pack.exitCode !== 0) {
493
+ throw new Error(
494
+ `bun pm pack failed for ${ws.name} (exit ${pack.exitCode})`
495
+ );
496
+ }
497
+ const entries = await readdir(directory);
498
+ const tarName = entries.find((name) =>
499
+ typeof name === 'string'
500
+ ? name.endsWith('.tgz')
501
+ : String(name).endsWith('.tgz')
502
+ );
503
+ if (!tarName) {
504
+ throw new Error(`bun pm pack produced no tarball for ${ws.name}`);
505
+ }
506
+ return { directory, path: join(directory, String(tarName)) };
507
+ } catch (error) {
508
+ await rm(directory, { force: true, recursive: true });
509
+ throw error;
510
+ }
511
+ };
512
+
513
+ /** Assert that a Bun-packed tarball contains publishable dependency ranges. */
514
+ const assertPackedManifestClean = async (
515
+ ws: NativeBunPublishWorkspace,
516
+ workspacesByName: ReadonlyMap<string, NativeBunPublishWorkspace>,
517
+ tarPath: string
518
+ ): Promise<void> => {
519
+ const extract = Bun.spawn(['tar', '-xOf', tarPath, 'package/package.json'], {
520
+ stderr: 'pipe',
521
+ stdin: 'ignore',
522
+ stdout: 'pipe',
523
+ });
524
+ const [manifestText, tarStderr, extractExit] = await Promise.all([
525
+ new Response(extract.stdout).text(),
526
+ new Response(extract.stderr).text(),
527
+ extract.exited,
528
+ ]);
529
+ if (extractExit !== 0) {
530
+ const detail = tarStderr.trim() || '(no stderr output)';
531
+ throw new Error(
532
+ `tar extraction failed for ${ws.name} (exit ${extractExit}): ${detail}`
533
+ );
534
+ }
535
+
536
+ let packedPackage: NativeBunPublishPackageJson;
537
+ try {
538
+ packedPackage = JSON.parse(manifestText) as NativeBunPublishPackageJson;
539
+ } catch (error) {
540
+ throw new Error(
541
+ `Invalid packed package.json for ${ws.name}: ${(error as Error).message}`,
542
+ { cause: error }
543
+ );
544
+ }
545
+
546
+ const offenders: string[] = [];
547
+ for (const [lineNo, line] of manifestText.split('\n').entries()) {
548
+ if (line.includes('"workspace:') || line.includes('"catalog:')) {
549
+ offenders.push(` line ${lineNo + 1}: ${line.trim()}`);
550
+ }
551
+ }
552
+ if (offenders.length > 0) {
553
+ const relPath = relative(REPO_ROOT, ws.path);
554
+ throw new Error(
555
+ `Packed manifest for ${ws.name} (${relPath}) contains forbidden ranges:\n${offenders.join('\n')}\n Hint: publish the Bun-packed tarball instead of packing through npm.`
556
+ );
557
+ }
558
+ const sourcePackage = await readJson<NativeBunPublishPackageJson>(
559
+ join(ws.path, 'package.json')
560
+ );
561
+ const mismatches = findPackedFirstPartyDependencyMismatches({
562
+ packageName: ws.name,
563
+ packagePath: ws.path,
564
+ packedPackage,
565
+ sourcePackage,
566
+ workspacesByName,
567
+ });
568
+ if (mismatches.length > 0) {
569
+ throw new Error(mismatches.join('\n'));
570
+ }
571
+ };
572
+
573
+ export const unsupportedPublishLifecycleScripts = (
574
+ workspaces: readonly NativeBunPublishWorkspace[]
575
+ ): string[] =>
576
+ workspaces.flatMap((workspace) =>
577
+ workspace.isPrivate
578
+ ? []
579
+ : (workspace.publishLifecycleScripts ?? []).map(
580
+ (script) => `${workspace.name} defines unsupported ${script}`
581
+ )
582
+ );
583
+
584
+ /** Validate repository metadata required by npm trusted publishing. */
585
+ export const publishRepositoryMetadataErrors = (
586
+ workspaces: readonly NativeBunPublishWorkspace[]
587
+ ): string[] =>
588
+ workspaces.flatMap((workspace) => {
589
+ if (workspace.isPrivate) {
590
+ return [];
591
+ }
592
+ const { repository } = workspace;
593
+ const expectedDirectory = relative(REPO_ROOT, workspace.path);
594
+ if (
595
+ typeof repository !== 'string' &&
596
+ repository?.type === 'git' &&
597
+ repository.url === TRUSTED_REPOSITORY_URL &&
598
+ repository.directory === expectedDirectory
599
+ ) {
600
+ return [];
601
+ }
602
+ return [
603
+ `${workspace.name} must declare repository ${TRUSTED_REPOSITORY_URL} with directory ${expectedDirectory}`,
604
+ ];
605
+ });
606
+
607
+ /** Run `--check` flow: pack dry-run plus manifest-range assertion per package. */
608
+ const runCheck = async (
609
+ workspaces: readonly NativeBunPublishWorkspace[],
610
+ allWorkspaces: readonly NativeBunPublishWorkspace[]
611
+ ): Promise<number> => {
612
+ const repositoryErrors = publishRepositoryMetadataErrors(workspaces);
613
+ if (repositoryErrors.length > 0) {
614
+ throw new Error(
615
+ `Package repository metadata check failed:\n${repositoryErrors.map((error) => `- ${error}`).join('\n')}`
616
+ );
617
+ }
618
+ const unsupportedLifecycle = unsupportedPublishLifecycleScripts(workspaces);
619
+ if (unsupportedLifecycle.length > 0) {
620
+ throw new Error(
621
+ `Tarball publication cannot honor publish-only lifecycle scripts:\n${unsupportedLifecycle.map((error) => `- ${error}`).join('\n')}`
622
+ );
623
+ }
624
+ const workspacesByName = new Map(allWorkspaces.map((ws) => [ws.name, ws]));
625
+ for (const ws of workspaces) {
626
+ if (ws.isPrivate) {
627
+ info(`Skipping ${ws.name} (private)`);
628
+ continue;
629
+ }
630
+ info(`Checking ${ws.name}@${ws.version}...`);
631
+ const dryRun = await spawnInherit(
632
+ ['bun', 'pm', 'pack', '--dry-run'],
633
+ ws.path
634
+ );
635
+ if (dryRun !== 0) {
636
+ fail(`bun pm pack --dry-run failed for ${ws.name}`);
637
+ return 1;
638
+ }
639
+ try {
640
+ const packed = await packWorkspaceTarball(ws);
641
+ try {
642
+ await assertPackedManifestClean(ws, workspacesByName, packed.path);
643
+ } finally {
644
+ await rm(packed.directory, { force: true, recursive: true });
645
+ }
646
+ } catch (error) {
647
+ fail((error as Error).message);
648
+ return 1;
649
+ }
650
+ success(`${ws.name}@${ws.version} pack check passed`);
651
+ }
652
+ console.log('');
653
+ success('All package pack checks passed!');
654
+ return 0;
655
+ };
656
+
657
+ const compareNumericVersion = (
658
+ version: string,
659
+ minimum: readonly [number, number, number]
660
+ ): number => {
661
+ const actual = version
662
+ .split('.')
663
+ .slice(0, 3)
664
+ .map((part) => Number.parseInt(part, 10));
665
+ if (actual.length !== 3 || actual.some((part) => Number.isNaN(part))) {
666
+ return -1;
667
+ }
668
+ for (let index = 0; index < minimum.length; index += 1) {
669
+ const delta = (actual[index] ?? 0) - (minimum[index] ?? 0);
670
+ if (delta !== 0) {
671
+ return delta;
672
+ }
673
+ }
674
+ return 0;
675
+ };
676
+
677
+ export const trustedPublishingPreflightErrors = ({
678
+ env,
679
+ nodeVersion,
680
+ npmVersion,
681
+ workspaces,
682
+ }: {
683
+ readonly env: Readonly<Record<string, string | undefined>>;
684
+ readonly nodeVersion: string;
685
+ readonly npmVersion: string;
686
+ readonly workspaces: readonly NativeBunPublishWorkspace[];
687
+ }): string[] => {
688
+ const errors: string[] = [];
689
+ if (env['GITHUB_ACTIONS'] !== 'true') {
690
+ errors.push('trusted publishing requires a GitHub Actions runner');
691
+ }
692
+ if (!env['ACTIONS_ID_TOKEN_REQUEST_URL']) {
693
+ errors.push('GitHub OIDC request URL is unavailable (id-token: write)');
694
+ }
695
+ if (!env['ACTIONS_ID_TOKEN_REQUEST_TOKEN']) {
696
+ errors.push('GitHub OIDC request token is unavailable (id-token: write)');
697
+ }
698
+ if (compareNumericVersion(nodeVersion, MINIMUM_TRUSTED_NODE) < 0) {
699
+ errors.push(
700
+ `trusted publishing requires Node >= ${MINIMUM_TRUSTED_NODE.join('.')}; found ${nodeVersion}`
701
+ );
702
+ }
703
+ if (compareNumericVersion(npmVersion, MINIMUM_TRUSTED_NPM) < 0) {
704
+ errors.push(
705
+ `trusted publishing requires npm >= ${MINIMUM_TRUSTED_NPM.join('.')}; found ${npmVersion}`
706
+ );
707
+ }
708
+ errors.push(...publishRepositoryMetadataErrors(workspaces));
709
+ return errors;
710
+ };
711
+
712
+ export const createNpmPublishCommand = ({
713
+ otp,
714
+ tag,
715
+ tarballPath,
716
+ }: {
717
+ readonly otp: string | undefined;
718
+ readonly tag: string;
719
+ readonly tarballPath: string;
720
+ }): string[] => {
721
+ const command = [
722
+ 'npm',
723
+ 'publish',
724
+ tarballPath,
725
+ '--access',
726
+ 'public',
727
+ '--tag',
728
+ tag,
729
+ ];
730
+ if (otp) {
731
+ command.push('--otp', otp);
732
+ }
733
+ return command;
734
+ };
735
+
736
+ export const publicationActionForRegistryState = (
737
+ state: PackageRegistryState,
738
+ trustedPublishing = false
739
+ ): 'block' | 'publish' | 'skip' => {
740
+ if (state.kind === 'complete') {
741
+ return 'skip';
742
+ }
743
+ if (state.kind === 'first-time-package') {
744
+ return trustedPublishing ? 'block' : 'publish';
745
+ }
746
+ if (state.kind === 'needs-publish') {
747
+ return 'publish';
748
+ }
749
+ return 'block';
750
+ };
751
+
752
+ const registryPublicationBlocker = (
753
+ workspace: NativeBunPublishWorkspace,
754
+ state: PackageRegistryState,
755
+ trustedPublishing: boolean
756
+ ): string => {
757
+ if (state.kind === 'first-time-package' && trustedPublishing) {
758
+ return `${workspace.name} is not published yet; bootstrap it with credentialed local tooling, configure its npm trusted publisher, then retry`;
759
+ }
760
+ if (state.kind === 'needs-tag-repair') {
761
+ return `${workspace.name}@${workspace.version} is published but the requested dist-tag points at ${state.currentTagVersion ?? '(missing)'}; repair it with credentialed npm tooling before retrying`;
762
+ }
763
+ if (state.kind === 'tag-points-ahead') {
764
+ return `${workspace.name} dist-tag points ahead at ${state.currentTagVersion}; refusing to publish ${workspace.version}`;
765
+ }
766
+ if (state.kind === 'registry-inaccessible') {
767
+ return `Could not inspect ${workspace.name}: ${state.error}`;
768
+ }
769
+ return `Could not determine whether ${workspace.name}@${workspace.version} is ready`;
770
+ };
771
+
772
+ const assertTrustedPublishingReady = async (
773
+ workspaces: readonly NativeBunPublishWorkspace[]
774
+ ): Promise<void> => {
775
+ const npmVersionResult = await spawnCapture(['npm', '--version'], REPO_ROOT);
776
+ if (npmVersionResult.exitCode !== 0) {
777
+ throw new Error('Could not read npm version for trusted publishing');
778
+ }
779
+ const nodeVersionResult = await spawnCapture(
780
+ ['node', '--version'],
781
+ REPO_ROOT
782
+ );
783
+ if (nodeVersionResult.exitCode !== 0) {
784
+ throw new Error('Could not read Node version for trusted publishing');
785
+ }
786
+ const errors = trustedPublishingPreflightErrors({
787
+ env: process.env,
788
+ nodeVersion: nodeVersionResult.stdout.trim().replace(/^v/, ''),
789
+ npmVersion: npmVersionResult.stdout.trim(),
790
+ workspaces,
791
+ });
792
+ if (errors.length > 0) {
793
+ throw new Error(
794
+ `Trusted publishing preflight failed:\n${errors.map((error) => `- ${error}`).join('\n')}`
795
+ );
796
+ }
797
+ success('Trusted publishing prerequisites are available');
798
+ };
799
+
800
+ /** Run the actual publish flow sequentially. Aborts on first failure. */
801
+ const runPublish = async (
802
+ workspaces: readonly NativeBunPublishWorkspace[],
803
+ allWorkspaces: readonly NativeBunPublishWorkspace[],
804
+ tag: string,
805
+ otp: string | undefined,
806
+ trustedPublishing: boolean
807
+ ): Promise<number> => {
808
+ if (trustedPublishing) {
809
+ await assertTrustedPublishingReady(workspaces);
810
+ }
811
+ const unsupportedLifecycle = unsupportedPublishLifecycleScripts(workspaces);
812
+ if (unsupportedLifecycle.length > 0) {
813
+ throw new Error(
814
+ `Tarball publication cannot honor publish-only lifecycle scripts:\n${unsupportedLifecycle.map((error) => `- ${error}`).join('\n')}`
815
+ );
816
+ }
817
+ const publicWorkspaces = workspaces.filter(
818
+ (workspace) => !workspace.isPrivate
819
+ );
820
+ const registryResults = await checkRegistryPosture(
821
+ publicWorkspaces,
822
+ npmRegistryView,
823
+ npmRegistryVersionProofView,
824
+ tag
825
+ );
826
+ const registryStates = new Map<string, PackageRegistryState>();
827
+ const registryBlockers: string[] = [];
828
+ for (const workspace of publicWorkspaces) {
829
+ const registryResult = registryResults.find(
830
+ (result) => result.name === workspace.name
831
+ );
832
+ if (!registryResult) {
833
+ registryBlockers.push(
834
+ `Could not read registry posture for ${workspace.name}`
835
+ );
836
+ continue;
837
+ }
838
+ const state = classifyPackageRegistryState(
839
+ factsFromRegistryResult(registryResult, tag)
840
+ );
841
+ registryStates.set(workspace.name, state);
842
+ if (
843
+ publicationActionForRegistryState(state, trustedPublishing) === 'block'
844
+ ) {
845
+ registryBlockers.push(
846
+ registryPublicationBlocker(workspace, state, trustedPublishing)
847
+ );
848
+ }
849
+ }
850
+ if (registryBlockers.length > 0) {
851
+ throw new Error(
852
+ `Registry preflight blocked publication before any package was published:\n${registryBlockers.map((error) => `- ${error}`).join('\n')}`
853
+ );
854
+ }
855
+ const workspacesByName = new Map(allWorkspaces.map((ws) => [ws.name, ws]));
856
+ for (const ws of workspaces) {
857
+ if (ws.isPrivate) {
858
+ info(`Skipping ${ws.name} (private)`);
859
+ continue;
860
+ }
861
+ const registryState = registryStates.get(ws.name);
862
+ if (!registryState) {
863
+ fail(`Registry preflight did not produce a state for ${ws.name}`);
864
+ return 1;
865
+ }
866
+ const publicationAction = publicationActionForRegistryState(
867
+ registryState,
868
+ trustedPublishing
869
+ );
870
+ if (publicationAction === 'skip') {
871
+ success(
872
+ `${ws.name}@${ws.version} already published with ${tag} at target; continuing`
873
+ );
874
+ continue;
875
+ }
876
+ if (publicationAction === 'block') {
877
+ fail(registryPublicationBlocker(ws, registryState, trustedPublishing));
878
+ return 1;
879
+ }
880
+ info(`Publishing ${ws.name}@${ws.version}... (tag=${tag})`);
881
+ const packed = await packWorkspaceTarball(ws);
882
+ try {
883
+ await assertPackedManifestClean(ws, workspacesByName, packed.path);
884
+ const code = await spawnInherit(
885
+ createNpmPublishCommand({ otp, tag, tarballPath: packed.path }),
886
+ ws.path
887
+ );
888
+ if (code !== 0) {
889
+ fail(
890
+ `Failed to publish ${ws.name} (exit ${code}); aborting remaining publishes`
891
+ );
892
+ return 1;
893
+ }
894
+ } finally {
895
+ await rm(packed.directory, { force: true, recursive: true });
896
+ }
897
+ success(`${ws.name}@${ws.version} published`);
898
+ }
899
+ console.log('');
900
+ success('All packages published!');
901
+ return 0;
902
+ };
903
+
904
+ /** Apply `--only` filter, erroring if any requested name is unknown. */
905
+ const applyOnlyFilter = (
906
+ workspaces: readonly NativeBunPublishWorkspace[],
907
+ only: readonly string[] | undefined
908
+ ): readonly NativeBunPublishWorkspace[] => {
909
+ if (!only) {
910
+ return workspaces;
911
+ }
912
+ const set = new Set(only);
913
+ const names = new Set(workspaces.map((w) => w.name));
914
+ const unknown = [...set].filter((n) => !names.has(n));
915
+ if (unknown.length > 0) {
916
+ fail(`--only references unknown packages: ${unknown.join(', ')}`);
917
+ process.exit(2);
918
+ }
919
+ return workspaces.filter((w) => set.has(w.name));
920
+ };
921
+
922
+ export const runNativeBunPublishCli = async (
923
+ args: readonly string[] = process.argv.slice(2)
924
+ ): Promise<number> => {
925
+ try {
926
+ const opts = parseArgs(args);
927
+ const all = await discoverWorkspaces();
928
+ const sorted = topoSort(all);
929
+ const selected = applyOnlyFilter(sorted, opts.only);
930
+
931
+ if (selected.length === 0) {
932
+ fail('No workspaces selected');
933
+ return 1;
934
+ }
935
+
936
+ if (opts.mode === 'check') {
937
+ return await runCheck(selected, all);
938
+ }
939
+
940
+ const tag = opts.tag ?? (await resolveDefaultTag());
941
+ if (!tag) {
942
+ fail('Could not resolve a dist-tag. Pass --tag <tag> explicitly.');
943
+ return 1;
944
+ }
945
+ return await runPublish(
946
+ selected,
947
+ all,
948
+ tag,
949
+ opts.otp,
950
+ opts.trustedPublishing ?? false
951
+ );
952
+ } catch (error) {
953
+ const msg = error instanceof Error ? error.message : String(error);
954
+ fail(msg);
955
+ if (process.env['DEBUG'] === '1' && error instanceof Error && error.stack) {
956
+ console.error(error.stack);
957
+ }
958
+ return 1;
959
+ }
960
+ };
961
+
962
+ if (import.meta.main) {
963
+ process.exit(await runNativeBunPublishCli(process.argv.slice(2)));
964
+ }