@ontrails/trails 1.0.0-beta.4 → 1.0.0-beta.42

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 (165) hide show
  1. package/CHANGELOG.md +1106 -4
  2. package/README.md +27 -0
  3. package/package.json +31 -7
  4. package/src/app.ts +147 -3
  5. package/src/cli.ts +345 -11
  6. package/src/completions.ts +240 -0
  7. package/src/lifecycle-source-io.ts +33 -0
  8. package/src/load-app-mirror.ts +202 -0
  9. package/src/local-state-io.ts +173 -0
  10. package/src/mcp-app.ts +42 -0
  11. package/src/mcp-options.ts +91 -0
  12. package/src/mcp.ts +8 -0
  13. package/src/project-writes.ts +377 -0
  14. package/src/regrade/config.ts +152 -0
  15. package/src/regrade/history.ts +430 -0
  16. package/src/regrade/live-api-preserve.ts +8 -0
  17. package/src/regrade/plan-artifact.ts +285 -0
  18. package/src/release/bindings.ts +39 -0
  19. package/src/release/check.ts +844 -0
  20. package/src/release/config.ts +63 -0
  21. package/src/release/contract-facts.ts +425 -0
  22. package/src/release/index.ts +142 -0
  23. package/src/release/lock-roundtrip-smoke.ts +230 -0
  24. package/src/release/native-bun-publish.ts +651 -0
  25. package/src/release/native-bun-registry.ts +765 -0
  26. package/src/release/notes-cli.ts +171 -0
  27. package/src/release/notes.ts +390 -0
  28. package/src/release/pack-coherence.ts +455 -0
  29. package/src/release/packed-artifacts-smoke.ts +236 -0
  30. package/src/release/policy.ts +1687 -0
  31. package/src/release/semver.ts +104 -0
  32. package/src/release/smoke.ts +56 -0
  33. package/src/release/wayfinder-dogfood-smoke.ts +760 -0
  34. package/src/retired-topo-command.ts +36 -0
  35. package/src/run-adapter-check.ts +76 -0
  36. package/src/run-collision.ts +126 -0
  37. package/src/run-completions-install.ts +179 -0
  38. package/src/run-example.ts +149 -0
  39. package/src/run-examples.ts +148 -0
  40. package/src/run-quiet.ts +75 -0
  41. package/src/run-release-check.ts +74 -0
  42. package/src/run-schema.ts +74 -0
  43. package/src/run-trace.ts +273 -0
  44. package/src/run-warden.ts +39 -0
  45. package/src/run-watch.ts +432 -0
  46. package/src/run-wayfind-outline.ts +170 -0
  47. package/src/scaffold-version-sync.ts +183 -0
  48. package/src/scaffold-versions.generated.ts +12 -0
  49. package/src/trails/adapter-check.ts +244 -0
  50. package/src/trails/add-surface.ts +99 -45
  51. package/src/trails/add-trail.ts +84 -37
  52. package/src/trails/add-verify.ts +100 -30
  53. package/src/trails/compile.ts +58 -0
  54. package/src/trails/completions-complete.ts +165 -0
  55. package/src/trails/completions.ts +47 -0
  56. package/src/trails/create-adapter.ts +785 -0
  57. package/src/trails/create-scaffold.ts +401 -106
  58. package/src/trails/create-versions.ts +62 -0
  59. package/src/trails/create.ts +186 -72
  60. package/src/trails/deprecate.ts +59 -0
  61. package/src/trails/dev-clean.ts +82 -0
  62. package/src/trails/dev-reset.ts +50 -0
  63. package/src/trails/dev-stats.ts +72 -0
  64. package/src/trails/dev-support.ts +360 -0
  65. package/src/trails/doctor.ts +77 -0
  66. package/src/trails/draft-promote.ts +949 -0
  67. package/src/trails/guide.ts +70 -68
  68. package/src/trails/load-app.ts +1123 -15
  69. package/src/trails/operator-context.ts +66 -0
  70. package/src/trails/project.ts +17 -3
  71. package/src/trails/regrade.ts +3091 -0
  72. package/src/trails/release-check.ts +105 -0
  73. package/src/trails/release-smoke.ts +49 -0
  74. package/src/trails/revise.ts +53 -0
  75. package/src/trails/root-dir.ts +21 -0
  76. package/src/trails/run-example.ts +475 -0
  77. package/src/trails/run-examples.ts +129 -0
  78. package/src/trails/run.ts +434 -0
  79. package/src/trails/scaffold-json.ts +58 -0
  80. package/src/trails/survey.ts +877 -214
  81. package/src/trails/topo-activation.ts +14 -0
  82. package/src/trails/topo-constants.ts +2 -0
  83. package/src/trails/topo-history.ts +47 -0
  84. package/src/trails/topo-output-schemas.ts +259 -0
  85. package/src/trails/topo-pin.ts +38 -0
  86. package/src/trails/topo-read-support.ts +368 -0
  87. package/src/trails/topo-reports.ts +809 -0
  88. package/src/trails/topo-store-support.ts +325 -0
  89. package/src/trails/topo-support.ts +220 -0
  90. package/src/trails/topo-unpin.ts +61 -0
  91. package/src/trails/topo.ts +92 -0
  92. package/src/trails/validate.ts +27 -0
  93. package/src/trails/version-lifecycle-support.ts +936 -0
  94. package/src/trails/warden-guide.ts +134 -0
  95. package/src/trails/warden.ts +198 -58
  96. package/src/trails/wayfind-outline.ts +876 -0
  97. package/src/trails/wayfind.ts +1053 -0
  98. package/src/versions.ts +31 -0
  99. package/.turbo/turbo-build.log +0 -1
  100. package/.turbo/turbo-lint.log +0 -3
  101. package/.turbo/turbo-typecheck.log +0 -1
  102. package/__tests__/examples.test.ts +0 -6
  103. package/dist/bin/trails.d.ts +0 -3
  104. package/dist/bin/trails.d.ts.map +0 -1
  105. package/dist/bin/trails.js +0 -4
  106. package/dist/bin/trails.js.map +0 -1
  107. package/dist/src/app.d.ts +0 -2
  108. package/dist/src/app.d.ts.map +0 -1
  109. package/dist/src/app.js +0 -11
  110. package/dist/src/app.js.map +0 -1
  111. package/dist/src/clack.d.ts +0 -9
  112. package/dist/src/clack.d.ts.map +0 -1
  113. package/dist/src/clack.js +0 -84
  114. package/dist/src/clack.js.map +0 -1
  115. package/dist/src/cli.d.ts +0 -2
  116. package/dist/src/cli.d.ts.map +0 -1
  117. package/dist/src/cli.js +0 -13
  118. package/dist/src/cli.js.map +0 -1
  119. package/dist/src/trails/add-surface.d.ts +0 -13
  120. package/dist/src/trails/add-surface.d.ts.map +0 -1
  121. package/dist/src/trails/add-surface.js +0 -88
  122. package/dist/src/trails/add-surface.js.map +0 -1
  123. package/dist/src/trails/add-trail.d.ts +0 -10
  124. package/dist/src/trails/add-trail.d.ts.map +0 -1
  125. package/dist/src/trails/add-trail.js +0 -77
  126. package/dist/src/trails/add-trail.js.map +0 -1
  127. package/dist/src/trails/add-verify.d.ts +0 -10
  128. package/dist/src/trails/add-verify.d.ts.map +0 -1
  129. package/dist/src/trails/add-verify.js +0 -67
  130. package/dist/src/trails/add-verify.js.map +0 -1
  131. package/dist/src/trails/create-scaffold.d.ts +0 -15
  132. package/dist/src/trails/create-scaffold.d.ts.map +0 -1
  133. package/dist/src/trails/create-scaffold.js +0 -288
  134. package/dist/src/trails/create-scaffold.js.map +0 -1
  135. package/dist/src/trails/create.d.ts +0 -22
  136. package/dist/src/trails/create.d.ts.map +0 -1
  137. package/dist/src/trails/create.js +0 -121
  138. package/dist/src/trails/create.js.map +0 -1
  139. package/dist/src/trails/guide.d.ts +0 -11
  140. package/dist/src/trails/guide.d.ts.map +0 -1
  141. package/dist/src/trails/guide.js +0 -80
  142. package/dist/src/trails/guide.js.map +0 -1
  143. package/dist/src/trails/load-app.d.ts +0 -4
  144. package/dist/src/trails/load-app.d.ts.map +0 -1
  145. package/dist/src/trails/load-app.js +0 -24
  146. package/dist/src/trails/load-app.js.map +0 -1
  147. package/dist/src/trails/project.d.ts +0 -8
  148. package/dist/src/trails/project.d.ts.map +0 -1
  149. package/dist/src/trails/project.js +0 -43
  150. package/dist/src/trails/project.js.map +0 -1
  151. package/dist/src/trails/survey.d.ts +0 -31
  152. package/dist/src/trails/survey.d.ts.map +0 -1
  153. package/dist/src/trails/survey.js +0 -221
  154. package/dist/src/trails/survey.js.map +0 -1
  155. package/dist/src/trails/warden.d.ts +0 -19
  156. package/dist/src/trails/warden.d.ts.map +0 -1
  157. package/dist/src/trails/warden.js +0 -88
  158. package/dist/src/trails/warden.js.map +0 -1
  159. package/dist/tsconfig.tsbuildinfo +0 -1
  160. package/src/__tests__/create.test.ts +0 -349
  161. package/src/__tests__/guide.test.ts +0 -91
  162. package/src/__tests__/load-app.test.ts +0 -15
  163. package/src/__tests__/survey.test.ts +0 -159
  164. package/src/__tests__/warden.test.ts +0 -74
  165. package/tsconfig.json +0 -9
