@ontrails/trails 1.0.0-beta.48 → 1.0.0-beta.49

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # trails
2
2
 
3
+ ## 1.0.0-beta.49
4
+
5
+ ### Patch Changes
6
+
7
+ - [`822b403`](https://github.com/outfitter-dev/trails/commit/822b40375cc2947f0824d5f4e291b370b5062866): Ship deterministic macOS and Linux CLI bundles for the GitHub release and hand published releases to the Outfitter Homebrew tap through a reviewable formula PR.
8
+
3
9
  ## 1.0.0-beta.48
4
10
 
5
11
  ### Patch Changes
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  Command-line tools for working with Trails projects.
4
4
 
5
+ Install, update, or uninstall the standalone CLI with Homebrew using the canonical [installation guide](../../docs/getting-started.md#installation). The formula requires Bun at runtime.
6
+
5
7
  Use the CLI to scaffold a Trails app, add surfaces, inspect the current topo, run warden checks, manage draft state, and keep local Trails project state tidy.
6
8
 
7
9
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ontrails/trails",
3
- "version": "1.0.0-beta.48",
3
+ "version": "1.0.0-beta.49",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/outfitter-dev/trails.git",
@@ -32,25 +32,25 @@
32
32
  },
33
33
  "dependencies": {
34
34
  "@clack/prompts": "^1.1.0",
35
- "@ontrails/adapter-kit": "^1.0.0-beta.48",
36
- "@ontrails/cli": "^1.0.0-beta.48",
37
- "@ontrails/commander": "^1.0.0-beta.48",
38
- "@ontrails/config": "^1.0.0-beta.48",
39
- "@ontrails/core": "^1.0.0-beta.48",
40
- "@ontrails/http": "^1.0.0-beta.48",
41
- "@ontrails/mcp": "^1.0.0-beta.48",
42
- "@ontrails/observability": "^1.0.0-beta.48",
43
- "@ontrails/permits": "^1.0.0-beta.48",
44
- "@ontrails/regrade": "^1.0.0-beta.48",
45
- "@ontrails/source": "^1.0.0-beta.48",
46
- "@ontrails/topography": "^1.0.0-beta.48",
47
- "@ontrails/warden": "^1.0.0-beta.48",
35
+ "@ontrails/adapter-kit": "^1.0.0-beta.49",
36
+ "@ontrails/cli": "^1.0.0-beta.49",
37
+ "@ontrails/commander": "^1.0.0-beta.49",
38
+ "@ontrails/config": "^1.0.0-beta.49",
39
+ "@ontrails/core": "^1.0.0-beta.49",
40
+ "@ontrails/http": "^1.0.0-beta.49",
41
+ "@ontrails/mcp": "^1.0.0-beta.49",
42
+ "@ontrails/observability": "^1.0.0-beta.49",
43
+ "@ontrails/permits": "^1.0.0-beta.49",
44
+ "@ontrails/regrade": "^1.0.0-beta.49",
45
+ "@ontrails/source": "^1.0.0-beta.49",
46
+ "@ontrails/topography": "^1.0.0-beta.49",
47
+ "@ontrails/warden": "^1.0.0-beta.49",
48
48
  "commander": "^14.0.3",
49
49
  "typescript": "^5.9.3",
50
50
  "zod": "^4.3.5"
51
51
  },
52
52
  "devDependencies": {
53
- "@ontrails/cloudflare": "^1.0.0-beta.48",
54
- "@ontrails/testing": "^1.0.0-beta.48"
53
+ "@ontrails/cloudflare": "^1.0.0-beta.49",
54
+ "@ontrails/testing": "^1.0.0-beta.49"
55
55
  }
56
56
  }
