@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,425 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ import ts from 'typescript';
6
+
7
+ import type { WorkspaceInfo } from './check.js';
8
+
9
+ export type ContractReleaseFactAspect =
10
+ | 'input'
11
+ | 'output'
12
+ | 'surfaces'
13
+ | 'trail'
14
+ | 'visibility';
15
+
16
+ export interface ContractReleaseFact {
17
+ readonly aspect: ContractReleaseFactAspect;
18
+ readonly baseHash: string | null;
19
+ readonly changedFiles: readonly string[];
20
+ readonly currentHash: string | null;
21
+ readonly packageName?: string;
22
+ readonly path: string;
23
+ readonly trailId: string;
24
+ readonly workspacePath?: string;
25
+ }
26
+
27
+ export interface ContractReleaseFactInput {
28
+ readonly baseRef?: string;
29
+ readonly changedFiles: readonly string[];
30
+ readonly repoRoot: string;
31
+ readonly workspaces: readonly WorkspaceInfo[];
32
+ }
33
+
34
+ export interface ContractSourceSnapshot {
35
+ readonly baseSource: string | null;
36
+ readonly changedFiles?: readonly string[];
37
+ readonly currentSource: string | null;
38
+ readonly packageName?: string;
39
+ readonly path: string;
40
+ readonly workspacePath?: string;
41
+ }
42
+
43
+ interface TrailDefinition {
44
+ readonly aspects: Readonly<Record<ContractReleaseFactAspect, string | null>>;
45
+ readonly trailId: string;
46
+ readonly visibility: 'internal' | 'public';
47
+ }
48
+
49
+ const NON_SHIPPING_SOURCE_PATTERNS = [
50
+ /(?:^|\/)__tests__(?:\/|$)/u,
51
+ /(?:^|\/)__snapshots__(?:\/|$)/u,
52
+ /(?:^|\/)dist(?:\/|$)/u,
53
+ /(?:^|\/)\.turbo(?:\/|$)/u,
54
+ /(?:^|\/)node_modules(?:\/|$)/u,
55
+ /\.(?:test|spec|snap)\.[cm]?[jt]sx?$/u,
56
+ /\.test-d\.ts$/u,
57
+ /\.tsbuildinfo$/u,
58
+ ] as const;
59
+
60
+ const SOURCE_PATH_PATTERN = /\.[cm]?[jt]sx?$/u;
61
+
62
+ const normalizePath = (path: string): string =>
63
+ path.replaceAll('\\', '/').replace(/^\.\//u, '');
64
+
65
+ const isPublishableOnTrailsWorkspace = (workspace: WorkspaceInfo): boolean =>
66
+ !workspace.isPrivate && workspace.name.startsWith('@ontrails/');
67
+
68
+ const isUnderWorkspace = (filePath: string, workspacePath: string): boolean =>
69
+ filePath === workspacePath || filePath.startsWith(`${workspacePath}/`);
70
+
71
+ const getWorkspaceRelativePath = (
72
+ filePath: string,
73
+ workspacePath: string
74
+ ): string => filePath.slice(workspacePath.length + 1);
75
+
76
+ const isNonShippingSourcePath = (workspaceRelativePath: string): boolean =>
77
+ NON_SHIPPING_SOURCE_PATTERNS.some((pattern) =>
78
+ pattern.test(workspaceRelativePath)
79
+ );
80
+
81
+ const hashSource = (source: string | null): string | null =>
82
+ source === null
83
+ ? null
84
+ : createHash('sha256').update(source).digest('hex').slice(0, 16);
85
+
86
+ const readCurrentSource = (
87
+ repoRoot: string,
88
+ relativePath: string
89
+ ): string | null => {
90
+ const absolutePath = join(repoRoot, relativePath);
91
+ return existsSync(absolutePath) ? readFileSync(absolutePath, 'utf8') : null;
92
+ };
93
+
94
+ const readBaseSource = (
95
+ repoRoot: string,
96
+ baseRef: string | undefined,
97
+ relativePath: string
98
+ ): string | null => {
99
+ if (!baseRef) {
100
+ return null;
101
+ }
102
+
103
+ const result = Bun.spawnSync({
104
+ cmd: ['git', 'show', `${baseRef}:${relativePath}`],
105
+ cwd: repoRoot,
106
+ stderr: 'pipe',
107
+ stdout: 'pipe',
108
+ });
109
+
110
+ return result.exitCode === 0 ? result.stdout.toString() : null;
111
+ };
112
+
113
+ const workspaceForFile = (
114
+ filePath: string,
115
+ workspaces: readonly WorkspaceInfo[]
116
+ ): WorkspaceInfo | undefined =>
117
+ workspaces
118
+ .filter(isPublishableOnTrailsWorkspace)
119
+ .find((workspace) => isUnderWorkspace(filePath, workspace.relativePath));
120
+
121
+ export const createContractSourceSnapshots = (
122
+ input: ContractReleaseFactInput
123
+ ): readonly ContractSourceSnapshot[] => {
124
+ const snapshots: ContractSourceSnapshot[] = [];
125
+
126
+ for (const changedFile of input.changedFiles.map(normalizePath)) {
127
+ if (!SOURCE_PATH_PATTERN.test(changedFile)) {
128
+ continue;
129
+ }
130
+
131
+ const workspace = workspaceForFile(changedFile, input.workspaces);
132
+ if (!workspace) {
133
+ continue;
134
+ }
135
+
136
+ const workspaceRelativePath = getWorkspaceRelativePath(
137
+ changedFile,
138
+ workspace.relativePath
139
+ );
140
+ if (isNonShippingSourcePath(workspaceRelativePath)) {
141
+ continue;
142
+ }
143
+
144
+ const baseSource = readBaseSource(
145
+ input.repoRoot,
146
+ input.baseRef,
147
+ changedFile
148
+ );
149
+ const currentSource = readCurrentSource(input.repoRoot, changedFile);
150
+ if (baseSource === null && currentSource === null) {
151
+ continue;
152
+ }
153
+
154
+ snapshots.push({
155
+ baseSource,
156
+ changedFiles: [changedFile],
157
+ currentSource,
158
+ packageName: workspace.name,
159
+ path: changedFile,
160
+ workspacePath: workspace.relativePath,
161
+ });
162
+ }
163
+
164
+ return snapshots;
165
+ };
166
+
167
+ const isPropertyNamed = (
168
+ property: ts.ObjectLiteralElementLike,
169
+ name: string
170
+ ): property is ts.PropertyAssignment => {
171
+ if (!ts.isPropertyAssignment(property)) {
172
+ return false;
173
+ }
174
+
175
+ const propertyName = property.name;
176
+ return (
177
+ (ts.isIdentifier(propertyName) && propertyName.text === name) ||
178
+ (ts.isStringLiteral(propertyName) && propertyName.text === name)
179
+ );
180
+ };
181
+
182
+ const findProperty = (
183
+ object: ts.ObjectLiteralExpression,
184
+ name: string
185
+ ): ts.PropertyAssignment | undefined =>
186
+ object.properties.find((property) => isPropertyNamed(property, name));
187
+
188
+ const isTrailCall = (node: ts.CallExpression): boolean =>
189
+ ts.isIdentifier(node.expression) && node.expression.text === 'trail';
190
+
191
+ const literalText = (node: ts.Expression | undefined): string | undefined =>
192
+ node && ts.isStringLiteralLike(node) ? node.text : undefined;
193
+
194
+ const normalizeText = (value: string): string =>
195
+ value.replaceAll(/\s+/gu, ' ').trim();
196
+
197
+ const normalizeExpressionText = (
198
+ expression: ts.Expression | undefined,
199
+ sourceFile: ts.SourceFile,
200
+ constInitializers: ReadonlyMap<string, ts.Expression>
201
+ ): string | null => {
202
+ if (!expression) {
203
+ return null;
204
+ }
205
+
206
+ if (ts.isIdentifier(expression)) {
207
+ const initializer = constInitializers.get(expression.text);
208
+ if (initializer) {
209
+ return `${expression.text}=${normalizeText(
210
+ initializer.getText(sourceFile)
211
+ )}`;
212
+ }
213
+ }
214
+
215
+ return normalizeText(expression.getText(sourceFile));
216
+ };
217
+
218
+ const visibilityFor = (
219
+ object: ts.ObjectLiteralExpression
220
+ ): TrailDefinition['visibility'] => {
221
+ const visibility = findProperty(object, 'visibility');
222
+ const value = literalText(visibility?.initializer);
223
+ if (value === 'internal') {
224
+ return 'internal';
225
+ }
226
+ return 'public';
227
+ };
228
+
229
+ const collectConstInitializers = (
230
+ sourceFile: ts.SourceFile
231
+ ): ReadonlyMap<string, ts.Expression> => {
232
+ const initializers = new Map<string, ts.Expression>();
233
+
234
+ const visit = (node: ts.Node): void => {
235
+ if (
236
+ ts.isVariableDeclaration(node) &&
237
+ ts.isIdentifier(node.name) &&
238
+ node.initializer
239
+ ) {
240
+ initializers.set(node.name.text, node.initializer);
241
+ }
242
+
243
+ ts.forEachChild(node, visit);
244
+ };
245
+
246
+ visit(sourceFile);
247
+ return initializers;
248
+ };
249
+
250
+ const collectTrailDefinitions = (
251
+ source: string
252
+ ): readonly TrailDefinition[] => {
253
+ const sourceFile = ts.createSourceFile(
254
+ 'release-contract-facts.ts',
255
+ source,
256
+ ts.ScriptTarget.Latest,
257
+ true,
258
+ ts.ScriptKind.TS
259
+ );
260
+ const constInitializers = collectConstInitializers(sourceFile);
261
+ const definitions: TrailDefinition[] = [];
262
+
263
+ const visit = (node: ts.Node): void => {
264
+ if (ts.isCallExpression(node) && isTrailCall(node)) {
265
+ const [idArgument, spec] = node.arguments;
266
+ const trailId = literalText(idArgument);
267
+
268
+ if (trailId && spec && ts.isObjectLiteralExpression(spec)) {
269
+ const visibility = visibilityFor(spec);
270
+ definitions.push({
271
+ aspects: {
272
+ input: normalizeExpressionText(
273
+ findProperty(spec, 'input')?.initializer,
274
+ sourceFile,
275
+ constInitializers
276
+ ),
277
+ output: normalizeExpressionText(
278
+ findProperty(spec, 'output')?.initializer,
279
+ sourceFile,
280
+ constInitializers
281
+ ),
282
+ surfaces: normalizeExpressionText(
283
+ findProperty(spec, 'surfaces')?.initializer,
284
+ sourceFile,
285
+ constInitializers
286
+ ),
287
+ trail: normalizeText(trailId),
288
+ visibility,
289
+ },
290
+ trailId,
291
+ visibility,
292
+ });
293
+ }
294
+ }
295
+
296
+ ts.forEachChild(node, visit);
297
+ };
298
+
299
+ visit(sourceFile);
300
+ return definitions;
301
+ };
302
+
303
+ const definitionsById = (
304
+ definitions: readonly TrailDefinition[]
305
+ ): ReadonlyMap<string, TrailDefinition> =>
306
+ new Map(definitions.map((definition) => [definition.trailId, definition]));
307
+
308
+ const isPublicContract = (definition: TrailDefinition | undefined): boolean =>
309
+ definition !== undefined && definition.visibility !== 'internal';
310
+
311
+ const fact = (
312
+ snapshot: ContractSourceSnapshot,
313
+ trailId: string,
314
+ aspect: ContractReleaseFactAspect,
315
+ baseValue: string | null,
316
+ currentValue: string | null
317
+ ): ContractReleaseFact => ({
318
+ aspect,
319
+ baseHash: hashSource(baseValue),
320
+ changedFiles: snapshot.changedFiles ?? [snapshot.path],
321
+ currentHash: hashSource(currentValue),
322
+ ...(snapshot.packageName ? { packageName: snapshot.packageName } : {}),
323
+ path: snapshot.path,
324
+ trailId,
325
+ ...(snapshot.workspacePath ? { workspacePath: snapshot.workspacePath } : {}),
326
+ });
327
+
328
+ const compareDefinitions = (
329
+ snapshot: ContractSourceSnapshot,
330
+ baseDefinition: TrailDefinition | undefined,
331
+ currentDefinition: TrailDefinition | undefined
332
+ ): readonly ContractReleaseFact[] => {
333
+ if (
334
+ !isPublicContract(baseDefinition) &&
335
+ !isPublicContract(currentDefinition)
336
+ ) {
337
+ return [];
338
+ }
339
+
340
+ const trailId =
341
+ currentDefinition?.trailId ?? baseDefinition?.trailId ?? '(unknown)';
342
+
343
+ if (!baseDefinition || !currentDefinition) {
344
+ return [
345
+ fact(
346
+ snapshot,
347
+ trailId,
348
+ 'trail',
349
+ baseDefinition?.aspects.trail ?? null,
350
+ currentDefinition?.aspects.trail ?? null
351
+ ),
352
+ ];
353
+ }
354
+
355
+ const facts: ContractReleaseFact[] = [];
356
+
357
+ for (const aspect of ['input', 'output', 'surfaces'] as const) {
358
+ const baseValue = baseDefinition.aspects[aspect];
359
+ const currentValue = currentDefinition.aspects[aspect];
360
+ if (baseValue !== currentValue) {
361
+ facts.push(fact(snapshot, trailId, aspect, baseValue, currentValue));
362
+ }
363
+ }
364
+
365
+ if (baseDefinition.visibility !== currentDefinition.visibility) {
366
+ facts.push(
367
+ fact(
368
+ snapshot,
369
+ trailId,
370
+ 'visibility',
371
+ baseDefinition.visibility,
372
+ currentDefinition.visibility
373
+ )
374
+ );
375
+ }
376
+
377
+ return facts;
378
+ };
379
+
380
+ export const findPublicTrailContractChangeFactsFromSnapshots = (
381
+ snapshots: readonly ContractSourceSnapshot[]
382
+ ): readonly ContractReleaseFact[] => {
383
+ const facts: ContractReleaseFact[] = [];
384
+
385
+ for (const snapshot of snapshots) {
386
+ const baseDefinitions = definitionsById(
387
+ snapshot.baseSource === null
388
+ ? []
389
+ : collectTrailDefinitions(snapshot.baseSource)
390
+ );
391
+ const currentDefinitions = definitionsById(
392
+ snapshot.currentSource === null
393
+ ? []
394
+ : collectTrailDefinitions(snapshot.currentSource)
395
+ );
396
+ const trailIds = new Set([
397
+ ...baseDefinitions.keys(),
398
+ ...currentDefinitions.keys(),
399
+ ]);
400
+
401
+ for (const trailId of [...trailIds].toSorted()) {
402
+ facts.push(
403
+ ...compareDefinitions(
404
+ snapshot,
405
+ baseDefinitions.get(trailId),
406
+ currentDefinitions.get(trailId)
407
+ )
408
+ );
409
+ }
410
+ }
411
+
412
+ return facts.toSorted(
413
+ (left, right) =>
414
+ left.path.localeCompare(right.path) ||
415
+ left.trailId.localeCompare(right.trailId) ||
416
+ left.aspect.localeCompare(right.aspect)
417
+ );
418
+ };
419
+
420
+ export const findPublicTrailContractChangeFacts = (
421
+ input: ContractReleaseFactInput
422
+ ): readonly ContractReleaseFact[] =>
423
+ findPublicTrailContractChangeFactsFromSnapshots(
424
+ createContractSourceSnapshots(input)
425
+ );
@@ -0,0 +1,221 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join, resolve } from 'node:path';
3
+
4
+ import {
5
+ expectedTrailsCliReleaseAssetNames,
6
+ trailsCliArchiveName,
7
+ trailsCliChecksumName,
8
+ trailsCliReleasePlatformValues,
9
+ } from './cli-bundle.js';
10
+ import type { TrailsCliReleasePlatform } from './cli-bundle.js';
11
+
12
+ export interface GitHubReleaseAsset {
13
+ readonly name: string;
14
+ }
15
+
16
+ export interface GitHubReleaseMetadata {
17
+ readonly assets: readonly GitHubReleaseAsset[];
18
+ readonly draft: boolean;
19
+ readonly published_at: string | null;
20
+ readonly tag_name: string;
21
+ }
22
+
23
+ export type TrailsCliReleaseChecksums = Readonly<
24
+ Record<TrailsCliReleasePlatform, string>
25
+ >;
26
+
27
+ const versionPattern = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u;
28
+ const checksumPattern = /^[0-9a-f]{64}$/u;
29
+
30
+ const requireVersion = (version: string): void => {
31
+ if (!versionPattern.test(version)) {
32
+ throw new Error(`Invalid Trails release version: ${version}`);
33
+ }
34
+ };
35
+
36
+ export const validateTrailsHomebrewRelease = (
37
+ release: GitHubReleaseMetadata,
38
+ version: string
39
+ ): void => {
40
+ requireVersion(version);
41
+ if (release.draft || !release.published_at) {
42
+ throw new Error(`GitHub release v${version} is not published`);
43
+ }
44
+ if (release.tag_name !== `v${version}`) {
45
+ throw new Error(
46
+ `GitHub release tag ${release.tag_name} does not match v${version}`
47
+ );
48
+ }
49
+ for (const expectedName of expectedTrailsCliReleaseAssetNames(version)) {
50
+ const count = release.assets.filter(
51
+ ({ name }) => name === expectedName
52
+ ).length;
53
+ if (count !== 1) {
54
+ throw new Error(
55
+ `Expected exactly one ${expectedName} release asset, found ${count}`
56
+ );
57
+ }
58
+ }
59
+ };
60
+
61
+ export const parseTrailsCliChecksums = (
62
+ contents: string,
63
+ version: string
64
+ ): TrailsCliReleaseChecksums => {
65
+ requireVersion(version);
66
+ const found = new Map<TrailsCliReleasePlatform, string>();
67
+ for (const line of contents.split('\n').filter(Boolean)) {
68
+ const match = /^(?<checksum>[0-9a-f]{64}) {2}(?<name>.+)$/u.exec(line);
69
+ if (!match?.groups) {
70
+ throw new Error(`Invalid Trails CLI checksum line: ${line}`);
71
+ }
72
+ const platform = trailsCliReleasePlatformValues.find(
73
+ (candidate) =>
74
+ trailsCliArchiveName(version, candidate) === match.groups?.['name']
75
+ );
76
+ if (!platform) {
77
+ throw new Error(
78
+ `Unexpected Trails CLI checksum asset: ${match.groups['name']}`
79
+ );
80
+ }
81
+ if (found.has(platform)) {
82
+ throw new Error(`Duplicate Trails CLI checksum for ${platform}`);
83
+ }
84
+ const parsedChecksum = match.groups['checksum'];
85
+ if (!parsedChecksum) {
86
+ throw new Error(`Missing Trails CLI checksum in line: ${line}`);
87
+ }
88
+ found.set(platform, parsedChecksum);
89
+ }
90
+ for (const platform of trailsCliReleasePlatformValues) {
91
+ if (!found.has(platform)) {
92
+ throw new Error(`Missing Trails CLI checksum for ${platform}`);
93
+ }
94
+ }
95
+ return Object.fromEntries(found) as TrailsCliReleaseChecksums;
96
+ };
97
+
98
+ export const verifyTrailsHomebrewAssetDirectory = async (
99
+ assetsDir: string,
100
+ release: GitHubReleaseMetadata,
101
+ version: string
102
+ ): Promise<TrailsCliReleaseChecksums> => {
103
+ validateTrailsHomebrewRelease(release, version);
104
+ const checksumPath = join(assetsDir, trailsCliChecksumName(version));
105
+ const checksums = parseTrailsCliChecksums(
106
+ readFileSync(checksumPath, 'utf8'),
107
+ version
108
+ );
109
+ for (const platform of trailsCliReleasePlatformValues) {
110
+ const archiveName = trailsCliArchiveName(version, platform);
111
+ const bytes = await Bun.file(join(assetsDir, archiveName)).bytes();
112
+ const actual = new Bun.CryptoHasher('sha256').update(bytes).digest('hex');
113
+ if (actual !== checksums[platform]) {
114
+ throw new Error(
115
+ `Checksum mismatch for ${archiveName}: expected ${checksums[platform]}, found ${actual}`
116
+ );
117
+ }
118
+ }
119
+ return checksums;
120
+ };
121
+
122
+ export const renderTrailsHomebrewFormula = (
123
+ version: string,
124
+ checksums: TrailsCliReleaseChecksums
125
+ ): string => {
126
+ requireVersion(version);
127
+ for (const [platform, checksum] of Object.entries(checksums)) {
128
+ if (!checksumPattern.test(checksum)) {
129
+ throw new Error(`Invalid ${platform} SHA256: ${checksum}`);
130
+ }
131
+ }
132
+ return `class Trails < Formula
133
+ desc "Agent-native, contract-first TypeScript framework"
134
+ homepage "https://github.com/outfitter-dev/trails"
135
+ license "MIT"
136
+ # Keep this scheme after the reset from 1.0.0-beta to 0.2.0.
137
+ version_scheme 1
138
+
139
+ depends_on "bun"
140
+
141
+ on_macos do
142
+ on_arm do
143
+ url "https://github.com/outfitter-dev/trails/releases/download/v${version}/trails-v${version}-darwin-arm64.tar.gz"
144
+ sha256 "${checksums['darwin-arm64']}"
145
+ end
146
+ on_intel do
147
+ url "https://github.com/outfitter-dev/trails/releases/download/v${version}/trails-v${version}-darwin-x64.tar.gz"
148
+ sha256 "${checksums['darwin-x64']}"
149
+ end
150
+ end
151
+
152
+ on_linux do
153
+ on_arm do
154
+ url "https://github.com/outfitter-dev/trails/releases/download/v${version}/trails-v${version}-linux-arm64.tar.gz"
155
+ sha256 "${checksums['linux-arm64']}"
156
+ end
157
+ on_intel do
158
+ url "https://github.com/outfitter-dev/trails/releases/download/v${version}/trails-v${version}-linux-x64.tar.gz"
159
+ sha256 "${checksums['linux-x64']}"
160
+ end
161
+ end
162
+
163
+ def install
164
+ libexec.install Dir["*"]
165
+ bin.install_symlink libexec/"bin/trails"
166
+ end
167
+
168
+ test do
169
+ assert_match version.to_s, shell_output("#{bin}/trails --version")
170
+ assert_match "Usage: trails", shell_output("#{bin}/trails --help")
171
+ end
172
+ end
173
+ `;
174
+ };
175
+
176
+ const readOption = (args: readonly string[], flag: string): string => {
177
+ const index = args.indexOf(flag);
178
+ const value = index === -1 ? undefined : args[index + 1];
179
+ if (!value) {
180
+ throw new Error(`Missing required argument: ${flag}`);
181
+ }
182
+ return value;
183
+ };
184
+
185
+ export const runTrailsHomebrewCli = async (
186
+ args: readonly string[]
187
+ ): Promise<number> => {
188
+ try {
189
+ const [command, ...options] = args;
190
+ const version = readOption(options, '--version');
191
+ if (command === 'validate') {
192
+ const releasePath = resolve(readOption(options, '--release-json'));
193
+ const assetsDir = resolve(readOption(options, '--assets-dir'));
194
+ const release = JSON.parse(
195
+ readFileSync(releasePath, 'utf8')
196
+ ) as GitHubReleaseMetadata;
197
+ await verifyTrailsHomebrewAssetDirectory(assetsDir, release, version);
198
+ console.log(`Validated Homebrew release assets for v${version}`);
199
+ return 0;
200
+ }
201
+ if (command === 'render') {
202
+ const checksumsPath = resolve(readOption(options, '--checksums'));
203
+ const outputPath = resolve(readOption(options, '--output'));
204
+ const checksums = parseTrailsCliChecksums(
205
+ readFileSync(checksumsPath, 'utf8'),
206
+ version
207
+ );
208
+ mkdirSync(dirname(outputPath), { recursive: true });
209
+ writeFileSync(
210
+ outputPath,
211
+ renderTrailsHomebrewFormula(version, checksums)
212
+ );
213
+ console.log(`Rendered ${outputPath}`);
214
+ return 0;
215
+ }
216
+ throw new Error(`Unknown Homebrew release command: ${command ?? '<none>'}`);
217
+ } catch (error) {
218
+ console.error(error instanceof Error ? error.message : String(error));
219
+ return 1;
220
+ }
221
+ };