@@ -0,0 +1,455 @@
1
+ import { readFile, readdir, writeFile } from 'node:fs/promises';
2
+ import { join, relative } from 'node:path';
3
+ import { escapeRegExp } from '@ontrails/core';
4
+
5
+ export interface ReleasePackCoherenceInput {
6
+ readonly branchName?: string | undefined;
7
+ readonly changedFiles: readonly string[];
8
+ }
9
+
10
+ export interface ReleasePackCoherenceWorkspace {
11
+ readonly name: string;
12
+ readonly path: string;
13
+ readonly version?: string | undefined;
14
+ }
15
+
16
+ export interface ReleasePackCoherenceLockfileWorkspace {
17
+ readonly name?: string | undefined;
18
+ readonly version?: string | undefined;
19
+ }
20
+
21
+ export interface ReleasePackCoherenceLockfileInput {
22
+ readonly lockfileWorkspaces: Readonly<
23
+ Record<string, ReleasePackCoherenceLockfileWorkspace | undefined>
24
+ >;
25
+ readonly sourceWorkspaces: readonly ReleasePackCoherenceWorkspace[];
26
+ }
27
+
28
+ export interface ReleasePackCoherenceLockfileSyncResult {
29
+ readonly text: string;
30
+ readonly updates: readonly string[];
31
+ }
32
+
33
+ interface RootPackageJson {
34
+ readonly workspaces?: readonly string[] | undefined;
35
+ }
36
+
37
+ interface WorkspacePackageJson {
38
+ readonly name?: string | undefined;
39
+ readonly version?: string | undefined;
40
+ }
41
+
42
+ interface BunLockfile {
43
+ readonly workspaces?:
44
+ | Readonly<
45
+ Record<string, ReleasePackCoherenceLockfileWorkspace | undefined>
46
+ >
47
+ | undefined;
48
+ }
49
+
50
+ const RELEASE_BRANCH_NAME = 'changeset-release/main';
51
+ const REPO_ROOT = process.cwd();
52
+
53
+ export const isReleasePackCoherenceFile = (path: string): boolean =>
54
+ path === 'bun.lock' ||
55
+ path === '.changeset/pre.json' ||
56
+ path === 'package.json' ||
57
+ path.endsWith('/package.json') ||
58
+ path.endsWith('/CHANGELOG.md');
59
+
60
+ export const shouldRunReleasePackCoherenceCheck = ({
61
+ branchName,
62
+ changedFiles,
63
+ }: ReleasePackCoherenceInput): boolean =>
64
+ branchName === RELEASE_BRANCH_NAME ||
65
+ changedFiles.some(isReleasePackCoherenceFile);
66
+
67
+ const commandText = (cmd: readonly string[]): string => cmd.join(' ');
68
+
69
+ const spawnCapture = async (cmd: readonly string[]): Promise<string> => {
70
+ const proc = Bun.spawn(cmd as string[], {
71
+ stderr: 'pipe',
72
+ stdin: 'ignore',
73
+ stdout: 'pipe',
74
+ });
75
+ const [stdout, stderr, exitCode] = await Promise.all([
76
+ new Response(proc.stdout).text(),
77
+ new Response(proc.stderr).text(),
78
+ proc.exited,
79
+ ]);
80
+ if (exitCode !== 0) {
81
+ throw new Error(
82
+ [
83
+ `Command failed: ${commandText(cmd)}`,
84
+ `exit: ${exitCode}`,
85
+ stdout.trim() ? `stdout:\n${stdout}` : undefined,
86
+ stderr.trim() ? `stderr:\n${stderr}` : undefined,
87
+ ]
88
+ .filter((line): line is string => typeof line === 'string')
89
+ .join('\n')
90
+ );
91
+ }
92
+ return stdout;
93
+ };
94
+
95
+ const spawnInherit = async (cmd: readonly string[]): Promise<number> =>
96
+ await Bun.spawn(cmd as string[], {
97
+ env: { ...process.env, GIT_PAGER: 'cat' },
98
+ stderr: 'inherit',
99
+ stdin: 'inherit',
100
+ stdout: 'inherit',
101
+ }).exited;
102
+
103
+ const currentBranchName = async (): Promise<string | undefined> => {
104
+ const output = await spawnCapture(['git', 'branch', '--show-current']);
105
+ const branch = output.trim();
106
+ return branch.length > 0 ? branch : undefined;
107
+ };
108
+
109
+ const changedFilesFromGit = async (): Promise<readonly string[]> => {
110
+ const mergeBaseOutput = await spawnCapture([
111
+ 'git',
112
+ 'merge-base',
113
+ 'origin/main',
114
+ 'HEAD',
115
+ ]);
116
+ const mergeBase = mergeBaseOutput.trim();
117
+ const output = await spawnCapture([
118
+ 'git',
119
+ 'diff',
120
+ '--name-only',
121
+ '--diff-filter=ACMRT',
122
+ `${mergeBase}...HEAD`,
123
+ ]);
124
+ return output
125
+ .split(/\r?\n/u)
126
+ .map((line) => line.trim())
127
+ .filter(Boolean);
128
+ };
129
+
130
+ const changedFilesFromPath = async (
131
+ path: string
132
+ ): Promise<readonly string[]> => {
133
+ const text = await readFile(path, 'utf8');
134
+ return text
135
+ .split(/\r?\n/u)
136
+ .map((line) => line.trim())
137
+ .filter(Boolean);
138
+ };
139
+
140
+ const removeJsonTrailingCommas = (text: string): string =>
141
+ text.replaceAll(/,(\s*[}\]])/gu, '$1');
142
+
143
+ const readJson = async <T>(path: string): Promise<T> =>
144
+ JSON.parse(await readFile(path, 'utf8')) as T;
145
+
146
+ const readJsonc = async <T>(path: string): Promise<T> =>
147
+ JSON.parse(removeJsonTrailingCommas(await readFile(path, 'utf8'))) as T;
148
+
149
+ const discoverWorkspaceDirs = async (
150
+ patterns: readonly string[]
151
+ ): Promise<string[]> => {
152
+ const dirs: string[] = [];
153
+ for (const pattern of patterns) {
154
+ if (pattern.endsWith('/*')) {
155
+ const parent = join(REPO_ROOT, pattern.slice(0, -2));
156
+ let names: string[] = [];
157
+ try {
158
+ const entries = await readdir(parent, { withFileTypes: true });
159
+ names = entries
160
+ .filter((entry) => entry.isDirectory())
161
+ .map((entry) => entry.name);
162
+ } catch {
163
+ continue;
164
+ }
165
+ for (const name of names) {
166
+ const dir = join(parent, name);
167
+ if (await Bun.file(join(dir, 'package.json')).exists()) {
168
+ dirs.push(dir);
169
+ }
170
+ }
171
+ continue;
172
+ }
173
+
174
+ const dir = join(REPO_ROOT, pattern);
175
+ if (await Bun.file(join(dir, 'package.json')).exists()) {
176
+ dirs.push(dir);
177
+ }
178
+ }
179
+ return dirs;
180
+ };
181
+
182
+ const discoverSourceWorkspaces = async (): Promise<
183
+ ReleasePackCoherenceWorkspace[]
184
+ > => {
185
+ const root = await readJson<RootPackageJson>(join(REPO_ROOT, 'package.json'));
186
+ if (!root.workspaces || root.workspaces.length === 0) {
187
+ throw new Error('Root package.json has no "workspaces" field');
188
+ }
189
+
190
+ const dirs = await discoverWorkspaceDirs(root.workspaces);
191
+ const workspaces: ReleasePackCoherenceWorkspace[] = [];
192
+ for (const dir of dirs) {
193
+ const pkg = await readJson<WorkspacePackageJson>(join(dir, 'package.json'));
194
+ if (!pkg.name) {
195
+ continue;
196
+ }
197
+ workspaces.push({
198
+ name: pkg.name,
199
+ path: relative(REPO_ROOT, dir),
200
+ version: pkg.version,
201
+ });
202
+ }
203
+ return workspaces;
204
+ };
205
+
206
+ export const findLockfileWorkspaceMetadataMismatches = ({
207
+ lockfileWorkspaces,
208
+ sourceWorkspaces,
209
+ }: ReleasePackCoherenceLockfileInput): string[] => {
210
+ const mismatches: string[] = [];
211
+ for (const workspace of sourceWorkspaces) {
212
+ const lockWorkspace = lockfileWorkspaces[workspace.path];
213
+ if (!lockWorkspace) {
214
+ mismatches.push(
215
+ `${workspace.path}/package.json is missing from bun.lock workspaces`
216
+ );
217
+ continue;
218
+ }
219
+
220
+ if (lockWorkspace.name !== workspace.name) {
221
+ mismatches.push(
222
+ `${workspace.path}/package.json has name ${workspace.name}, but bun.lock records ${lockWorkspace.name ?? '(missing)'}`
223
+ );
224
+ }
225
+
226
+ if (
227
+ typeof workspace.version === 'string' &&
228
+ lockWorkspace.version !== workspace.version
229
+ ) {
230
+ mismatches.push(
231
+ `${workspace.path}/package.json has version ${workspace.version}, but bun.lock records ${lockWorkspace.version ?? '(missing)'}`
232
+ );
233
+ }
234
+ }
235
+ return mismatches;
236
+ };
237
+
238
+ const findWorkspaceBlockRange = (
239
+ text: string,
240
+ workspacePath: string
241
+ ): { readonly start: number; readonly end: number } | undefined => {
242
+ const keyMatch = new RegExp(
243
+ `^ "${escapeRegExp(workspacePath)}": \\{`,
244
+ 'mu'
245
+ ).exec(text);
246
+ if (!keyMatch) {
247
+ return undefined;
248
+ }
249
+
250
+ const openBrace = text.indexOf('{', keyMatch.index);
251
+ if (openBrace === -1) {
252
+ return undefined;
253
+ }
254
+
255
+ let depth = 0;
256
+ let inString = false;
257
+ let escaped = false;
258
+ for (let index = openBrace; index < text.length; index += 1) {
259
+ const char = text[index];
260
+ if (escaped) {
261
+ escaped = false;
262
+ continue;
263
+ }
264
+ if (char === '\\') {
265
+ escaped = true;
266
+ continue;
267
+ }
268
+ if (char === '"') {
269
+ inString = !inString;
270
+ continue;
271
+ }
272
+ if (inString) {
273
+ continue;
274
+ }
275
+ if (char === '{') {
276
+ depth += 1;
277
+ continue;
278
+ }
279
+ if (char === '}') {
280
+ depth -= 1;
281
+ if (depth === 0) {
282
+ return { end: index + 1, start: openBrace };
283
+ }
284
+ }
285
+ }
286
+
287
+ return undefined;
288
+ };
289
+
290
+ export const syncLockfileWorkspaceMetadataText = (
291
+ text: string,
292
+ sourceWorkspaces: readonly ReleasePackCoherenceWorkspace[]
293
+ ): ReleasePackCoherenceLockfileSyncResult => {
294
+ let nextText = text;
295
+ const updates: string[] = [];
296
+
297
+ for (const workspace of sourceWorkspaces) {
298
+ if (typeof workspace.version !== 'string') {
299
+ continue;
300
+ }
301
+
302
+ const range = findWorkspaceBlockRange(nextText, workspace.path);
303
+ if (!range) {
304
+ continue;
305
+ }
306
+
307
+ const block = nextText.slice(range.start, range.end);
308
+ const versionMatch = /^(\s*"version":\s*")([^"]*)(",?)$/mu.exec(block);
309
+ if (!versionMatch || versionMatch[2] === workspace.version) {
310
+ continue;
311
+ }
312
+
313
+ const replacement = `${versionMatch[1]}${workspace.version}${versionMatch[3]}`;
314
+ const blockStart = range.start + versionMatch.index;
315
+ const valueEnd = blockStart + versionMatch[0].length;
316
+ nextText =
317
+ nextText.slice(0, blockStart) + replacement + nextText.slice(valueEnd);
318
+ updates.push(
319
+ `${workspace.path}/package.json: ${versionMatch[2]} -> ${workspace.version}`
320
+ );
321
+ }
322
+
323
+ return { text: nextText, updates };
324
+ };
325
+
326
+ const loadBunLockfile = async (): Promise<BunLockfile> =>
327
+ await readJsonc<BunLockfile>(join(REPO_ROOT, 'bun.lock'));
328
+
329
+ const syncLockfileWorkspaceMetadata = async (): Promise<void> => {
330
+ const lockfilePath = join(REPO_ROOT, 'bun.lock');
331
+ const [lockfile, sourceWorkspaces] = await Promise.all([
332
+ readFile(lockfilePath, 'utf8'),
333
+ discoverSourceWorkspaces(),
334
+ ]);
335
+ const { text, updates } = syncLockfileWorkspaceMetadataText(
336
+ lockfile,
337
+ sourceWorkspaces
338
+ );
339
+
340
+ if (updates.length === 0) {
341
+ console.error('release-pack: bun.lock workspace metadata already synced');
342
+ return;
343
+ }
344
+
345
+ await writeFile(lockfilePath, text);
346
+ console.error('release-pack: synced bun.lock workspace metadata');
347
+ for (const update of updates) {
348
+ console.error(` ${update}`);
349
+ }
350
+ };
351
+
352
+ const runLockfileWorkspaceMetadataCheck = async (): Promise<number> => {
353
+ const lockfile = await loadBunLockfile();
354
+ if (!lockfile.workspaces) {
355
+ throw new Error('bun.lock has no "workspaces" object');
356
+ }
357
+
358
+ const mismatches = findLockfileWorkspaceMetadataMismatches({
359
+ lockfileWorkspaces: lockfile.workspaces,
360
+ sourceWorkspaces: await discoverSourceWorkspaces(),
361
+ });
362
+
363
+ if (mismatches.length === 0) {
364
+ console.error('release-pack: bun.lock workspace metadata is coherent');
365
+ return 0;
366
+ }
367
+
368
+ console.error('release-pack: bun.lock workspace metadata is stale');
369
+ for (const mismatch of mismatches) {
370
+ console.error(` ${mismatch}`);
371
+ }
372
+ return 1;
373
+ };
374
+
375
+ export interface ReleasePackCoherenceParsedArgs {
376
+ readonly branchName?: string | undefined;
377
+ readonly changedFilesPath?: string | undefined;
378
+ readonly fixLockfile: boolean;
379
+ readonly lockfileOnly: boolean;
380
+ }
381
+
382
+ export const parseReleasePackCoherenceArgs = (
383
+ args: readonly string[]
384
+ ): ReleasePackCoherenceParsedArgs => {
385
+ let branchName: string | undefined;
386
+ let changedFilesPath: string | undefined;
387
+ let fixLockfile = false;
388
+ let lockfileOnly = false;
389
+ const readValue = (index: number, flag: string): string => {
390
+ const value = args[index + 1];
391
+ if (!value || value.startsWith('--')) {
392
+ throw new Error(
393
+ `Missing value for release pack coherence argument: ${flag}`
394
+ );
395
+ }
396
+ return value;
397
+ };
398
+ for (let index = 0; index < args.length; index += 1) {
399
+ const arg = args[index];
400
+ if (arg === '--branch') {
401
+ branchName = readValue(index, arg);
402
+ index += 1;
403
+ } else if (arg === '--changed-files') {
404
+ changedFilesPath = readValue(index, arg);
405
+ index += 1;
406
+ } else if (arg === '--lockfile-only') {
407
+ lockfileOnly = true;
408
+ } else if (arg === '--fix-lockfile') {
409
+ fixLockfile = true;
410
+ } else {
411
+ throw new Error(`Unknown release pack coherence argument: ${arg}`);
412
+ }
413
+ }
414
+ return { branchName, changedFilesPath, fixLockfile, lockfileOnly };
415
+ };
416
+
417
+ export const runReleasePackCoherenceCli = async (
418
+ args: readonly string[] = process.argv.slice(2)
419
+ ): Promise<number> => {
420
+ try {
421
+ const parsed = parseReleasePackCoherenceArgs(args);
422
+ if (parsed.fixLockfile) {
423
+ await syncLockfileWorkspaceMetadata();
424
+ console.error('release-pack: checking bun.lock workspace metadata');
425
+ return await runLockfileWorkspaceMetadataCheck();
426
+ }
427
+
428
+ const [branchName, changedFiles] = await Promise.all([
429
+ parsed.branchName
430
+ ? Promise.resolve(parsed.branchName)
431
+ : currentBranchName(),
432
+ parsed.changedFilesPath
433
+ ? changedFilesFromPath(parsed.changedFilesPath)
434
+ : changedFilesFromGit(),
435
+ ]);
436
+
437
+ if (!shouldRunReleasePackCoherenceCheck({ branchName, changedFiles })) {
438
+ console.error(
439
+ 'release-pack: skipped; no package release metadata changed'
440
+ );
441
+ return 0;
442
+ }
443
+
444
+ if (parsed.lockfileOnly) {
445
+ console.error('release-pack: checking bun.lock workspace metadata');
446
+ return await runLockfileWorkspaceMetadataCheck();
447
+ }
448
+
449
+ console.error('release-pack: checking packed workspace metadata');
450
+ return await spawnInherit(['bun', 'run', 'publish:check']);
451
+ } catch (error) {
452
+ console.error(error instanceof Error ? error.message : String(error));
453
+ return 1;
454
+ }
455
+ };
@@ -0,0 +1,236 @@
1
+ /* oxlint-disable eslint-plugin-jest/require-hook, max-statements -- end-to-end package smoke with temp consumer setup */
2
+ /**
3
+ * Packs public first-party packages into tarballs, installs them into a
4
+ * scratch consumer with first-party overrides, and runs the Warden/Trails CLI
5
+ * from the packed artifacts.
6
+ */
7
+
8
+ import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises';
9
+ import { tmpdir } from 'node:os';
10
+ import { isAbsolute, join, resolve } from 'node:path';
11
+
12
+ const REPO_ROOT = resolve(process.cwd());
13
+
14
+ interface PackedSmokePackageJson {
15
+ readonly name?: string;
16
+ readonly private?: boolean;
17
+ readonly version?: string;
18
+ readonly workspaces?: readonly string[];
19
+ }
20
+
21
+ interface PackedSmokeWorkspace {
22
+ readonly name: string;
23
+ readonly path: string;
24
+ }
25
+
26
+ export interface PackedArtifactsSmokeResult {
27
+ readonly check: 'packed-artifacts';
28
+ readonly message: string;
29
+ readonly packageCount: number;
30
+ readonly passed: true;
31
+ }
32
+
33
+ const commandText = (cmd: readonly string[]): string => cmd.join(' ');
34
+
35
+ const readJson = async <T>(path: string): Promise<T> =>
36
+ (await Bun.file(path).json()) as T;
37
+
38
+ const lastOutputLine = (output: string): string => {
39
+ const line = output
40
+ .split(/\r?\n/)
41
+ .map((item) => item.trim())
42
+ .findLast((item) => item.length > 0);
43
+ if (line === undefined) {
44
+ throw new Error('Expected command output, received none');
45
+ }
46
+ return line;
47
+ };
48
+
49
+ const runCapture = async (
50
+ cmd: readonly string[],
51
+ cwd: string
52
+ ): Promise<string> => {
53
+ const proc = Bun.spawn(cmd as string[], {
54
+ cwd,
55
+ stderr: 'pipe',
56
+ stdin: 'ignore',
57
+ stdout: 'pipe',
58
+ });
59
+ const [stdout, stderr, exitCode] = await Promise.all([
60
+ new Response(proc.stdout).text(),
61
+ new Response(proc.stderr).text(),
62
+ proc.exited,
63
+ ]);
64
+ if (exitCode !== 0) {
65
+ throw new Error(
66
+ [
67
+ `Command failed in ${cwd}: ${commandText(cmd)}`,
68
+ `exit: ${exitCode}`,
69
+ stdout.trim() ? `stdout:\n${stdout}` : undefined,
70
+ stderr.trim() ? `stderr:\n${stderr}` : undefined,
71
+ ]
72
+ .filter((line): line is string => typeof line === 'string')
73
+ .join('\n')
74
+ );
75
+ }
76
+ return stdout || stderr;
77
+ };
78
+
79
+ const workspaceDirs = async (): Promise<readonly string[]> => {
80
+ const rootPackage = await readJson<PackedSmokePackageJson>(
81
+ join(REPO_ROOT, 'package.json')
82
+ );
83
+ const dirs: string[] = [];
84
+ for (const pattern of rootPackage.workspaces ?? []) {
85
+ if (!pattern.endsWith('/*')) {
86
+ throw new Error(`Unsupported workspace pattern: ${pattern}`);
87
+ }
88
+ const base = join(REPO_ROOT, pattern.slice(0, -2));
89
+ for (const entry of await readdir(base, { withFileTypes: true })) {
90
+ const dir = join(base, entry.name);
91
+ if (
92
+ entry.isDirectory() &&
93
+ (await Bun.file(join(dir, 'package.json')).exists())
94
+ ) {
95
+ dirs.push(dir);
96
+ }
97
+ }
98
+ }
99
+ return dirs.toSorted((a, b) => a.localeCompare(b));
100
+ };
101
+
102
+ const publicFirstPartyWorkspaces = async (): Promise<
103
+ readonly PackedSmokeWorkspace[]
104
+ > => {
105
+ const workspaces: PackedSmokeWorkspace[] = [];
106
+ for (const path of await workspaceDirs()) {
107
+ const packageJson = await readJson<PackedSmokePackageJson>(
108
+ join(path, 'package.json')
109
+ );
110
+ if (
111
+ packageJson.private !== true &&
112
+ packageJson.name?.startsWith('@ontrails/') &&
113
+ packageJson.version !== undefined
114
+ ) {
115
+ workspaces.push({
116
+ name: packageJson.name,
117
+ path,
118
+ });
119
+ }
120
+ }
121
+ return workspaces.toSorted((a, b) => a.name.localeCompare(b.name));
122
+ };
123
+
124
+ const packWorkspace = async (
125
+ workspace: PackedSmokeWorkspace,
126
+ packRoot: string
127
+ ): Promise<string> => {
128
+ const output = await runCapture(
129
+ ['bun', 'pm', 'pack', '--destination', packRoot, '--quiet'],
130
+ workspace.path
131
+ );
132
+ const tarball = lastOutputLine(output);
133
+ const destinationPath = isAbsolute(tarball)
134
+ ? tarball
135
+ : join(packRoot, tarball);
136
+ if (await Bun.file(destinationPath).exists()) {
137
+ return destinationPath;
138
+ }
139
+ throw new Error(
140
+ `bun pm pack did not create expected tarball for ${workspace.name}: ${destinationPath} (destination: ${packRoot})`
141
+ );
142
+ };
143
+
144
+ const writeConsumerManifest = async (
145
+ consumerRoot: string,
146
+ tarballsByName: ReadonlyMap<string, string>
147
+ ): Promise<void> => {
148
+ const tarballDependencies = Object.fromEntries(
149
+ [...tarballsByName.entries()].map(([name, tarball]) => [
150
+ name,
151
+ `file:${tarball}`,
152
+ ])
153
+ );
154
+ await writeFile(
155
+ join(consumerRoot, 'package.json'),
156
+ `${JSON.stringify(
157
+ {
158
+ dependencies: tarballDependencies,
159
+ overrides: tarballDependencies,
160
+ private: true,
161
+ type: 'module',
162
+ },
163
+ null,
164
+ 2
165
+ )}\n`
166
+ );
167
+ };
168
+
169
+ const binPath = (consumerRoot: string, name: 'trails' | 'warden'): string =>
170
+ join(consumerRoot, 'node_modules', '.bin', name);
171
+
172
+ export const runPackedArtifactsSmoke =
173
+ async (): Promise<PackedArtifactsSmokeResult> => {
174
+ const tempRoot = await mkdtemp(join(tmpdir(), 'trails-packed-dogfood-'));
175
+ const packRoot = join(tempRoot, 'pack');
176
+ const consumerRoot = join(tempRoot, 'consumer');
177
+ let succeeded = false;
178
+
179
+ try {
180
+ await Promise.all([
181
+ mkdir(packRoot, { recursive: true }),
182
+ mkdir(consumerRoot, { recursive: true }),
183
+ ]);
184
+
185
+ const workspaces = await publicFirstPartyWorkspaces();
186
+ const tarballsByName = new Map<string, string>();
187
+ for (const workspace of workspaces) {
188
+ tarballsByName.set(
189
+ workspace.name,
190
+ await packWorkspace(workspace, packRoot)
191
+ );
192
+ }
193
+
194
+ await writeConsumerManifest(consumerRoot, tarballsByName);
195
+ await runCapture(['bun', 'install', '--silent'], consumerRoot);
196
+
197
+ await runCapture(
198
+ [
199
+ binPath(consumerRoot, 'warden'),
200
+ '--root-dir',
201
+ REPO_ROOT,
202
+ '--lock',
203
+ 'skip',
204
+ '--format',
205
+ 'summary',
206
+ ],
207
+ REPO_ROOT
208
+ );
209
+ await runCapture([binPath(consumerRoot, 'trails'), '--help'], REPO_ROOT);
210
+ await runCapture(
211
+ [binPath(consumerRoot, 'trails'), 'warden', '--lock', 'skip'],
212
+ REPO_ROOT
213
+ );
214
+
215
+ succeeded = true;
216
+ return {
217
+ check: 'packed-artifacts',
218
+ message: `Packed artifact smoke passed for ${workspaces.length} @ontrails/* packages.`,
219
+ packageCount: workspaces.length,
220
+ passed: true,
221
+ };
222
+ } finally {
223
+ if (succeeded) {
224
+ await rm(tempRoot, { force: true, recursive: true });
225
+ } else {
226
+ console.error(
227
+ `Packed dogfood temp root kept for inspection: ${tempRoot}`
228
+ );
229
+ }
230
+ }
231
+ };
232
+
233
+ if (import.meta.main) {
234
+ const result = await runPackedArtifactsSmoke();
235
+ console.log(result.message);
236
+ }