@@ -0,0 +1,575 @@
1
+ import {
2
+ chmodSync,
3
+ cpSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ mkdtempSync,
7
+ readFileSync,
8
+ readdirSync,
9
+ rmSync,
10
+ writeFileSync,
11
+ } from 'node:fs';
12
+ import { tmpdir } from 'node:os';
13
+ import { dirname, join, relative, resolve } from 'node:path';
14
+
15
+ export const trailsCliReleasePlatformValues = [
16
+ 'darwin-arm64',
17
+ 'darwin-x64',
18
+ 'linux-arm64',
19
+ 'linux-x64',
20
+ ] as const;
21
+
22
+ export type TrailsCliReleasePlatform =
23
+ (typeof trailsCliReleasePlatformValues)[number];
24
+
25
+ export interface TrailsCliReleasePlatformDescriptor {
26
+ readonly oxcParserBinding: string;
27
+ readonly oxcResolverBinding: string;
28
+ readonly platform: TrailsCliReleasePlatform;
29
+ }
30
+
31
+ export const trailsCliReleasePlatforms = [
32
+ {
33
+ oxcParserBinding: '@oxc-parser/binding-darwin-arm64',
34
+ oxcResolverBinding: '@oxc-resolver/binding-darwin-arm64',
35
+ platform: 'darwin-arm64',
36
+ },
37
+ {
38
+ oxcParserBinding: '@oxc-parser/binding-darwin-x64',
39
+ oxcResolverBinding: '@oxc-resolver/binding-darwin-x64',
40
+ platform: 'darwin-x64',
41
+ },
42
+ {
43
+ oxcParserBinding: '@oxc-parser/binding-linux-arm64-gnu',
44
+ oxcResolverBinding: '@oxc-resolver/binding-linux-arm64-gnu',
45
+ platform: 'linux-arm64',
46
+ },
47
+ {
48
+ oxcParserBinding: '@oxc-parser/binding-linux-x64-gnu',
49
+ oxcResolverBinding: '@oxc-resolver/binding-linux-x64-gnu',
50
+ platform: 'linux-x64',
51
+ },
52
+ ] as const satisfies readonly TrailsCliReleasePlatformDescriptor[];
53
+
54
+ export interface BuildTrailsCliReleaseOptions {
55
+ readonly outDir: string;
56
+ readonly platform?: TrailsCliReleasePlatform;
57
+ readonly repoRoot?: string;
58
+ readonly version?: string;
59
+ }
60
+
61
+ export interface TrailsCliReleaseArtifact {
62
+ readonly archiveName: string;
63
+ readonly archivePath: string;
64
+ readonly checksum: string;
65
+ readonly checksumPath: string;
66
+ readonly platform: TrailsCliReleasePlatform;
67
+ readonly version: string;
68
+ }
69
+
70
+ const DEFAULT_REPO_ROOT = resolve(import.meta.dir, '../../../..');
71
+ const textDecoder = new TextDecoder();
72
+ const BUNDLE_EXTERNALS = ['oxc-parser', 'oxc-resolver'] as const;
73
+
74
+ const platformDescriptor = (
75
+ platform: TrailsCliReleasePlatform
76
+ ): TrailsCliReleasePlatformDescriptor => {
77
+ const descriptor = trailsCliReleasePlatforms.find(
78
+ (candidate) => candidate.platform === platform
79
+ );
80
+ if (!descriptor) {
81
+ throw new Error(`Unsupported Trails CLI release platform: ${platform}`);
82
+ }
83
+ return descriptor;
84
+ };
85
+
86
+ export const currentTrailsCliReleasePlatform = (
87
+ platform = process.platform,
88
+ architecture = process.arch
89
+ ): TrailsCliReleasePlatform => {
90
+ const candidate = `${platform}-${architecture}`;
91
+ if (
92
+ trailsCliReleasePlatformValues.includes(
93
+ candidate as TrailsCliReleasePlatform
94
+ )
95
+ ) {
96
+ return candidate as TrailsCliReleasePlatform;
97
+ }
98
+ throw new Error(`Unsupported Trails CLI release host: ${candidate}`);
99
+ };
100
+
101
+ export const trailsCliArchiveName = (
102
+ version: string,
103
+ platform: TrailsCliReleasePlatform
104
+ ): string => `trails-v${version}-${platform}.tar.gz`;
105
+
106
+ export const trailsCliChecksumName = (version: string): string =>
107
+ `trails-v${version}-SHA256SUMS`;
108
+
109
+ export const expectedTrailsCliReleaseAssetNames = (
110
+ version: string
111
+ ): readonly string[] => [
112
+ ...trailsCliReleasePlatformValues.map((platform) =>
113
+ trailsCliArchiveName(version, platform)
114
+ ),
115
+ trailsCliChecksumName(version),
116
+ ];
117
+
118
+ const findPackageRoot = (entryPath: string, expectedName: string): string => {
119
+ let candidate = dirname(entryPath);
120
+ while (candidate !== dirname(candidate)) {
121
+ const packagePath = join(candidate, 'package.json');
122
+ if (existsSync(packagePath)) {
123
+ const packageJson = JSON.parse(readFileSync(packagePath, 'utf8')) as {
124
+ name?: string;
125
+ };
126
+ if (packageJson.name === expectedName) {
127
+ return candidate;
128
+ }
129
+ }
130
+ candidate = dirname(candidate);
131
+ }
132
+ throw new Error(`Could not locate package root for ${expectedName}`);
133
+ };
134
+
135
+ const resolvePackageRoot = (
136
+ packageName: string,
137
+ fromDirectory: string
138
+ ): string =>
139
+ findPackageRoot(Bun.resolveSync(packageName, fromDirectory), packageName);
140
+
141
+ const copyPackage = (
142
+ packageName: string,
143
+ sourceRoot: string,
144
+ bundleRoot: string
145
+ ): { readonly name: string; readonly version: string } => {
146
+ const scope = packageName.startsWith('@') ? packageName.split('/')[0] : null;
147
+ const target = join(bundleRoot, 'node_modules', packageName);
148
+ if (scope) {
149
+ mkdirSync(join(bundleRoot, 'node_modules', scope), { recursive: true });
150
+ }
151
+ cpSync(sourceRoot, target, { dereference: true, recursive: true });
152
+ const packageJson = JSON.parse(
153
+ readFileSync(join(sourceRoot, 'package.json'), 'utf8')
154
+ ) as { name?: string; version?: string };
155
+ if (packageJson.name !== packageName || !packageJson.version) {
156
+ throw new Error(`Invalid package metadata for ${packageName}`);
157
+ }
158
+ return { name: packageName, version: packageJson.version };
159
+ };
160
+
161
+ const launcherSource = `#!/bin/sh
162
+ set -eu
163
+
164
+ source_path=$0
165
+ while [ -L "$source_path" ]; do
166
+ source_dir=$(CDPATH= cd -P -- "$(dirname -- "$source_path")" && pwd)
167
+ source_path=$(readlink "$source_path")
168
+ case $source_path in
169
+ /*) ;;
170
+ *) source_path=$source_dir/$source_path ;;
171
+ esac
172
+ done
173
+ source_dir=$(CDPATH= cd -P -- "$(dirname -- "$source_path")" && pwd)
174
+ exec bun "$source_dir/../lib/trails.js" "$@"
175
+ `;
176
+
177
+ interface ArchiveEntry {
178
+ readonly bytes: Buffer;
179
+ readonly mode: number;
180
+ readonly path: string;
181
+ }
182
+
183
+ const collectArchiveEntries = (
184
+ rootDir: string,
185
+ archiveRoot: string
186
+ ): readonly ArchiveEntry[] => {
187
+ const entries: ArchiveEntry[] = [];
188
+ const visit = (directory: string): void => {
189
+ for (const entry of readdirSync(directory, {
190
+ withFileTypes: true,
191
+ }).toSorted((left, right) => left.name.localeCompare(right.name))) {
192
+ const absolutePath = join(directory, entry.name);
193
+ if (entry.isDirectory()) {
194
+ visit(absolutePath);
195
+ continue;
196
+ }
197
+ if (!entry.isFile()) {
198
+ throw new Error(
199
+ `Release bundles cannot contain links: ${absolutePath}`
200
+ );
201
+ }
202
+ const relativePath = relative(rootDir, absolutePath).replaceAll(
203
+ '\\',
204
+ '/'
205
+ );
206
+ entries.push({
207
+ bytes: readFileSync(absolutePath),
208
+ mode: relativePath === 'bin/trails' ? 0o755 : 0o644,
209
+ path: `${archiveRoot}/${relativePath}`,
210
+ });
211
+ }
212
+ };
213
+ visit(rootDir);
214
+ return entries;
215
+ };
216
+
217
+ const writeText = (
218
+ target: Buffer,
219
+ offset: number,
220
+ length: number,
221
+ value: string
222
+ ): void => {
223
+ const encoded = Buffer.from(value);
224
+ if (encoded.length > length) {
225
+ throw new Error(`Tar header field is too long: ${value}`);
226
+ }
227
+ encoded.copy(target, offset);
228
+ };
229
+
230
+ const writeOctal = (
231
+ target: Buffer,
232
+ offset: number,
233
+ length: number,
234
+ value: number
235
+ ): void => {
236
+ const encoded = value.toString(8).padStart(length - 1, '0');
237
+ writeText(target, offset, length - 1, encoded);
238
+ };
239
+
240
+ const splitTarPath = (
241
+ path: string
242
+ ): { readonly name: string; readonly prefix: string } => {
243
+ if (Buffer.byteLength(path) <= 100) {
244
+ return { name: path, prefix: '' };
245
+ }
246
+ for (
247
+ let index = path.lastIndexOf('/');
248
+ index > 0;
249
+ index = path.lastIndexOf('/', index - 1)
250
+ ) {
251
+ const prefix = path.slice(0, index);
252
+ const name = path.slice(index + 1);
253
+ if (Buffer.byteLength(prefix) <= 155 && Buffer.byteLength(name) <= 100) {
254
+ return { name, prefix };
255
+ }
256
+ }
257
+ throw new Error(`Tar path is too long: ${path}`);
258
+ };
259
+
260
+ const tarHeader = (entry: ArchiveEntry): Buffer => {
261
+ const header = Buffer.alloc(512);
262
+ const { name, prefix } = splitTarPath(entry.path);
263
+ writeText(header, 0, 100, name);
264
+ writeOctal(header, 100, 8, entry.mode);
265
+ writeOctal(header, 108, 8, 0);
266
+ writeOctal(header, 116, 8, 0);
267
+ writeOctal(header, 124, 12, entry.bytes.length);
268
+ writeOctal(header, 136, 12, 0);
269
+ header.fill(0x20, 148, 156);
270
+ header[156] = 0x30;
271
+ writeText(header, 257, 6, 'ustar');
272
+ writeText(header, 263, 2, '00');
273
+ writeText(header, 345, 155, prefix);
274
+ const checksum = header.reduce((sum, byte) => sum + byte, 0);
275
+ writeText(header, 148, 8, `${checksum.toString(8).padStart(6, '0')}\0 `);
276
+ return header;
277
+ };
278
+
279
+ export const createDeterministicTarGzip = (
280
+ rootDir: string,
281
+ archiveRoot: string
282
+ ): Uint8Array => {
283
+ const chunks: Buffer[] = [];
284
+ for (const entry of collectArchiveEntries(rootDir, archiveRoot)) {
285
+ chunks.push(tarHeader(entry), entry.bytes);
286
+ const padding = (512 - (entry.bytes.length % 512)) % 512;
287
+ if (padding > 0) {
288
+ chunks.push(Buffer.alloc(padding));
289
+ }
290
+ }
291
+ chunks.push(Buffer.alloc(1024));
292
+ return Bun.gzipSync(Buffer.concat(chunks), { level: 9 });
293
+ };
294
+
295
+ const sha256 = (bytes: Uint8Array): string =>
296
+ new Bun.CryptoHasher('sha256').update(bytes).digest('hex');
297
+
298
+ const readVersion = (repoRoot: string): string => {
299
+ const packageJson = JSON.parse(
300
+ readFileSync(join(repoRoot, 'apps/trails/package.json'), 'utf8')
301
+ ) as { version?: string };
302
+ if (!packageJson.version) {
303
+ throw new Error('apps/trails/package.json does not declare a version');
304
+ }
305
+ return packageJson.version;
306
+ };
307
+
308
+ export const verifyTrailsCliArchive = (
309
+ artifact: Pick<
310
+ TrailsCliReleaseArtifact,
311
+ 'archivePath' | 'platform' | 'version'
312
+ >
313
+ ): void => {
314
+ const host = currentTrailsCliReleasePlatform();
315
+ if (artifact.platform !== host) {
316
+ throw new Error(
317
+ `Cannot cold-start ${artifact.platform} on the ${host} release host`
318
+ );
319
+ }
320
+ const extractRoot = mkdtempSync(join(tmpdir(), 'trails-cli-cold-'));
321
+ try {
322
+ const extract = Bun.spawnSync([
323
+ 'tar',
324
+ '-xzf',
325
+ artifact.archivePath,
326
+ '-C',
327
+ extractRoot,
328
+ ]);
329
+ if (extract.exitCode !== 0) {
330
+ throw new Error(textDecoder.decode(extract.stderr));
331
+ }
332
+ const rootName = artifact.archivePath
333
+ .split('/')
334
+ .at(-1)
335
+ ?.replace(/\.tar\.gz$/u, '');
336
+ if (!rootName) {
337
+ throw new Error(`Invalid archive path: ${artifact.archivePath}`);
338
+ }
339
+ const launcher = join(extractRoot, rootName, 'bin/trails');
340
+ const bundleRoot = join(extractRoot, rootName);
341
+ for (const [argument, expected] of [
342
+ ['--version', artifact.version],
343
+ ['--help', 'Usage: trails'],
344
+ ] as const) {
345
+ const result = Bun.spawnSync([launcher, argument], {
346
+ cwd: extractRoot,
347
+ env: {
348
+ HOME: extractRoot,
349
+ PATH: process.env['PATH'] ?? '',
350
+ },
351
+ });
352
+ const output = textDecoder.decode(result.stdout);
353
+ if (result.exitCode !== 0 || !output.includes(expected)) {
354
+ throw new Error(
355
+ `Cold Trails CLI ${argument} check failed: ${textDecoder.decode(result.stderr) || output}`
356
+ );
357
+ }
358
+ }
359
+ const example = Bun.spawnSync(
360
+ [
361
+ launcher,
362
+ 'run',
363
+ 'example',
364
+ 'survey.brief',
365
+ 'Brief capability report',
366
+ '--root-dir',
367
+ '.',
368
+ '--module',
369
+ 'lib/app.js',
370
+ '--permit',
371
+ '{"id":"release-cold-verifier","scopes":["trails:run"]}',
372
+ ],
373
+ {
374
+ cwd: bundleRoot,
375
+ env: {
376
+ HOME: extractRoot,
377
+ PATH: process.env['PATH'] ?? '',
378
+ },
379
+ }
380
+ );
381
+ const exampleOutput = textDecoder.decode(example.stdout);
382
+ const expectedExampleOutput = 'OK survey.brief :: Brief capability report';
383
+ if (
384
+ example.exitCode !== 0 ||
385
+ !exampleOutput.split(/\r?\n/u).includes(expectedExampleOutput)
386
+ ) {
387
+ throw new Error(
388
+ `Cold Trails CLI authored example check failed: ${textDecoder.decode(example.stderr) || exampleOutput}`
389
+ );
390
+ }
391
+ } finally {
392
+ rmSync(extractRoot, { force: true, recursive: true });
393
+ }
394
+ };
395
+
396
+ export const buildTrailsCliReleaseArtifact = async (
397
+ options: BuildTrailsCliReleaseOptions
398
+ ): Promise<TrailsCliReleaseArtifact> => {
399
+ const repoRoot = resolve(options.repoRoot ?? DEFAULT_REPO_ROOT);
400
+ const platform = options.platform ?? currentTrailsCliReleasePlatform();
401
+ const host = currentTrailsCliReleasePlatform();
402
+ if (platform !== host) {
403
+ throw new Error(
404
+ `Build ${platform} on its matching host so Bun installs the correct optional bindings; current host is ${host}`
405
+ );
406
+ }
407
+ const descriptor = platformDescriptor(platform);
408
+ const packageVersion = readVersion(repoRoot);
409
+ const version = options.version ?? packageVersion;
410
+ if (version !== packageVersion) {
411
+ throw new Error(
412
+ `Requested version ${version} does not match apps/trails/package.json ${packageVersion}`
413
+ );
414
+ }
415
+
416
+ const outputDir = resolve(options.outDir);
417
+ mkdirSync(outputDir, { recursive: true });
418
+ const stagingDir = mkdtempSync(join(tmpdir(), 'trails-cli-release-'));
419
+ const archiveName = trailsCliArchiveName(version, platform);
420
+ const archiveRoot = archiveName.replace(/\.tar\.gz$/u, '');
421
+ const bundleRoot = join(stagingDir, archiveRoot);
422
+ try {
423
+ mkdirSync(join(bundleRoot, 'bin'), { recursive: true });
424
+ mkdirSync(join(bundleRoot, 'lib'), { recursive: true });
425
+ for (const [entrypoint, output, label] of [
426
+ ['apps/trails/bin/trails.ts', 'trails.js', 'CLI'],
427
+ ['apps/trails/src/app.ts', 'app.js', 'app module'],
428
+ ] as const) {
429
+ const build = Bun.spawnSync(
430
+ [
431
+ process.execPath,
432
+ 'build',
433
+ entrypoint,
434
+ '--target=bun',
435
+ ...BUNDLE_EXTERNALS.flatMap((external) => ['--external', external]),
436
+ '--outfile',
437
+ join(bundleRoot, 'lib', output),
438
+ ],
439
+ { cwd: repoRoot }
440
+ );
441
+ if (build.exitCode !== 0) {
442
+ throw new Error(
443
+ `Could not bundle the Trails ${label}: ${textDecoder.decode(build.stderr)}`
444
+ );
445
+ }
446
+ }
447
+
448
+ const parserRoot = resolvePackageRoot(
449
+ 'oxc-parser',
450
+ join(repoRoot, 'packages/source/src')
451
+ );
452
+ const resolverRoot = resolvePackageRoot(
453
+ 'oxc-resolver',
454
+ join(repoRoot, 'packages/warden/src')
455
+ );
456
+ const includedPackages = [
457
+ copyPackage('oxc-parser', parserRoot, bundleRoot),
458
+ copyPackage('oxc-resolver', resolverRoot, bundleRoot),
459
+ copyPackage(
460
+ descriptor.oxcParserBinding,
461
+ resolvePackageRoot(descriptor.oxcParserBinding, parserRoot),
462
+ bundleRoot
463
+ ),
464
+ copyPackage(
465
+ descriptor.oxcResolverBinding,
466
+ resolvePackageRoot(descriptor.oxcResolverBinding, resolverRoot),
467
+ bundleRoot
468
+ ),
469
+ ];
470
+ const dependencies: Record<string, string> = {};
471
+ for (const {
472
+ name,
473
+ version: dependencyVersion,
474
+ } of includedPackages.toSorted((left, right) =>
475
+ left.name.localeCompare(right.name)
476
+ )) {
477
+ dependencies[name] = dependencyVersion;
478
+ }
479
+ writeFileSync(
480
+ join(bundleRoot, 'package.json'),
481
+ `${JSON.stringify(
482
+ {
483
+ dependencies,
484
+ name: '@ontrails/trails-homebrew-bundle',
485
+ private: true,
486
+ type: 'module',
487
+ version,
488
+ },
489
+ null,
490
+ 2
491
+ )}\n`
492
+ );
493
+ const launcherPath = join(bundleRoot, 'bin/trails');
494
+ writeFileSync(launcherPath, launcherSource);
495
+ chmodSync(launcherPath, 0o755);
496
+
497
+ const archiveBytes = createDeterministicTarGzip(bundleRoot, archiveRoot);
498
+ const archivePath = join(outputDir, archiveName);
499
+ const checksum = sha256(archiveBytes);
500
+ const checksumPath = `${archivePath}.sha256`;
501
+ writeFileSync(archivePath, archiveBytes);
502
+ writeFileSync(checksumPath, `${checksum} ${archiveName}\n`);
503
+ const artifact = {
504
+ archiveName,
505
+ archivePath,
506
+ checksum,
507
+ checksumPath,
508
+ platform,
509
+ version,
510
+ } satisfies TrailsCliReleaseArtifact;
511
+ verifyTrailsCliArchive(artifact);
512
+ return artifact;
513
+ } finally {
514
+ rmSync(stagingDir, { force: true, recursive: true });
515
+ }
516
+ };
517
+
518
+ const parseCliArgs = (
519
+ args: readonly string[]
520
+ ): BuildTrailsCliReleaseOptions => {
521
+ let outDir: string | undefined;
522
+ let platform: TrailsCliReleasePlatform | undefined;
523
+ let repoRoot: string | undefined;
524
+ let version: string | undefined;
525
+ for (let index = 0; index < args.length; index += 1) {
526
+ const argument = args[index];
527
+ const value = args[index + 1];
528
+ if (!value) {
529
+ throw new Error(`Missing value for ${argument}`);
530
+ }
531
+ if (argument === '--out-dir') {
532
+ outDir = value;
533
+ } else if (argument === '--platform') {
534
+ if (
535
+ !trailsCliReleasePlatformValues.includes(
536
+ value as TrailsCliReleasePlatform
537
+ )
538
+ ) {
539
+ throw new Error(`Unsupported Trails CLI release platform: ${value}`);
540
+ }
541
+ platform = value as TrailsCliReleasePlatform;
542
+ } else if (argument === '--repo-root') {
543
+ repoRoot = value;
544
+ } else if (argument === '--version') {
545
+ version = value;
546
+ } else {
547
+ throw new Error(`Unknown Trails CLI release argument: ${argument}`);
548
+ }
549
+ index += 1;
550
+ }
551
+ if (!outDir) {
552
+ throw new Error('Missing required argument: --out-dir');
553
+ }
554
+ return {
555
+ outDir,
556
+ ...(platform ? { platform } : {}),
557
+ ...(repoRoot ? { repoRoot } : {}),
558
+ ...(version ? { version } : {}),
559
+ };
560
+ };
561
+
562
+ export const runTrailsCliReleaseCli = async (
563
+ args: readonly string[]
564
+ ): Promise<number> => {
565
+ try {
566
+ const artifact = await buildTrailsCliReleaseArtifact(parseCliArgs(args));
567
+ console.log(
568
+ `Built and cold-verified ${artifact.archiveName} (${artifact.checksum})`
569
+ );
570
+ return 0;
571
+ } catch (error) {
572
+ console.error(error instanceof Error ? error.message : String(error));
573
+ return 1;
574
+ }
575
+ };
@@ -0,0 +1,220 @@
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
+ version "${version}"
136
+ license "MIT"
137
+
138
+ depends_on "bun"
139
+
140
+ on_macos do
141
+ on_arm do
142
+ url "https://github.com/outfitter-dev/trails/releases/download/v#{version}/trails-v#{version}-darwin-arm64.tar.gz"
143
+ sha256 "${checksums['darwin-arm64']}"
144
+ end
145
+ on_intel do
146
+ url "https://github.com/outfitter-dev/trails/releases/download/v#{version}/trails-v#{version}-darwin-x64.tar.gz"
147
+ sha256 "${checksums['darwin-x64']}"
148
+ end
149
+ end
150
+
151
+ on_linux do
152
+ on_arm do
153
+ url "https://github.com/outfitter-dev/trails/releases/download/v#{version}/trails-v#{version}-linux-arm64.tar.gz"
154
+ sha256 "${checksums['linux-arm64']}"
155
+ end
156
+ on_intel do
157
+ url "https://github.com/outfitter-dev/trails/releases/download/v#{version}/trails-v#{version}-linux-x64.tar.gz"
158
+ sha256 "${checksums['linux-x64']}"
159
+ end
160
+ end
161
+
162
+ def install
163
+ libexec.install Dir["*"]
164
+ bin.install_symlink libexec/"bin/trails"
165
+ end
166
+
167
+ test do
168
+ assert_match version.to_s, shell_output("#{bin}/trails --version")
169
+ assert_match "Usage: trails", shell_output("#{bin}/trails --help")
170
+ end
171
+ end
172
+ `;
173
+ };
174
+
175
+ const readOption = (args: readonly string[], flag: string): string => {
176
+ const index = args.indexOf(flag);
177
+ const value = index === -1 ? undefined : args[index + 1];
178
+ if (!value) {
179
+ throw new Error(`Missing required argument: ${flag}`);
180
+ }
181
+ return value;
182
+ };
183
+
184
+ export const runTrailsHomebrewCli = async (
185
+ args: readonly string[]
186
+ ): Promise<number> => {
187
+ try {
188
+ const [command, ...options] = args;
189
+ const version = readOption(options, '--version');
190
+ if (command === 'validate') {
191
+ const releasePath = resolve(readOption(options, '--release-json'));
192
+ const assetsDir = resolve(readOption(options, '--assets-dir'));
193
+ const release = JSON.parse(
194
+ readFileSync(releasePath, 'utf8')
195
+ ) as GitHubReleaseMetadata;
196
+ await verifyTrailsHomebrewAssetDirectory(assetsDir, release, version);
197
+ console.log(`Validated Homebrew release assets for v${version}`);
198
+ return 0;
199
+ }
200
+ if (command === 'render') {
201
+ const checksumsPath = resolve(readOption(options, '--checksums'));
202
+ const outputPath = resolve(readOption(options, '--output'));
203
+ const checksums = parseTrailsCliChecksums(
204
+ readFileSync(checksumsPath, 'utf8'),
205
+ version
206
+ );
207
+ mkdirSync(dirname(outputPath), { recursive: true });
208
+ writeFileSync(
209
+ outputPath,
210
+ renderTrailsHomebrewFormula(version, checksums)
211
+ );
212
+ console.log(`Rendered ${outputPath}`);
213
+ return 0;
214
+ }
215
+ throw new Error(`Unknown Homebrew release command: ${command ?? '<none>'}`);
216
+ } catch (error) {
217
+ console.error(error instanceof Error ? error.message : String(error));
218
+ return 1;
219
+ }
220
+ };
@@ -56,6 +56,32 @@ export {
56
56
  type ReleaseRule,
57
57
  type ReleaseRuleInput,
58
58
  } from './config.js';
59
+ export {
60
+ buildTrailsCliReleaseArtifact,
61
+ createDeterministicTarGzip,
62
+ currentTrailsCliReleasePlatform,
63
+ expectedTrailsCliReleaseAssetNames,
64
+ runTrailsCliReleaseCli,
65
+ trailsCliArchiveName,
66
+ trailsCliChecksumName,
67
+ trailsCliReleasePlatforms,
68
+ trailsCliReleasePlatformValues,
69
+ verifyTrailsCliArchive,
70
+ type BuildTrailsCliReleaseOptions,
71
+ type TrailsCliReleaseArtifact,
72
+ type TrailsCliReleasePlatform,
73
+ type TrailsCliReleasePlatformDescriptor,
74
+ } from './cli-bundle.js';
75
+ export {
76
+ parseTrailsCliChecksums,
77
+ renderTrailsHomebrewFormula,
78
+ runTrailsHomebrewCli,
79
+ validateTrailsHomebrewRelease,
80
+ verifyTrailsHomebrewAssetDirectory,
81
+ type GitHubReleaseAsset,
82
+ type GitHubReleaseMetadata,
83
+ type TrailsCliReleaseChecksums,
84
+ } from './homebrew.js';
59
85
  export {
60
86
  createNpmPublishCommand,
61
87
  findPackedFirstPartyDependencyMismatches,
@@ -35,7 +35,15 @@ export const topoSnapshotOutput = z.object({
35
35
 
36
36
  export const DEFAULT_TOPO_HISTORY_LIMIT = 10;
37
37
  export const LOCK_PATH = 'trails.lock';
38
- const EXAMPLE_APP_MODULE = fileURLToPath(new URL('../app.ts', import.meta.url));
38
+ const sourceExampleAppModule = fileURLToPath(
39
+ new URL('../app.ts', import.meta.url)
40
+ );
41
+ const bundledExampleAppModule = fileURLToPath(
42
+ new URL('app.js', import.meta.url)
43
+ );
44
+ const EXAMPLE_APP_MODULE = existsSync(sourceExampleAppModule)
45
+ ? sourceExampleAppModule
46
+ : bundledExampleAppModule;
39
47
 
40
48
  const uniqueExampleRootName = (name: string): string =>
41
49
  `${name}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;