@dalzoubi/dev-agents-sync 1.0.1 → 1.0.3

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 (3) hide show
  1. package/package.json +43 -43
  2. package/src/fetcher.mjs +356 -356
  3. package/tests/fetcher.test.mjs +1247 -1247
@@ -1,1247 +1,1247 @@
1
- /**
2
- * tests/fetcher.test.mjs
3
- *
4
- * Failing tests for the real implementations of:
5
- * - defaultGithubTagLister(repo, token, { httpClient })
6
- * - defaultGithubFetcher(repo, tag, token, { httpClient, tarExtractor })
7
- *
8
- * No real network is used. All HTTP is stubbed via the injectable `httpClient`
9
- * seam. The `tarExtractor` seam is also injected so real tar decompression
10
- * is out of scope.
11
- *
12
- * These tests FAIL against the current stub implementation and PASS only
13
- * after the Implement agent wires up real logic.
14
- *
15
- * Target API (the Implement agent must match these signatures):
16
- *
17
- * defaultGithubTagLister(repo, token, { httpClient })
18
- * -> Promise<string[]> bare semver strings, sorted newest-first
19
- *
20
- * defaultGithubFetcher(repo, tag, token, { httpClient, tarExtractor })
21
- * -> Promise<FileMap> { [relativePath]: content }
22
- * `tag` is bare semver e.g. "1.0.0" (no "v" prefix)
23
- */
24
-
25
- import { describe, it } from 'node:test';
26
- import assert from 'node:assert/strict';
27
-
28
- import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
29
- import { tmpdir } from 'node:os';
30
- import { join } from 'node:path';
31
- import * as tar from 'tar';
32
-
33
- import {
34
- defaultGithubTagLister,
35
- defaultGithubFetcher,
36
- defaultTarExtractor,
37
- STUB_NOT_IMPLEMENTED,
38
- } from '../src/fetcher.mjs';
39
-
40
- // ---------------------------------------------------------------------------
41
- // Mock / helper factories
42
- // ---------------------------------------------------------------------------
43
-
44
- /**
45
- * Creates a minimal httpClient mock.
46
- *
47
- * `responses` is a Map<url-substring, responseShape> used to dispatch.
48
- * A responseShape is:
49
- * { status, ok, jsonBody?, bufferBody?, headers? }
50
- *
51
- * The mock returns the first entry whose key is a substring of the requested URL.
52
- * If nothing matches, it throws to make test failures obvious.
53
- *
54
- * Signature mirrors the Fetch API:
55
- * (url: string, init?: { headers?: object }) => Promise<Response-like>
56
- */
57
- function makeHttpClient(responses) {
58
- return async function stubHttpClient(url, _init) {
59
- for (const [key, shape] of Object.entries(responses)) {
60
- if (url.includes(key)) {
61
- return {
62
- ok: shape.ok ?? (shape.status >= 200 && shape.status < 300),
63
- status: shape.status ?? 200,
64
- async json() {
65
- return shape.jsonBody ?? null;
66
- },
67
- async arrayBuffer() {
68
- const body = shape.bufferBody ?? new ArrayBuffer(0);
69
- return body;
70
- },
71
- headers: {
72
- get(name) {
73
- return (shape.headers ?? {})[name.toLowerCase()] ?? null;
74
- },
75
- },
76
- };
77
- }
78
- }
79
- throw new Error(`stubHttpClient: no mock registered for URL: ${url}`);
80
- };
81
- }
82
-
83
- /**
84
- * Creates a minimal tarExtractor mock.
85
- *
86
- * Returns a fixed FileMap for any buffer, simulating a tarball that contains
87
- * the provided entries.
88
- *
89
- * The entries object maps tar entry paths (as they appear inside the tarball)
90
- * to their string content.
91
- */
92
- function makeTarExtractor(entries) {
93
- return async function stubTarExtractor(_buffer) {
94
- return entries;
95
- };
96
- }
97
-
98
- /** Builds a fake releases list response body matching the GitHub Releases API shape. */
99
- function makeReleasesBody(releases) {
100
- // Each element: { tag_name, draft, prerelease, assets: [] }
101
- return releases;
102
- }
103
-
104
- /** Builds a fake single-release response body with assets attached. */
105
- function makeReleaseBody({ tagName, assets = [] }) {
106
- return {
107
- tag_name: tagName,
108
- draft: false,
109
- prerelease: false,
110
- assets,
111
- };
112
- }
113
-
114
- /** Builds a fake asset descriptor as GitHub returns in the releases API. */
115
- function makeAsset({ name, url, size = 100 }) {
116
- return { name, url, size };
117
- }
118
-
119
- // ---------------------------------------------------------------------------
120
- // Fixture data
121
- // ---------------------------------------------------------------------------
122
-
123
- const REPO = 'dalzoubi/dev-agents';
124
- const TOKEN = 'ghp_TESTTOKEN000000';
125
-
126
- // A tarball that contains a dist/ subtree as the implement agent should emit.
127
- // Keys mirror the tarball entry paths before the fetcher strips the dist/ prefix.
128
- const FAKE_TARBALL_ENTRIES = {
129
- 'dist/claude/agents/define.md': '# define agent',
130
- 'dist/claude/commands/preflight.md': '# preflight command',
131
- 'dist/cursor/rules/define.mdc': '# define rule',
132
- // An entry outside dist/ — must be filtered out by the fetcher.
133
- 'README.md': '# should be ignored',
134
- // A directory entry (no content) — must be skipped.
135
- 'dist/claude/agents/': '',
136
- };
137
-
138
- // The expected FileMap after path-rewriting: dist/ is stripped.
139
- const EXPECTED_FILE_MAP = {
140
- 'claude/agents/define.md': '# define agent',
141
- 'claude/commands/preflight.md': '# preflight command',
142
- 'cursor/rules/define.mdc': '# define rule',
143
- };
144
-
145
- // ---------------------------------------------------------------------------
146
- // defaultGithubTagLister — HTTP contract
147
- // ---------------------------------------------------------------------------
148
-
149
- describe('defaultGithubTagLister — HTTP contract', () => {
150
- it('calls GET /repos/<repo>/releases with Authorization Bearer header', async () => {
151
- let capturedUrl = null;
152
- let capturedInit = null;
153
-
154
- const spyHttpClient = async (url, init) => {
155
- capturedUrl = url;
156
- capturedInit = init;
157
- return {
158
- ok: true,
159
- status: 200,
160
- async json() { return []; },
161
- headers: { get: () => null },
162
- };
163
- };
164
-
165
- await defaultGithubTagLister(REPO, TOKEN, { httpClient: spyHttpClient });
166
-
167
- assert.ok(
168
- capturedUrl !== null,
169
- 'httpClient must be called',
170
- );
171
- assert.ok(
172
- capturedUrl.includes(`/repos/${REPO}/releases`),
173
- `URL must include /repos/${REPO}/releases, got: ${capturedUrl}`,
174
- );
175
- const authHeader =
176
- (capturedInit?.headers?.Authorization) ??
177
- (capturedInit?.headers?.authorization);
178
- assert.ok(
179
- authHeader === `Bearer ${TOKEN}`,
180
- `Authorization header must be "Bearer <token>", got: ${authHeader}`,
181
- );
182
- });
183
-
184
- it('sends Accept: application/vnd.github+json', async () => {
185
- let capturedInit = null;
186
-
187
- const spyHttpClient = async (_url, init) => {
188
- capturedInit = init;
189
- return {
190
- ok: true,
191
- status: 200,
192
- async json() { return []; },
193
- headers: { get: () => null },
194
- };
195
- };
196
-
197
- await defaultGithubTagLister(REPO, TOKEN, { httpClient: spyHttpClient });
198
-
199
- const acceptHeader =
200
- (capturedInit?.headers?.Accept) ??
201
- (capturedInit?.headers?.accept);
202
- assert.ok(
203
- acceptHeader === 'application/vnd.github+json',
204
- `Accept header must be "application/vnd.github+json", got: ${acceptHeader}`,
205
- );
206
- });
207
- });
208
-
209
- // ---------------------------------------------------------------------------
210
- // defaultGithubTagLister — response parsing
211
- // ---------------------------------------------------------------------------
212
-
213
- describe('defaultGithubTagLister — response parsing', () => {
214
- it('returns bare semver strings (no "v" prefix)', async () => {
215
- const releases = makeReleasesBody([
216
- { tag_name: 'v1.2.3', draft: false, prerelease: false },
217
- { tag_name: 'v1.0.0', draft: false, prerelease: false },
218
- ]);
219
-
220
- const httpClient = makeHttpClient({
221
- [`/repos/${REPO}/releases`]: { status: 200, jsonBody: releases },
222
- });
223
-
224
- const tags = await defaultGithubTagLister(REPO, TOKEN, { httpClient });
225
-
226
- for (const t of tags) {
227
- assert.ok(
228
- !t.startsWith('v'),
229
- `tag must not start with "v", got: ${t}`,
230
- );
231
- }
232
- });
233
-
234
- it('filters out drafts', async () => {
235
- const releases = makeReleasesBody([
236
- { tag_name: 'v1.2.3', draft: false, prerelease: false },
237
- { tag_name: 'v1.1.0', draft: true, prerelease: false },
238
- ]);
239
-
240
- const httpClient = makeHttpClient({
241
- [`/repos/${REPO}/releases`]: { status: 200, jsonBody: releases },
242
- });
243
-
244
- const tags = await defaultGithubTagLister(REPO, TOKEN, { httpClient });
245
-
246
- assert.ok(!tags.includes('1.1.0'), 'draft release must be filtered out');
247
- assert.ok(tags.includes('1.2.3'), '1.2.3 must be present');
248
- });
249
-
250
- it('filters out prereleases', async () => {
251
- const releases = makeReleasesBody([
252
- { tag_name: 'v2.0.0', draft: false, prerelease: false },
253
- { tag_name: 'v2.0.0-beta.1', draft: false, prerelease: true },
254
- ]);
255
-
256
- const httpClient = makeHttpClient({
257
- [`/repos/${REPO}/releases`]: { status: 200, jsonBody: releases },
258
- });
259
-
260
- const tags = await defaultGithubTagLister(REPO, TOKEN, { httpClient });
261
-
262
- assert.ok(!tags.includes('2.0.0-beta.1'), 'prerelease must be filtered out');
263
- assert.ok(tags.includes('2.0.0'), '2.0.0 must be present');
264
- });
265
-
266
- it('filters out tags that do not match ^v\\d+\\.\\d+\\.\\d+$ pattern', async () => {
267
- const releases = makeReleasesBody([
268
- { tag_name: 'v1.0.0', draft: false, prerelease: false },
269
- { tag_name: 'latest', draft: false, prerelease: false },
270
- { tag_name: 'v1.0.0-rc.1', draft: false, prerelease: false },
271
- { tag_name: 'v1.0', draft: false, prerelease: false },
272
- ]);
273
-
274
- const httpClient = makeHttpClient({
275
- [`/repos/${REPO}/releases`]: { status: 200, jsonBody: releases },
276
- });
277
-
278
- const tags = await defaultGithubTagLister(REPO, TOKEN, { httpClient });
279
-
280
- assert.ok(tags.includes('1.0.0'), '1.0.0 must be included');
281
- assert.ok(!tags.includes('latest'), '"latest" must be excluded');
282
- assert.ok(!tags.includes('1.0.0-rc.1'), 'rc tag must be excluded');
283
- assert.ok(!tags.includes('1.0'), 'partial semver must be excluded');
284
- assert.ok(!tags.includes('v1.0.0'), 'tags must not include "v" prefix');
285
- });
286
-
287
- it('sorts results descending by semver (newest first)', async () => {
288
- const releases = makeReleasesBody([
289
- { tag_name: 'v1.0.0', draft: false, prerelease: false },
290
- { tag_name: 'v1.2.0', draft: false, prerelease: false },
291
- { tag_name: 'v1.1.0', draft: false, prerelease: false },
292
- { tag_name: 'v2.0.0', draft: false, prerelease: false },
293
- ]);
294
-
295
- const httpClient = makeHttpClient({
296
- [`/repos/${REPO}/releases`]: { status: 200, jsonBody: releases },
297
- });
298
-
299
- const tags = await defaultGithubTagLister(REPO, TOKEN, { httpClient });
300
-
301
- assert.deepEqual(
302
- tags,
303
- ['2.0.0', '1.2.0', '1.1.0', '1.0.0'],
304
- 'tags must be sorted descending by semver',
305
- );
306
- });
307
-
308
- it('returns empty array when releases list is empty', async () => {
309
- const httpClient = makeHttpClient({
310
- [`/repos/${REPO}/releases`]: { status: 200, jsonBody: [] },
311
- });
312
-
313
- const tags = await defaultGithubTagLister(REPO, TOKEN, { httpClient });
314
-
315
- assert.deepEqual(tags, [], 'empty releases must return []');
316
- });
317
- });
318
-
319
- // ---------------------------------------------------------------------------
320
- // defaultGithubTagLister — error handling
321
- // ---------------------------------------------------------------------------
322
-
323
- describe('defaultGithubTagLister — error handling', () => {
324
- it('throws AuthError-shaped error on 401 (mentions GITHUB_TOKEN)', async () => {
325
- const httpClient = makeHttpClient({
326
- [`/repos/${REPO}/releases`]: { status: 401, ok: false, jsonBody: { message: 'Requires authentication' } },
327
- });
328
-
329
- let thrown = null;
330
- try {
331
- await defaultGithubTagLister(REPO, TOKEN, { httpClient });
332
- } catch (err) {
333
- thrown = err;
334
- }
335
-
336
- assert.ok(thrown !== null, 'must throw on 401');
337
- assert.ok(
338
- thrown.message.includes('GITHUB_TOKEN'),
339
- `error message must mention GITHUB_TOKEN, got: ${thrown.message}`,
340
- );
341
- assert.equal(thrown.exitCode, 2, 'exitCode must be 2 on auth error');
342
- });
343
-
344
- it('throws AuthError-shaped error on 401 (mentions gh auth login)', async () => {
345
- const httpClient = makeHttpClient({
346
- [`/repos/${REPO}/releases`]: { status: 401, ok: false, jsonBody: { message: 'Requires authentication' } },
347
- });
348
-
349
- let message = '';
350
- try {
351
- await defaultGithubTagLister(REPO, TOKEN, { httpClient });
352
- } catch (err) {
353
- message = err.message;
354
- }
355
-
356
- assert.ok(
357
- message.includes('gh auth login'),
358
- `error message must mention 'gh auth login', got: ${message}`,
359
- );
360
- });
361
-
362
- it('throws AuthError-shaped error on 403', async () => {
363
- const httpClient = makeHttpClient({
364
- [`/repos/${REPO}/releases`]: { status: 403, ok: false, jsonBody: { message: 'Forbidden' } },
365
- });
366
-
367
- let thrown = null;
368
- try {
369
- await defaultGithubTagLister(REPO, TOKEN, { httpClient });
370
- } catch (err) {
371
- thrown = err;
372
- }
373
-
374
- assert.ok(thrown !== null, 'must throw on 403');
375
- assert.equal(thrown.exitCode, 2, 'exitCode must be 2 on auth error');
376
- assert.ok(
377
- thrown.message.includes('GITHUB_TOKEN'),
378
- 'error message must mention GITHUB_TOKEN on 403',
379
- );
380
- });
381
-
382
- it('throws on 5xx without STUB_NOT_IMPLEMENTED code', async () => {
383
- const httpClient = makeHttpClient({
384
- [`/repos/${REPO}/releases`]: { status: 503, ok: false, jsonBody: { message: 'Service unavailable' } },
385
- });
386
-
387
- let thrown = null;
388
- try {
389
- await defaultGithubTagLister(REPO, TOKEN, { httpClient });
390
- } catch (err) {
391
- thrown = err;
392
- }
393
-
394
- assert.ok(thrown !== null, 'must throw on 5xx');
395
- assert.ok(
396
- thrown.code !== STUB_NOT_IMPLEMENTED,
397
- `5xx error must not use STUB_NOT_IMPLEMENTED code; code was: ${thrown.code}`,
398
- );
399
- });
400
-
401
- it('token does not appear in error messages', async () => {
402
- const httpClient = makeHttpClient({
403
- [`/repos/${REPO}/releases`]: { status: 401, ok: false, jsonBody: { message: 'Requires authentication' } },
404
- });
405
-
406
- let message = '';
407
- try {
408
- await defaultGithubTagLister(REPO, TOKEN, { httpClient });
409
- } catch (err) {
410
- message = err.message;
411
- }
412
-
413
- assert.ok(
414
- !message.includes(TOKEN),
415
- `error message must not contain the token, got: ${message}`,
416
- );
417
- });
418
- });
419
-
420
- // ---------------------------------------------------------------------------
421
- // defaultGithubFetcher — HTTP contract
422
- // ---------------------------------------------------------------------------
423
-
424
- describe('defaultGithubFetcher — HTTP contract', () => {
425
- it('calls GET /repos/<repo>/releases/tags/v<tag>', async () => {
426
- const TAG = '1.0.0';
427
- let capturedReleaseUrl = null;
428
-
429
- const tarExtractor = makeTarExtractor(FAKE_TARBALL_ENTRIES);
430
-
431
- const httpClient = async (url, init) => {
432
- if (url.includes(`/releases/tags/v${TAG}`)) {
433
- capturedReleaseUrl = url;
434
- return {
435
- ok: true,
436
- status: 200,
437
- async json() {
438
- return makeReleaseBody({
439
- tagName: `v${TAG}`,
440
- assets: [
441
- makeAsset({ name: `dist-v${TAG}.tar.gz`, url: 'https://example.com/assets/dist.tar.gz' }),
442
- ],
443
- });
444
- },
445
- headers: { get: () => null },
446
- };
447
- }
448
- if (url.includes('assets/dist.tar.gz')) {
449
- return {
450
- ok: true,
451
- status: 200,
452
- async arrayBuffer() { return new ArrayBuffer(8); },
453
- headers: { get: () => null },
454
- };
455
- }
456
- throw new Error(`stubHttpClient: unexpected URL ${url}`);
457
- };
458
-
459
- await defaultGithubFetcher(REPO, TAG, TOKEN, { httpClient, tarExtractor });
460
-
461
- assert.ok(
462
- capturedReleaseUrl !== null,
463
- 'must call GET on the releases/tags endpoint',
464
- );
465
- assert.ok(
466
- capturedReleaseUrl.includes(`/repos/${REPO}/releases/tags/v${TAG}`),
467
- `URL must include /repos/${REPO}/releases/tags/v${TAG}, got: ${capturedReleaseUrl}`,
468
- );
469
- });
470
-
471
- it('downloads the asset using Accept: application/octet-stream and bearer token', async () => {
472
- const TAG = '1.0.0';
473
- let capturedAssetInit = null;
474
-
475
- const tarExtractor = makeTarExtractor(FAKE_TARBALL_ENTRIES);
476
-
477
- const httpClient = async (url, init) => {
478
- if (url.includes(`/releases/tags/v${TAG}`)) {
479
- return {
480
- ok: true,
481
- status: 200,
482
- async json() {
483
- return makeReleaseBody({
484
- tagName: `v${TAG}`,
485
- assets: [
486
- makeAsset({ name: `dist-v${TAG}.tar.gz`, url: 'https://example.com/assets/dist.tar.gz' }),
487
- ],
488
- });
489
- },
490
- headers: { get: () => null },
491
- };
492
- }
493
- if (url.includes('assets/dist.tar.gz')) {
494
- capturedAssetInit = init;
495
- return {
496
- ok: true,
497
- status: 200,
498
- async arrayBuffer() { return new ArrayBuffer(8); },
499
- headers: { get: () => null },
500
- };
501
- }
502
- throw new Error(`stubHttpClient: unexpected URL ${url}`);
503
- };
504
-
505
- await defaultGithubFetcher(REPO, TAG, TOKEN, { httpClient, tarExtractor });
506
-
507
- assert.ok(capturedAssetInit !== null, 'httpClient must be called for asset download');
508
-
509
- const acceptHeader =
510
- (capturedAssetInit?.headers?.Accept) ??
511
- (capturedAssetInit?.headers?.accept);
512
- assert.ok(
513
- acceptHeader === 'application/octet-stream',
514
- `asset download must use Accept: application/octet-stream, got: ${acceptHeader}`,
515
- );
516
-
517
- const authHeader =
518
- (capturedAssetInit?.headers?.Authorization) ??
519
- (capturedAssetInit?.headers?.authorization);
520
- assert.ok(
521
- authHeader === `Bearer ${TOKEN}`,
522
- `asset download must include Authorization: Bearer <token>, got: ${authHeader}`,
523
- );
524
- });
525
- });
526
-
527
- // ---------------------------------------------------------------------------
528
- // defaultGithubFetcher — asset resolution
529
- // ---------------------------------------------------------------------------
530
-
531
- describe('defaultGithubFetcher — asset resolution', () => {
532
- it('finds the asset named dist-v<tag>.tar.gz', async () => {
533
- const TAG = '1.2.3';
534
- let downloadedUrl = null;
535
-
536
- const tarExtractor = makeTarExtractor(FAKE_TARBALL_ENTRIES);
537
-
538
- const httpClient = async (url, init) => {
539
- if (url.includes(`/releases/tags/v${TAG}`)) {
540
- return {
541
- ok: true,
542
- status: 200,
543
- async json() {
544
- return makeReleaseBody({
545
- tagName: `v${TAG}`,
546
- assets: [
547
- makeAsset({ name: `dist-v${TAG}.tar.gz`, url: 'https://example.com/assets/main.tar.gz' }),
548
- makeAsset({ name: 'something-else.zip', url: 'https://example.com/assets/other.zip' }),
549
- ],
550
- });
551
- },
552
- headers: { get: () => null },
553
- };
554
- }
555
- if (url.includes('assets/main.tar.gz')) {
556
- downloadedUrl = url;
557
- return {
558
- ok: true,
559
- status: 200,
560
- async arrayBuffer() { return new ArrayBuffer(8); },
561
- headers: { get: () => null },
562
- };
563
- }
564
- throw new Error(`stubHttpClient: unexpected URL ${url}`);
565
- };
566
-
567
- await defaultGithubFetcher(REPO, TAG, TOKEN, { httpClient, tarExtractor });
568
-
569
- assert.ok(
570
- downloadedUrl !== null && downloadedUrl.includes('assets/main.tar.gz'),
571
- `must download the correct asset URL, got: ${downloadedUrl}`,
572
- );
573
- });
574
-
575
- it('throws when the expected asset is absent (error mentions asset name)', async () => {
576
- const TAG = '1.0.0';
577
-
578
- const tarExtractor = makeTarExtractor({});
579
-
580
- const httpClient = makeHttpClient({
581
- [`/releases/tags/v${TAG}`]: {
582
- status: 200,
583
- jsonBody: makeReleaseBody({
584
- tagName: `v${TAG}`,
585
- assets: [
586
- // Wrong asset name — the correct one is dist-v1.0.0.tar.gz
587
- makeAsset({ name: 'wrong-asset.zip', url: 'https://example.com/assets/wrong.zip' }),
588
- ],
589
- }),
590
- },
591
- });
592
-
593
- let thrown = null;
594
- try {
595
- await defaultGithubFetcher(REPO, TAG, TOKEN, { httpClient, tarExtractor });
596
- } catch (err) {
597
- thrown = err;
598
- }
599
-
600
- assert.ok(thrown !== null, 'must throw when asset is absent');
601
- assert.ok(
602
- thrown.message.includes(`dist-v${TAG}.tar.gz`),
603
- `error message must mention the missing asset name "dist-v${TAG}.tar.gz", got: ${thrown.message}`,
604
- );
605
- });
606
- });
607
-
608
- // ---------------------------------------------------------------------------
609
- // defaultGithubFetcher — tarExtractor integration
610
- // ---------------------------------------------------------------------------
611
-
612
- describe('defaultGithubFetcher — tarExtractor integration', () => {
613
- /**
614
- * Builds a standard happy-path httpClient for a given tag, returning a
615
- * release with the dist tarball asset. The asset download returns an
616
- * ArrayBuffer of 8 zero bytes (content doesn't matter for stub extraction).
617
- */
618
- function makeHappyHttpClient(tag) {
619
- return async (url, _init) => {
620
- if (url.includes(`/releases/tags/v${tag}`)) {
621
- return {
622
- ok: true,
623
- status: 200,
624
- async json() {
625
- return makeReleaseBody({
626
- tagName: `v${tag}`,
627
- assets: [
628
- makeAsset({ name: `dist-v${tag}.tar.gz`, url: 'https://example.com/assets/dist.tar.gz' }),
629
- ],
630
- });
631
- },
632
- headers: { get: () => null },
633
- };
634
- }
635
- if (url.includes('assets/dist.tar.gz')) {
636
- return {
637
- ok: true,
638
- status: 200,
639
- async arrayBuffer() { return new ArrayBuffer(8); },
640
- headers: { get: () => null },
641
- };
642
- }
643
- throw new Error(`stubHttpClient: unexpected URL ${url}`);
644
- };
645
- }
646
-
647
- it('calls tarExtractor with the downloaded buffer', async () => {
648
- const TAG = '1.0.0';
649
- let extractorWasCalled = false;
650
-
651
- const tarExtractor = async (buffer) => {
652
- extractorWasCalled = true;
653
- assert.ok(buffer instanceof ArrayBuffer, 'tarExtractor must receive an ArrayBuffer');
654
- return FAKE_TARBALL_ENTRIES;
655
- };
656
-
657
- await defaultGithubFetcher(REPO, TAG, TOKEN, {
658
- httpClient: makeHappyHttpClient(TAG),
659
- tarExtractor,
660
- });
661
-
662
- assert.ok(extractorWasCalled, 'tarExtractor must be called');
663
- });
664
-
665
- it('strips dist/ prefix from tarball entry paths', async () => {
666
- const TAG = '1.0.0';
667
- const tarExtractor = makeTarExtractor(FAKE_TARBALL_ENTRIES);
668
-
669
- const fileMap = await defaultGithubFetcher(REPO, TAG, TOKEN, {
670
- httpClient: makeHappyHttpClient(TAG),
671
- tarExtractor,
672
- });
673
-
674
- for (const key of Object.keys(fileMap)) {
675
- assert.ok(
676
- !key.startsWith('dist/'),
677
- `FileMap key must not start with "dist/", got: ${key}`,
678
- );
679
- }
680
- });
681
-
682
- it('includes expected keys after prefix rewriting', async () => {
683
- const TAG = '1.0.0';
684
- const tarExtractor = makeTarExtractor(FAKE_TARBALL_ENTRIES);
685
-
686
- const fileMap = await defaultGithubFetcher(REPO, TAG, TOKEN, {
687
- httpClient: makeHappyHttpClient(TAG),
688
- tarExtractor,
689
- });
690
-
691
- assert.ok(
692
- 'claude/agents/define.md' in fileMap,
693
- 'FileMap must contain "claude/agents/define.md"',
694
- );
695
- assert.ok(
696
- 'cursor/rules/define.mdc' in fileMap,
697
- 'FileMap must contain "cursor/rules/define.mdc"',
698
- );
699
- assert.ok(
700
- 'claude/commands/preflight.md' in fileMap,
701
- 'FileMap must contain "claude/commands/preflight.md"',
702
- );
703
- });
704
-
705
- it('excludes entries outside dist/', async () => {
706
- const TAG = '1.0.0';
707
- const tarExtractor = makeTarExtractor(FAKE_TARBALL_ENTRIES);
708
-
709
- const fileMap = await defaultGithubFetcher(REPO, TAG, TOKEN, {
710
- httpClient: makeHappyHttpClient(TAG),
711
- tarExtractor,
712
- });
713
-
714
- assert.ok(
715
- !('README.md' in fileMap),
716
- 'README.md (outside dist/) must not appear in FileMap',
717
- );
718
- });
719
-
720
- it('skips directory entries (empty trailing slash)', async () => {
721
- const TAG = '1.0.0';
722
- const tarExtractor = makeTarExtractor(FAKE_TARBALL_ENTRIES);
723
-
724
- const fileMap = await defaultGithubFetcher(REPO, TAG, TOKEN, {
725
- httpClient: makeHappyHttpClient(TAG),
726
- tarExtractor,
727
- });
728
-
729
- for (const key of Object.keys(fileMap)) {
730
- assert.ok(
731
- !key.endsWith('/'),
732
- `FileMap must not contain directory entries (keys ending with "/"), got: ${key}`,
733
- );
734
- }
735
- });
736
-
737
- it('returns the correct file content', async () => {
738
- const TAG = '1.0.0';
739
- const tarExtractor = makeTarExtractor(FAKE_TARBALL_ENTRIES);
740
-
741
- const fileMap = await defaultGithubFetcher(REPO, TAG, TOKEN, {
742
- httpClient: makeHappyHttpClient(TAG),
743
- tarExtractor,
744
- });
745
-
746
- assert.equal(
747
- fileMap['claude/agents/define.md'],
748
- FAKE_TARBALL_ENTRIES['dist/claude/agents/define.md'],
749
- );
750
- });
751
- });
752
-
753
- // ---------------------------------------------------------------------------
754
- // defaultGithubFetcher — sha256 sidecar verification (optional but preferred)
755
- // ---------------------------------------------------------------------------
756
-
757
- describe('defaultGithubFetcher — sha256 sidecar verification', () => {
758
- it('throws a mismatch error when sidecar hash does not match downloaded content', async () => {
759
- const TAG = '1.0.0';
760
-
761
- const tarExtractor = makeTarExtractor(FAKE_TARBALL_ENTRIES);
762
-
763
- // Provide both a .tar.gz and a .sha256 asset, but give a wrong hash.
764
- const httpClient = async (url, _init) => {
765
- if (url.includes(`/releases/tags/v${TAG}`)) {
766
- return {
767
- ok: true,
768
- status: 200,
769
- async json() {
770
- return makeReleaseBody({
771
- tagName: `v${TAG}`,
772
- assets: [
773
- makeAsset({ name: `dist-v${TAG}.tar.gz`, url: 'https://example.com/assets/dist.tar.gz' }),
774
- makeAsset({ name: `dist-v${TAG}.tar.gz.sha256`, url: 'https://example.com/assets/dist.sha256' }),
775
- ],
776
- });
777
- },
778
- headers: { get: () => null },
779
- };
780
- }
781
- if (url.includes('assets/dist.tar.gz')) {
782
- return {
783
- ok: true,
784
- status: 200,
785
- // Return 8 zero bytes as the tarball.
786
- async arrayBuffer() { return new ArrayBuffer(8); },
787
- headers: { get: () => null },
788
- };
789
- }
790
- if (url.includes('assets/dist.sha256')) {
791
- return {
792
- ok: true,
793
- status: 200,
794
- async arrayBuffer() {
795
- // A deliberately wrong sha256 hash (64 hex chars).
796
- const badHash = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
797
- const enc = new TextEncoder();
798
- const buf = enc.encode(badHash);
799
- return buf.buffer;
800
- },
801
- headers: { get: () => null },
802
- };
803
- }
804
- throw new Error(`stubHttpClient: unexpected URL ${url}`);
805
- };
806
-
807
- let thrown = null;
808
- try {
809
- await defaultGithubFetcher(REPO, TAG, TOKEN, { httpClient, tarExtractor });
810
- } catch (err) {
811
- thrown = err;
812
- }
813
-
814
- // If sha256 verification is implemented, it must throw here.
815
- // The test may be skipped by the implementation returning the FileMap
816
- // without verification only if the sidecar is genuinely absent — since
817
- // it IS present in this test, a mismatch must throw.
818
- assert.ok(
819
- thrown !== null,
820
- 'must throw when sha256 sidecar is present and hash does not match',
821
- );
822
- assert.ok(
823
- thrown.message.toLowerCase().includes('sha256') ||
824
- thrown.message.toLowerCase().includes('checksum') ||
825
- thrown.message.toLowerCase().includes('hash') ||
826
- thrown.message.toLowerCase().includes('mismatch'),
827
- `error message must mention sha256/checksum/hash/mismatch, got: ${thrown.message}`,
828
- );
829
- });
830
- });
831
-
832
- // ---------------------------------------------------------------------------
833
- // defaultGithubFetcher — error handling
834
- // ---------------------------------------------------------------------------
835
-
836
- describe('defaultGithubFetcher — error handling', () => {
837
- it('throws on 404 with message mentioning the missing tag', async () => {
838
- const TAG = '9.9.9';
839
-
840
- const httpClient = makeHttpClient({
841
- [`/releases/tags/v${TAG}`]: {
842
- status: 404,
843
- ok: false,
844
- jsonBody: { message: 'Not Found' },
845
- },
846
- });
847
-
848
- const tarExtractor = makeTarExtractor({});
849
-
850
- let thrown = null;
851
- try {
852
- await defaultGithubFetcher(REPO, TAG, TOKEN, { httpClient, tarExtractor });
853
- } catch (err) {
854
- thrown = err;
855
- }
856
-
857
- assert.ok(thrown !== null, 'must throw on 404');
858
- assert.ok(
859
- thrown.message.includes(TAG) || thrown.message.includes(`v${TAG}`),
860
- `error message must mention the missing tag, got: ${thrown.message}`,
861
- );
862
- });
863
-
864
- it('404 yields exitCode 2', async () => {
865
- const TAG = '9.9.9';
866
-
867
- const httpClient = makeHttpClient({
868
- [`/releases/tags/v${TAG}`]: {
869
- status: 404,
870
- ok: false,
871
- jsonBody: { message: 'Not Found' },
872
- },
873
- });
874
-
875
- const tarExtractor = makeTarExtractor({});
876
-
877
- let exitCode = null;
878
- try {
879
- await defaultGithubFetcher(REPO, TAG, TOKEN, { httpClient, tarExtractor });
880
- } catch (err) {
881
- exitCode = err.exitCode;
882
- }
883
-
884
- assert.equal(exitCode, 2, '404 must yield exitCode 2');
885
- });
886
-
887
- it('404 does NOT use STUB_NOT_IMPLEMENTED error code', async () => {
888
- const TAG = '9.9.9';
889
-
890
- const httpClient = makeHttpClient({
891
- [`/releases/tags/v${TAG}`]: {
892
- status: 404,
893
- ok: false,
894
- jsonBody: { message: 'Not Found' },
895
- },
896
- });
897
-
898
- const tarExtractor = makeTarExtractor({});
899
-
900
- let code = null;
901
- try {
902
- await defaultGithubFetcher(REPO, TAG, TOKEN, { httpClient, tarExtractor });
903
- } catch (err) {
904
- code = err.code;
905
- }
906
-
907
- assert.ok(
908
- code !== STUB_NOT_IMPLEMENTED,
909
- `404 error must not use STUB_NOT_IMPLEMENTED code; got: ${code}`,
910
- );
911
- });
912
-
913
- it('throws AuthError-shaped error on 401 (exitCode 2, mentions GITHUB_TOKEN)', async () => {
914
- const TAG = '1.0.0';
915
-
916
- const httpClient = makeHttpClient({
917
- [`/releases/tags/v${TAG}`]: {
918
- status: 401,
919
- ok: false,
920
- jsonBody: { message: 'Requires authentication' },
921
- },
922
- });
923
-
924
- const tarExtractor = makeTarExtractor({});
925
-
926
- let thrown = null;
927
- try {
928
- await defaultGithubFetcher(REPO, TAG, TOKEN, { httpClient, tarExtractor });
929
- } catch (err) {
930
- thrown = err;
931
- }
932
-
933
- assert.ok(thrown !== null, 'must throw on 401');
934
- assert.equal(thrown.exitCode, 2, 'exitCode must be 2 on 401');
935
- assert.ok(
936
- thrown.message.includes('GITHUB_TOKEN'),
937
- `error message must mention GITHUB_TOKEN on 401, got: ${thrown.message}`,
938
- );
939
- });
940
-
941
- it('throws AuthError-shaped error on 403 (mentions gh auth login)', async () => {
942
- const TAG = '1.0.0';
943
-
944
- const httpClient = makeHttpClient({
945
- [`/releases/tags/v${TAG}`]: {
946
- status: 403,
947
- ok: false,
948
- jsonBody: { message: 'Forbidden' },
949
- },
950
- });
951
-
952
- const tarExtractor = makeTarExtractor({});
953
-
954
- let thrown = null;
955
- try {
956
- await defaultGithubFetcher(REPO, TAG, TOKEN, { httpClient, tarExtractor });
957
- } catch (err) {
958
- thrown = err;
959
- }
960
-
961
- assert.ok(thrown !== null, 'must throw on 403');
962
- assert.ok(
963
- thrown.message.includes('gh auth login'),
964
- `error message must mention 'gh auth login' on 403, got: ${thrown.message}`,
965
- );
966
- });
967
-
968
- it('token never appears in error messages (404 case)', async () => {
969
- const TAG = '9.9.9';
970
-
971
- const httpClient = makeHttpClient({
972
- [`/releases/tags/v${TAG}`]: {
973
- status: 404,
974
- ok: false,
975
- jsonBody: { message: 'Not Found' },
976
- },
977
- });
978
-
979
- const tarExtractor = makeTarExtractor({});
980
-
981
- let message = '';
982
- try {
983
- await defaultGithubFetcher(REPO, TAG, TOKEN, { httpClient, tarExtractor });
984
- } catch (err) {
985
- message = err.message;
986
- }
987
-
988
- assert.ok(
989
- !message.includes(TOKEN),
990
- `error message must not contain the token, got: ${message}`,
991
- );
992
- });
993
-
994
- it('token never appears in error messages (401 case)', async () => {
995
- const TAG = '1.0.0';
996
-
997
- const httpClient = makeHttpClient({
998
- [`/releases/tags/v${TAG}`]: {
999
- status: 401,
1000
- ok: false,
1001
- jsonBody: { message: 'Requires authentication' },
1002
- },
1003
- });
1004
-
1005
- const tarExtractor = makeTarExtractor({});
1006
-
1007
- let message = '';
1008
- try {
1009
- await defaultGithubFetcher(REPO, TAG, TOKEN, { httpClient, tarExtractor });
1010
- } catch (err) {
1011
- message = err.message;
1012
- }
1013
-
1014
- assert.ok(
1015
- !message.includes(TOKEN),
1016
- `error message must not contain the token, got: ${message}`,
1017
- );
1018
- });
1019
- });
1020
-
1021
- // ---------------------------------------------------------------------------
1022
- // defaultGithubFetcher — redirect handling (auth stripping on hop 2)
1023
- // ---------------------------------------------------------------------------
1024
-
1025
- describe('defaultGithubFetcher — redirect handling', () => {
1026
- it('strips Authorization header when following 302 from GitHub asset URL to S3', async () => {
1027
- const TAG = '1.0.0';
1028
- const S3_URL = 'https://s3.example/presigned/dist.tar.gz?sig=abc';
1029
- const calls = [];
1030
-
1031
- const tarExtractor = makeTarExtractor(FAKE_TARBALL_ENTRIES);
1032
-
1033
- const httpClient = async (url, init) => {
1034
- calls.push({ url, init });
1035
- if (url.includes(`/releases/tags/v${TAG}`)) {
1036
- return {
1037
- ok: true,
1038
- status: 200,
1039
- async json() {
1040
- return makeReleaseBody({
1041
- tagName: `v${TAG}`,
1042
- assets: [
1043
- makeAsset({ name: `dist-v${TAG}.tar.gz`, url: 'https://api.github.com/assets/123' }),
1044
- ],
1045
- });
1046
- },
1047
- headers: { get: () => null },
1048
- };
1049
- }
1050
- if (url === 'https://api.github.com/assets/123') {
1051
- return {
1052
- ok: false,
1053
- status: 302,
1054
- async arrayBuffer() { return new ArrayBuffer(0); },
1055
- headers: {
1056
- get(name) {
1057
- return name.toLowerCase() === 'location' ? S3_URL : null;
1058
- },
1059
- },
1060
- };
1061
- }
1062
- if (url === S3_URL) {
1063
- return {
1064
- ok: true,
1065
- status: 200,
1066
- async arrayBuffer() { return new ArrayBuffer(8); },
1067
- headers: { get: () => null },
1068
- };
1069
- }
1070
- throw new Error(`stubHttpClient: unexpected URL ${url}`);
1071
- };
1072
-
1073
- await defaultGithubFetcher(REPO, TAG, TOKEN, { httpClient, tarExtractor });
1074
-
1075
- const s3Call = calls.find((c) => c.url === S3_URL);
1076
- assert.ok(s3Call, 'must issue a second request to the S3 redirect target');
1077
-
1078
- const s3AuthHeader =
1079
- (s3Call.init?.headers?.Authorization) ??
1080
- (s3Call.init?.headers?.authorization);
1081
- assert.equal(
1082
- s3AuthHeader,
1083
- undefined,
1084
- `redirect hop must NOT include Authorization header, got: ${s3AuthHeader}`,
1085
- );
1086
-
1087
- const s3Accept =
1088
- (s3Call.init?.headers?.Accept) ??
1089
- (s3Call.init?.headers?.accept);
1090
- assert.equal(
1091
- s3Accept,
1092
- 'application/octet-stream',
1093
- `redirect hop must use Accept: application/octet-stream, got: ${s3Accept}`,
1094
- );
1095
-
1096
- const serialized = JSON.stringify(s3Call.init ?? {});
1097
- assert.ok(
1098
- !serialized.includes(TOKEN),
1099
- `token must not appear in redirect hop init, got: ${serialized}`,
1100
- );
1101
-
1102
- const firstAssetCall = calls.find((c) => c.url === 'https://api.github.com/assets/123');
1103
- assert.equal(
1104
- firstAssetCall.init?.redirect,
1105
- 'manual',
1106
- 'first asset hop must use redirect: manual',
1107
- );
1108
- });
1109
-
1110
- it('strips Authorization header when following 302 from sidecar URL to S3', async () => {
1111
- const TAG = '1.0.0';
1112
- const SIDECAR_S3 = 'https://s3.example/presigned/dist.sha256?sig=def';
1113
- const TARBALL_S3 = 'https://s3.example/presigned/dist.tar.gz?sig=abc';
1114
- const calls = [];
1115
-
1116
- const tarExtractor = makeTarExtractor(FAKE_TARBALL_ENTRIES);
1117
-
1118
- const { createHash } = await import('node:crypto');
1119
- const correctHash = createHash('sha256').update(Buffer.alloc(8)).digest('hex');
1120
-
1121
- const httpClient = async (url, init) => {
1122
- calls.push({ url, init });
1123
- if (url.includes(`/releases/tags/v${TAG}`)) {
1124
- return {
1125
- ok: true,
1126
- status: 200,
1127
- async json() {
1128
- return makeReleaseBody({
1129
- tagName: `v${TAG}`,
1130
- assets: [
1131
- makeAsset({ name: `dist-v${TAG}.tar.gz`, url: 'https://api.github.com/assets/tar' }),
1132
- makeAsset({ name: `dist-v${TAG}.tar.gz.sha256`, url: 'https://api.github.com/assets/sha' }),
1133
- ],
1134
- });
1135
- },
1136
- headers: { get: () => null },
1137
- };
1138
- }
1139
- if (url === 'https://api.github.com/assets/tar') {
1140
- return {
1141
- ok: false,
1142
- status: 302,
1143
- async arrayBuffer() { return new ArrayBuffer(0); },
1144
- headers: {
1145
- get(name) {
1146
- return name.toLowerCase() === 'location' ? TARBALL_S3 : null;
1147
- },
1148
- },
1149
- };
1150
- }
1151
- if (url === TARBALL_S3) {
1152
- return {
1153
- ok: true,
1154
- status: 200,
1155
- async arrayBuffer() { return new ArrayBuffer(8); },
1156
- headers: { get: () => null },
1157
- };
1158
- }
1159
- if (url === 'https://api.github.com/assets/sha') {
1160
- return {
1161
- ok: false,
1162
- status: 302,
1163
- async arrayBuffer() { return new ArrayBuffer(0); },
1164
- headers: {
1165
- get(name) {
1166
- return name.toLowerCase() === 'location' ? SIDECAR_S3 : null;
1167
- },
1168
- },
1169
- };
1170
- }
1171
- if (url === SIDECAR_S3) {
1172
- const enc = new TextEncoder();
1173
- const buf = enc.encode(`${correctHash} dist-v${TAG}.tar.gz\n`);
1174
- return {
1175
- ok: true,
1176
- status: 200,
1177
- async arrayBuffer() { return buf.buffer; },
1178
- headers: { get: () => null },
1179
- };
1180
- }
1181
- throw new Error(`stubHttpClient: unexpected URL ${url}`);
1182
- };
1183
-
1184
- await defaultGithubFetcher(REPO, TAG, TOKEN, { httpClient, tarExtractor });
1185
-
1186
- const sidecarS3Call = calls.find((c) => c.url === SIDECAR_S3);
1187
- assert.ok(sidecarS3Call, 'must issue a second request to the sidecar S3 URL');
1188
-
1189
- const authHeader =
1190
- (sidecarS3Call.init?.headers?.Authorization) ??
1191
- (sidecarS3Call.init?.headers?.authorization);
1192
- assert.equal(
1193
- authHeader,
1194
- undefined,
1195
- `sidecar redirect hop must NOT include Authorization header, got: ${authHeader}`,
1196
- );
1197
-
1198
- const serialized = JSON.stringify(sidecarS3Call.init ?? {});
1199
- assert.ok(
1200
- !serialized.includes(TOKEN),
1201
- `token must not appear in sidecar redirect hop init, got: ${serialized}`,
1202
- );
1203
- });
1204
- });
1205
-
1206
- // ---------------------------------------------------------------------------
1207
- // defaultTarExtractor — real extraction from an in-memory .tar.gz fixture
1208
- // ---------------------------------------------------------------------------
1209
-
1210
- describe('defaultTarExtractor — real extraction', () => {
1211
- it('extracts a real .tar.gz buffer into a FileMap, skipping directories', async () => {
1212
- const tmp = await mkdtemp(join(tmpdir(), 'tar-fixture-'));
1213
- try {
1214
- await writeFile(join(tmp, 'a.md'), '# alpha\n', 'utf8');
1215
- await mkdir(join(tmp, 'nested'), { recursive: true });
1216
- await writeFile(join(tmp, 'nested', 'b.md'), '# beta\n', 'utf8');
1217
-
1218
- const chunks = [];
1219
- const stream = tar.create(
1220
- { gzip: true, cwd: tmp, portable: true },
1221
- ['a.md', 'nested/b.md'],
1222
- );
1223
- for await (const chunk of stream) {
1224
- chunks.push(chunk);
1225
- }
1226
- const buffer = Buffer.concat(chunks);
1227
-
1228
- const entries = await defaultTarExtractor(buffer);
1229
-
1230
- assert.equal(entries['a.md'], '# alpha\n', 'a.md must be present with content');
1231
- assert.equal(
1232
- entries['nested/b.md'],
1233
- '# beta\n',
1234
- 'nested/b.md must be present with content',
1235
- );
1236
-
1237
- for (const key of Object.keys(entries)) {
1238
- assert.ok(
1239
- !key.endsWith('/'),
1240
- `extractor must skip directory entries, got: ${key}`,
1241
- );
1242
- }
1243
- } finally {
1244
- await rm(tmp, { recursive: true, force: true });
1245
- }
1246
- });
1247
- });
1
+ /**
2
+ * tests/fetcher.test.mjs
3
+ *
4
+ * Failing tests for the real implementations of:
5
+ * - defaultGithubTagLister(repo, token, { httpClient })
6
+ * - defaultGithubFetcher(repo, tag, token, { httpClient, tarExtractor })
7
+ *
8
+ * No real network is used. All HTTP is stubbed via the injectable `httpClient`
9
+ * seam. The `tarExtractor` seam is also injected so real tar decompression
10
+ * is out of scope.
11
+ *
12
+ * These tests FAIL against the current stub implementation and PASS only
13
+ * after the Implement agent wires up real logic.
14
+ *
15
+ * Target API (the Implement agent must match these signatures):
16
+ *
17
+ * defaultGithubTagLister(repo, token, { httpClient })
18
+ * -> Promise<string[]> bare semver strings, sorted newest-first
19
+ *
20
+ * defaultGithubFetcher(repo, tag, token, { httpClient, tarExtractor })
21
+ * -> Promise<FileMap> { [relativePath]: content }
22
+ * `tag` is bare semver e.g. "1.0.0" (no "v" prefix)
23
+ */
24
+
25
+ import { describe, it } from 'node:test';
26
+ import assert from 'node:assert/strict';
27
+
28
+ import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
29
+ import { tmpdir } from 'node:os';
30
+ import { join } from 'node:path';
31
+ import * as tar from 'tar';
32
+
33
+ import {
34
+ defaultGithubTagLister,
35
+ defaultGithubFetcher,
36
+ defaultTarExtractor,
37
+ STUB_NOT_IMPLEMENTED,
38
+ } from '../src/fetcher.mjs';
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Mock / helper factories
42
+ // ---------------------------------------------------------------------------
43
+
44
+ /**
45
+ * Creates a minimal httpClient mock.
46
+ *
47
+ * `responses` is a Map<url-substring, responseShape> used to dispatch.
48
+ * A responseShape is:
49
+ * { status, ok, jsonBody?, bufferBody?, headers? }
50
+ *
51
+ * The mock returns the first entry whose key is a substring of the requested URL.
52
+ * If nothing matches, it throws to make test failures obvious.
53
+ *
54
+ * Signature mirrors the Fetch API:
55
+ * (url: string, init?: { headers?: object }) => Promise<Response-like>
56
+ */
57
+ function makeHttpClient(responses) {
58
+ return async function stubHttpClient(url, _init) {
59
+ for (const [key, shape] of Object.entries(responses)) {
60
+ if (url.includes(key)) {
61
+ return {
62
+ ok: shape.ok ?? (shape.status >= 200 && shape.status < 300),
63
+ status: shape.status ?? 200,
64
+ async json() {
65
+ return shape.jsonBody ?? null;
66
+ },
67
+ async arrayBuffer() {
68
+ const body = shape.bufferBody ?? new ArrayBuffer(0);
69
+ return body;
70
+ },
71
+ headers: {
72
+ get(name) {
73
+ return (shape.headers ?? {})[name.toLowerCase()] ?? null;
74
+ },
75
+ },
76
+ };
77
+ }
78
+ }
79
+ throw new Error(`stubHttpClient: no mock registered for URL: ${url}`);
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Creates a minimal tarExtractor mock.
85
+ *
86
+ * Returns a fixed FileMap for any buffer, simulating a tarball that contains
87
+ * the provided entries.
88
+ *
89
+ * The entries object maps tar entry paths (as they appear inside the tarball)
90
+ * to their string content.
91
+ */
92
+ function makeTarExtractor(entries) {
93
+ return async function stubTarExtractor(_buffer) {
94
+ return entries;
95
+ };
96
+ }
97
+
98
+ /** Builds a fake releases list response body matching the GitHub Releases API shape. */
99
+ function makeReleasesBody(releases) {
100
+ // Each element: { tag_name, draft, prerelease, assets: [] }
101
+ return releases;
102
+ }
103
+
104
+ /** Builds a fake single-release response body with assets attached. */
105
+ function makeReleaseBody({ tagName, assets = [] }) {
106
+ return {
107
+ tag_name: tagName,
108
+ draft: false,
109
+ prerelease: false,
110
+ assets,
111
+ };
112
+ }
113
+
114
+ /** Builds a fake asset descriptor as GitHub returns in the releases API. */
115
+ function makeAsset({ name, url, size = 100 }) {
116
+ return { name, url, size };
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Fixture data
121
+ // ---------------------------------------------------------------------------
122
+
123
+ const REPO = 'dalzoubi/dev-agents';
124
+ const TOKEN = 'ghp_TESTTOKEN000000';
125
+
126
+ // A tarball that contains a dist/ subtree as the implement agent should emit.
127
+ // Keys mirror the tarball entry paths before the fetcher strips the dist/ prefix.
128
+ const FAKE_TARBALL_ENTRIES = {
129
+ 'dist/claude/agents/define.md': '# define agent',
130
+ 'dist/claude/commands/preflight.md': '# preflight command',
131
+ 'dist/cursor/rules/define.mdc': '# define rule',
132
+ // An entry outside dist/ — must be filtered out by the fetcher.
133
+ 'README.md': '# should be ignored',
134
+ // A directory entry (no content) — must be skipped.
135
+ 'dist/claude/agents/': '',
136
+ };
137
+
138
+ // The expected FileMap after path-rewriting: dist/ is stripped.
139
+ const EXPECTED_FILE_MAP = {
140
+ 'claude/agents/define.md': '# define agent',
141
+ 'claude/commands/preflight.md': '# preflight command',
142
+ 'cursor/rules/define.mdc': '# define rule',
143
+ };
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // defaultGithubTagLister — HTTP contract
147
+ // ---------------------------------------------------------------------------
148
+
149
+ describe('defaultGithubTagLister — HTTP contract', () => {
150
+ it('calls GET /repos/<repo>/releases with Authorization Bearer header', async () => {
151
+ let capturedUrl = null;
152
+ let capturedInit = null;
153
+
154
+ const spyHttpClient = async (url, init) => {
155
+ capturedUrl = url;
156
+ capturedInit = init;
157
+ return {
158
+ ok: true,
159
+ status: 200,
160
+ async json() { return []; },
161
+ headers: { get: () => null },
162
+ };
163
+ };
164
+
165
+ await defaultGithubTagLister(REPO, TOKEN, { httpClient: spyHttpClient });
166
+
167
+ assert.ok(
168
+ capturedUrl !== null,
169
+ 'httpClient must be called',
170
+ );
171
+ assert.ok(
172
+ capturedUrl.includes(`/repos/${REPO}/releases`),
173
+ `URL must include /repos/${REPO}/releases, got: ${capturedUrl}`,
174
+ );
175
+ const authHeader =
176
+ (capturedInit?.headers?.Authorization) ??
177
+ (capturedInit?.headers?.authorization);
178
+ assert.ok(
179
+ authHeader === `Bearer ${TOKEN}`,
180
+ `Authorization header must be "Bearer <token>", got: ${authHeader}`,
181
+ );
182
+ });
183
+
184
+ it('sends Accept: application/vnd.github+json', async () => {
185
+ let capturedInit = null;
186
+
187
+ const spyHttpClient = async (_url, init) => {
188
+ capturedInit = init;
189
+ return {
190
+ ok: true,
191
+ status: 200,
192
+ async json() { return []; },
193
+ headers: { get: () => null },
194
+ };
195
+ };
196
+
197
+ await defaultGithubTagLister(REPO, TOKEN, { httpClient: spyHttpClient });
198
+
199
+ const acceptHeader =
200
+ (capturedInit?.headers?.Accept) ??
201
+ (capturedInit?.headers?.accept);
202
+ assert.ok(
203
+ acceptHeader === 'application/vnd.github+json',
204
+ `Accept header must be "application/vnd.github+json", got: ${acceptHeader}`,
205
+ );
206
+ });
207
+ });
208
+
209
+ // ---------------------------------------------------------------------------
210
+ // defaultGithubTagLister — response parsing
211
+ // ---------------------------------------------------------------------------
212
+
213
+ describe('defaultGithubTagLister — response parsing', () => {
214
+ it('returns bare semver strings (no "v" prefix)', async () => {
215
+ const releases = makeReleasesBody([
216
+ { tag_name: 'v1.2.3', draft: false, prerelease: false },
217
+ { tag_name: 'v1.0.0', draft: false, prerelease: false },
218
+ ]);
219
+
220
+ const httpClient = makeHttpClient({
221
+ [`/repos/${REPO}/releases`]: { status: 200, jsonBody: releases },
222
+ });
223
+
224
+ const tags = await defaultGithubTagLister(REPO, TOKEN, { httpClient });
225
+
226
+ for (const t of tags) {
227
+ assert.ok(
228
+ !t.startsWith('v'),
229
+ `tag must not start with "v", got: ${t}`,
230
+ );
231
+ }
232
+ });
233
+
234
+ it('filters out drafts', async () => {
235
+ const releases = makeReleasesBody([
236
+ { tag_name: 'v1.2.3', draft: false, prerelease: false },
237
+ { tag_name: 'v1.1.0', draft: true, prerelease: false },
238
+ ]);
239
+
240
+ const httpClient = makeHttpClient({
241
+ [`/repos/${REPO}/releases`]: { status: 200, jsonBody: releases },
242
+ });
243
+
244
+ const tags = await defaultGithubTagLister(REPO, TOKEN, { httpClient });
245
+
246
+ assert.ok(!tags.includes('1.1.0'), 'draft release must be filtered out');
247
+ assert.ok(tags.includes('1.2.3'), '1.2.3 must be present');
248
+ });
249
+
250
+ it('filters out prereleases', async () => {
251
+ const releases = makeReleasesBody([
252
+ { tag_name: 'v2.0.0', draft: false, prerelease: false },
253
+ { tag_name: 'v2.0.0-beta.1', draft: false, prerelease: true },
254
+ ]);
255
+
256
+ const httpClient = makeHttpClient({
257
+ [`/repos/${REPO}/releases`]: { status: 200, jsonBody: releases },
258
+ });
259
+
260
+ const tags = await defaultGithubTagLister(REPO, TOKEN, { httpClient });
261
+
262
+ assert.ok(!tags.includes('2.0.0-beta.1'), 'prerelease must be filtered out');
263
+ assert.ok(tags.includes('2.0.0'), '2.0.0 must be present');
264
+ });
265
+
266
+ it('filters out tags that do not match ^v\\d+\\.\\d+\\.\\d+$ pattern', async () => {
267
+ const releases = makeReleasesBody([
268
+ { tag_name: 'v1.0.0', draft: false, prerelease: false },
269
+ { tag_name: 'latest', draft: false, prerelease: false },
270
+ { tag_name: 'v1.0.0-rc.1', draft: false, prerelease: false },
271
+ { tag_name: 'v1.0', draft: false, prerelease: false },
272
+ ]);
273
+
274
+ const httpClient = makeHttpClient({
275
+ [`/repos/${REPO}/releases`]: { status: 200, jsonBody: releases },
276
+ });
277
+
278
+ const tags = await defaultGithubTagLister(REPO, TOKEN, { httpClient });
279
+
280
+ assert.ok(tags.includes('1.0.0'), '1.0.0 must be included');
281
+ assert.ok(!tags.includes('latest'), '"latest" must be excluded');
282
+ assert.ok(!tags.includes('1.0.0-rc.1'), 'rc tag must be excluded');
283
+ assert.ok(!tags.includes('1.0'), 'partial semver must be excluded');
284
+ assert.ok(!tags.includes('v1.0.0'), 'tags must not include "v" prefix');
285
+ });
286
+
287
+ it('sorts results descending by semver (newest first)', async () => {
288
+ const releases = makeReleasesBody([
289
+ { tag_name: 'v1.0.0', draft: false, prerelease: false },
290
+ { tag_name: 'v1.2.0', draft: false, prerelease: false },
291
+ { tag_name: 'v1.1.0', draft: false, prerelease: false },
292
+ { tag_name: 'v2.0.0', draft: false, prerelease: false },
293
+ ]);
294
+
295
+ const httpClient = makeHttpClient({
296
+ [`/repos/${REPO}/releases`]: { status: 200, jsonBody: releases },
297
+ });
298
+
299
+ const tags = await defaultGithubTagLister(REPO, TOKEN, { httpClient });
300
+
301
+ assert.deepEqual(
302
+ tags,
303
+ ['2.0.0', '1.2.0', '1.1.0', '1.0.0'],
304
+ 'tags must be sorted descending by semver',
305
+ );
306
+ });
307
+
308
+ it('returns empty array when releases list is empty', async () => {
309
+ const httpClient = makeHttpClient({
310
+ [`/repos/${REPO}/releases`]: { status: 200, jsonBody: [] },
311
+ });
312
+
313
+ const tags = await defaultGithubTagLister(REPO, TOKEN, { httpClient });
314
+
315
+ assert.deepEqual(tags, [], 'empty releases must return []');
316
+ });
317
+ });
318
+
319
+ // ---------------------------------------------------------------------------
320
+ // defaultGithubTagLister — error handling
321
+ // ---------------------------------------------------------------------------
322
+
323
+ describe('defaultGithubTagLister — error handling', () => {
324
+ it('throws AuthError-shaped error on 401 (mentions GITHUB_TOKEN)', async () => {
325
+ const httpClient = makeHttpClient({
326
+ [`/repos/${REPO}/releases`]: { status: 401, ok: false, jsonBody: { message: 'Requires authentication' } },
327
+ });
328
+
329
+ let thrown = null;
330
+ try {
331
+ await defaultGithubTagLister(REPO, TOKEN, { httpClient });
332
+ } catch (err) {
333
+ thrown = err;
334
+ }
335
+
336
+ assert.ok(thrown !== null, 'must throw on 401');
337
+ assert.ok(
338
+ thrown.message.includes('GITHUB_TOKEN'),
339
+ `error message must mention GITHUB_TOKEN, got: ${thrown.message}`,
340
+ );
341
+ assert.equal(thrown.exitCode, 2, 'exitCode must be 2 on auth error');
342
+ });
343
+
344
+ it('throws AuthError-shaped error on 401 (mentions gh auth login)', async () => {
345
+ const httpClient = makeHttpClient({
346
+ [`/repos/${REPO}/releases`]: { status: 401, ok: false, jsonBody: { message: 'Requires authentication' } },
347
+ });
348
+
349
+ let message = '';
350
+ try {
351
+ await defaultGithubTagLister(REPO, TOKEN, { httpClient });
352
+ } catch (err) {
353
+ message = err.message;
354
+ }
355
+
356
+ assert.ok(
357
+ message.includes('gh auth login'),
358
+ `error message must mention 'gh auth login', got: ${message}`,
359
+ );
360
+ });
361
+
362
+ it('throws AuthError-shaped error on 403', async () => {
363
+ const httpClient = makeHttpClient({
364
+ [`/repos/${REPO}/releases`]: { status: 403, ok: false, jsonBody: { message: 'Forbidden' } },
365
+ });
366
+
367
+ let thrown = null;
368
+ try {
369
+ await defaultGithubTagLister(REPO, TOKEN, { httpClient });
370
+ } catch (err) {
371
+ thrown = err;
372
+ }
373
+
374
+ assert.ok(thrown !== null, 'must throw on 403');
375
+ assert.equal(thrown.exitCode, 2, 'exitCode must be 2 on auth error');
376
+ assert.ok(
377
+ thrown.message.includes('GITHUB_TOKEN'),
378
+ 'error message must mention GITHUB_TOKEN on 403',
379
+ );
380
+ });
381
+
382
+ it('throws on 5xx without STUB_NOT_IMPLEMENTED code', async () => {
383
+ const httpClient = makeHttpClient({
384
+ [`/repos/${REPO}/releases`]: { status: 503, ok: false, jsonBody: { message: 'Service unavailable' } },
385
+ });
386
+
387
+ let thrown = null;
388
+ try {
389
+ await defaultGithubTagLister(REPO, TOKEN, { httpClient });
390
+ } catch (err) {
391
+ thrown = err;
392
+ }
393
+
394
+ assert.ok(thrown !== null, 'must throw on 5xx');
395
+ assert.ok(
396
+ thrown.code !== STUB_NOT_IMPLEMENTED,
397
+ `5xx error must not use STUB_NOT_IMPLEMENTED code; code was: ${thrown.code}`,
398
+ );
399
+ });
400
+
401
+ it('token does not appear in error messages', async () => {
402
+ const httpClient = makeHttpClient({
403
+ [`/repos/${REPO}/releases`]: { status: 401, ok: false, jsonBody: { message: 'Requires authentication' } },
404
+ });
405
+
406
+ let message = '';
407
+ try {
408
+ await defaultGithubTagLister(REPO, TOKEN, { httpClient });
409
+ } catch (err) {
410
+ message = err.message;
411
+ }
412
+
413
+ assert.ok(
414
+ !message.includes(TOKEN),
415
+ `error message must not contain the token, got: ${message}`,
416
+ );
417
+ });
418
+ });
419
+
420
+ // ---------------------------------------------------------------------------
421
+ // defaultGithubFetcher — HTTP contract
422
+ // ---------------------------------------------------------------------------
423
+
424
+ describe('defaultGithubFetcher — HTTP contract', () => {
425
+ it('calls GET /repos/<repo>/releases/tags/v<tag>', async () => {
426
+ const TAG = '1.0.0';
427
+ let capturedReleaseUrl = null;
428
+
429
+ const tarExtractor = makeTarExtractor(FAKE_TARBALL_ENTRIES);
430
+
431
+ const httpClient = async (url, init) => {
432
+ if (url.includes(`/releases/tags/v${TAG}`)) {
433
+ capturedReleaseUrl = url;
434
+ return {
435
+ ok: true,
436
+ status: 200,
437
+ async json() {
438
+ return makeReleaseBody({
439
+ tagName: `v${TAG}`,
440
+ assets: [
441
+ makeAsset({ name: `dist-v${TAG}.tar.gz`, url: 'https://example.com/assets/dist.tar.gz' }),
442
+ ],
443
+ });
444
+ },
445
+ headers: { get: () => null },
446
+ };
447
+ }
448
+ if (url.includes('assets/dist.tar.gz')) {
449
+ return {
450
+ ok: true,
451
+ status: 200,
452
+ async arrayBuffer() { return new ArrayBuffer(8); },
453
+ headers: { get: () => null },
454
+ };
455
+ }
456
+ throw new Error(`stubHttpClient: unexpected URL ${url}`);
457
+ };
458
+
459
+ await defaultGithubFetcher(REPO, TAG, TOKEN, { httpClient, tarExtractor });
460
+
461
+ assert.ok(
462
+ capturedReleaseUrl !== null,
463
+ 'must call GET on the releases/tags endpoint',
464
+ );
465
+ assert.ok(
466
+ capturedReleaseUrl.includes(`/repos/${REPO}/releases/tags/v${TAG}`),
467
+ `URL must include /repos/${REPO}/releases/tags/v${TAG}, got: ${capturedReleaseUrl}`,
468
+ );
469
+ });
470
+
471
+ it('downloads the asset using Accept: application/octet-stream and bearer token', async () => {
472
+ const TAG = '1.0.0';
473
+ let capturedAssetInit = null;
474
+
475
+ const tarExtractor = makeTarExtractor(FAKE_TARBALL_ENTRIES);
476
+
477
+ const httpClient = async (url, init) => {
478
+ if (url.includes(`/releases/tags/v${TAG}`)) {
479
+ return {
480
+ ok: true,
481
+ status: 200,
482
+ async json() {
483
+ return makeReleaseBody({
484
+ tagName: `v${TAG}`,
485
+ assets: [
486
+ makeAsset({ name: `dist-v${TAG}.tar.gz`, url: 'https://example.com/assets/dist.tar.gz' }),
487
+ ],
488
+ });
489
+ },
490
+ headers: { get: () => null },
491
+ };
492
+ }
493
+ if (url.includes('assets/dist.tar.gz')) {
494
+ capturedAssetInit = init;
495
+ return {
496
+ ok: true,
497
+ status: 200,
498
+ async arrayBuffer() { return new ArrayBuffer(8); },
499
+ headers: { get: () => null },
500
+ };
501
+ }
502
+ throw new Error(`stubHttpClient: unexpected URL ${url}`);
503
+ };
504
+
505
+ await defaultGithubFetcher(REPO, TAG, TOKEN, { httpClient, tarExtractor });
506
+
507
+ assert.ok(capturedAssetInit !== null, 'httpClient must be called for asset download');
508
+
509
+ const acceptHeader =
510
+ (capturedAssetInit?.headers?.Accept) ??
511
+ (capturedAssetInit?.headers?.accept);
512
+ assert.ok(
513
+ acceptHeader === 'application/octet-stream',
514
+ `asset download must use Accept: application/octet-stream, got: ${acceptHeader}`,
515
+ );
516
+
517
+ const authHeader =
518
+ (capturedAssetInit?.headers?.Authorization) ??
519
+ (capturedAssetInit?.headers?.authorization);
520
+ assert.ok(
521
+ authHeader === `Bearer ${TOKEN}`,
522
+ `asset download must include Authorization: Bearer <token>, got: ${authHeader}`,
523
+ );
524
+ });
525
+ });
526
+
527
+ // ---------------------------------------------------------------------------
528
+ // defaultGithubFetcher — asset resolution
529
+ // ---------------------------------------------------------------------------
530
+
531
+ describe('defaultGithubFetcher — asset resolution', () => {
532
+ it('finds the asset named dist-v<tag>.tar.gz', async () => {
533
+ const TAG = '1.2.3';
534
+ let downloadedUrl = null;
535
+
536
+ const tarExtractor = makeTarExtractor(FAKE_TARBALL_ENTRIES);
537
+
538
+ const httpClient = async (url, init) => {
539
+ if (url.includes(`/releases/tags/v${TAG}`)) {
540
+ return {
541
+ ok: true,
542
+ status: 200,
543
+ async json() {
544
+ return makeReleaseBody({
545
+ tagName: `v${TAG}`,
546
+ assets: [
547
+ makeAsset({ name: `dist-v${TAG}.tar.gz`, url: 'https://example.com/assets/main.tar.gz' }),
548
+ makeAsset({ name: 'something-else.zip', url: 'https://example.com/assets/other.zip' }),
549
+ ],
550
+ });
551
+ },
552
+ headers: { get: () => null },
553
+ };
554
+ }
555
+ if (url.includes('assets/main.tar.gz')) {
556
+ downloadedUrl = url;
557
+ return {
558
+ ok: true,
559
+ status: 200,
560
+ async arrayBuffer() { return new ArrayBuffer(8); },
561
+ headers: { get: () => null },
562
+ };
563
+ }
564
+ throw new Error(`stubHttpClient: unexpected URL ${url}`);
565
+ };
566
+
567
+ await defaultGithubFetcher(REPO, TAG, TOKEN, { httpClient, tarExtractor });
568
+
569
+ assert.ok(
570
+ downloadedUrl !== null && downloadedUrl.includes('assets/main.tar.gz'),
571
+ `must download the correct asset URL, got: ${downloadedUrl}`,
572
+ );
573
+ });
574
+
575
+ it('throws when the expected asset is absent (error mentions asset name)', async () => {
576
+ const TAG = '1.0.0';
577
+
578
+ const tarExtractor = makeTarExtractor({});
579
+
580
+ const httpClient = makeHttpClient({
581
+ [`/releases/tags/v${TAG}`]: {
582
+ status: 200,
583
+ jsonBody: makeReleaseBody({
584
+ tagName: `v${TAG}`,
585
+ assets: [
586
+ // Wrong asset name — the correct one is dist-v1.0.0.tar.gz
587
+ makeAsset({ name: 'wrong-asset.zip', url: 'https://example.com/assets/wrong.zip' }),
588
+ ],
589
+ }),
590
+ },
591
+ });
592
+
593
+ let thrown = null;
594
+ try {
595
+ await defaultGithubFetcher(REPO, TAG, TOKEN, { httpClient, tarExtractor });
596
+ } catch (err) {
597
+ thrown = err;
598
+ }
599
+
600
+ assert.ok(thrown !== null, 'must throw when asset is absent');
601
+ assert.ok(
602
+ thrown.message.includes(`dist-v${TAG}.tar.gz`),
603
+ `error message must mention the missing asset name "dist-v${TAG}.tar.gz", got: ${thrown.message}`,
604
+ );
605
+ });
606
+ });
607
+
608
+ // ---------------------------------------------------------------------------
609
+ // defaultGithubFetcher — tarExtractor integration
610
+ // ---------------------------------------------------------------------------
611
+
612
+ describe('defaultGithubFetcher — tarExtractor integration', () => {
613
+ /**
614
+ * Builds a standard happy-path httpClient for a given tag, returning a
615
+ * release with the dist tarball asset. The asset download returns an
616
+ * ArrayBuffer of 8 zero bytes (content doesn't matter for stub extraction).
617
+ */
618
+ function makeHappyHttpClient(tag) {
619
+ return async (url, _init) => {
620
+ if (url.includes(`/releases/tags/v${tag}`)) {
621
+ return {
622
+ ok: true,
623
+ status: 200,
624
+ async json() {
625
+ return makeReleaseBody({
626
+ tagName: `v${tag}`,
627
+ assets: [
628
+ makeAsset({ name: `dist-v${tag}.tar.gz`, url: 'https://example.com/assets/dist.tar.gz' }),
629
+ ],
630
+ });
631
+ },
632
+ headers: { get: () => null },
633
+ };
634
+ }
635
+ if (url.includes('assets/dist.tar.gz')) {
636
+ return {
637
+ ok: true,
638
+ status: 200,
639
+ async arrayBuffer() { return new ArrayBuffer(8); },
640
+ headers: { get: () => null },
641
+ };
642
+ }
643
+ throw new Error(`stubHttpClient: unexpected URL ${url}`);
644
+ };
645
+ }
646
+
647
+ it('calls tarExtractor with the downloaded buffer', async () => {
648
+ const TAG = '1.0.0';
649
+ let extractorWasCalled = false;
650
+
651
+ const tarExtractor = async (buffer) => {
652
+ extractorWasCalled = true;
653
+ assert.ok(buffer instanceof ArrayBuffer, 'tarExtractor must receive an ArrayBuffer');
654
+ return FAKE_TARBALL_ENTRIES;
655
+ };
656
+
657
+ await defaultGithubFetcher(REPO, TAG, TOKEN, {
658
+ httpClient: makeHappyHttpClient(TAG),
659
+ tarExtractor,
660
+ });
661
+
662
+ assert.ok(extractorWasCalled, 'tarExtractor must be called');
663
+ });
664
+
665
+ it('strips dist/ prefix from tarball entry paths', async () => {
666
+ const TAG = '1.0.0';
667
+ const tarExtractor = makeTarExtractor(FAKE_TARBALL_ENTRIES);
668
+
669
+ const fileMap = await defaultGithubFetcher(REPO, TAG, TOKEN, {
670
+ httpClient: makeHappyHttpClient(TAG),
671
+ tarExtractor,
672
+ });
673
+
674
+ for (const key of Object.keys(fileMap)) {
675
+ assert.ok(
676
+ !key.startsWith('dist/'),
677
+ `FileMap key must not start with "dist/", got: ${key}`,
678
+ );
679
+ }
680
+ });
681
+
682
+ it('includes expected keys after prefix rewriting', async () => {
683
+ const TAG = '1.0.0';
684
+ const tarExtractor = makeTarExtractor(FAKE_TARBALL_ENTRIES);
685
+
686
+ const fileMap = await defaultGithubFetcher(REPO, TAG, TOKEN, {
687
+ httpClient: makeHappyHttpClient(TAG),
688
+ tarExtractor,
689
+ });
690
+
691
+ assert.ok(
692
+ 'claude/agents/define.md' in fileMap,
693
+ 'FileMap must contain "claude/agents/define.md"',
694
+ );
695
+ assert.ok(
696
+ 'cursor/rules/define.mdc' in fileMap,
697
+ 'FileMap must contain "cursor/rules/define.mdc"',
698
+ );
699
+ assert.ok(
700
+ 'claude/commands/preflight.md' in fileMap,
701
+ 'FileMap must contain "claude/commands/preflight.md"',
702
+ );
703
+ });
704
+
705
+ it('excludes entries outside dist/', async () => {
706
+ const TAG = '1.0.0';
707
+ const tarExtractor = makeTarExtractor(FAKE_TARBALL_ENTRIES);
708
+
709
+ const fileMap = await defaultGithubFetcher(REPO, TAG, TOKEN, {
710
+ httpClient: makeHappyHttpClient(TAG),
711
+ tarExtractor,
712
+ });
713
+
714
+ assert.ok(
715
+ !('README.md' in fileMap),
716
+ 'README.md (outside dist/) must not appear in FileMap',
717
+ );
718
+ });
719
+
720
+ it('skips directory entries (empty trailing slash)', async () => {
721
+ const TAG = '1.0.0';
722
+ const tarExtractor = makeTarExtractor(FAKE_TARBALL_ENTRIES);
723
+
724
+ const fileMap = await defaultGithubFetcher(REPO, TAG, TOKEN, {
725
+ httpClient: makeHappyHttpClient(TAG),
726
+ tarExtractor,
727
+ });
728
+
729
+ for (const key of Object.keys(fileMap)) {
730
+ assert.ok(
731
+ !key.endsWith('/'),
732
+ `FileMap must not contain directory entries (keys ending with "/"), got: ${key}`,
733
+ );
734
+ }
735
+ });
736
+
737
+ it('returns the correct file content', async () => {
738
+ const TAG = '1.0.0';
739
+ const tarExtractor = makeTarExtractor(FAKE_TARBALL_ENTRIES);
740
+
741
+ const fileMap = await defaultGithubFetcher(REPO, TAG, TOKEN, {
742
+ httpClient: makeHappyHttpClient(TAG),
743
+ tarExtractor,
744
+ });
745
+
746
+ assert.equal(
747
+ fileMap['claude/agents/define.md'],
748
+ FAKE_TARBALL_ENTRIES['dist/claude/agents/define.md'],
749
+ );
750
+ });
751
+ });
752
+
753
+ // ---------------------------------------------------------------------------
754
+ // defaultGithubFetcher — sha256 sidecar verification (optional but preferred)
755
+ // ---------------------------------------------------------------------------
756
+
757
+ describe('defaultGithubFetcher — sha256 sidecar verification', () => {
758
+ it('throws a mismatch error when sidecar hash does not match downloaded content', async () => {
759
+ const TAG = '1.0.0';
760
+
761
+ const tarExtractor = makeTarExtractor(FAKE_TARBALL_ENTRIES);
762
+
763
+ // Provide both a .tar.gz and a .sha256 asset, but give a wrong hash.
764
+ const httpClient = async (url, _init) => {
765
+ if (url.includes(`/releases/tags/v${TAG}`)) {
766
+ return {
767
+ ok: true,
768
+ status: 200,
769
+ async json() {
770
+ return makeReleaseBody({
771
+ tagName: `v${TAG}`,
772
+ assets: [
773
+ makeAsset({ name: `dist-v${TAG}.tar.gz`, url: 'https://example.com/assets/dist.tar.gz' }),
774
+ makeAsset({ name: `dist-v${TAG}.tar.gz.sha256`, url: 'https://example.com/assets/dist.sha256' }),
775
+ ],
776
+ });
777
+ },
778
+ headers: { get: () => null },
779
+ };
780
+ }
781
+ if (url.includes('assets/dist.tar.gz')) {
782
+ return {
783
+ ok: true,
784
+ status: 200,
785
+ // Return 8 zero bytes as the tarball.
786
+ async arrayBuffer() { return new ArrayBuffer(8); },
787
+ headers: { get: () => null },
788
+ };
789
+ }
790
+ if (url.includes('assets/dist.sha256')) {
791
+ return {
792
+ ok: true,
793
+ status: 200,
794
+ async arrayBuffer() {
795
+ // A deliberately wrong sha256 hash (64 hex chars).
796
+ const badHash = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
797
+ const enc = new TextEncoder();
798
+ const buf = enc.encode(badHash);
799
+ return buf.buffer;
800
+ },
801
+ headers: { get: () => null },
802
+ };
803
+ }
804
+ throw new Error(`stubHttpClient: unexpected URL ${url}`);
805
+ };
806
+
807
+ let thrown = null;
808
+ try {
809
+ await defaultGithubFetcher(REPO, TAG, TOKEN, { httpClient, tarExtractor });
810
+ } catch (err) {
811
+ thrown = err;
812
+ }
813
+
814
+ // If sha256 verification is implemented, it must throw here.
815
+ // The test may be skipped by the implementation returning the FileMap
816
+ // without verification only if the sidecar is genuinely absent — since
817
+ // it IS present in this test, a mismatch must throw.
818
+ assert.ok(
819
+ thrown !== null,
820
+ 'must throw when sha256 sidecar is present and hash does not match',
821
+ );
822
+ assert.ok(
823
+ thrown.message.toLowerCase().includes('sha256') ||
824
+ thrown.message.toLowerCase().includes('checksum') ||
825
+ thrown.message.toLowerCase().includes('hash') ||
826
+ thrown.message.toLowerCase().includes('mismatch'),
827
+ `error message must mention sha256/checksum/hash/mismatch, got: ${thrown.message}`,
828
+ );
829
+ });
830
+ });
831
+
832
+ // ---------------------------------------------------------------------------
833
+ // defaultGithubFetcher — error handling
834
+ // ---------------------------------------------------------------------------
835
+
836
+ describe('defaultGithubFetcher — error handling', () => {
837
+ it('throws on 404 with message mentioning the missing tag', async () => {
838
+ const TAG = '9.9.9';
839
+
840
+ const httpClient = makeHttpClient({
841
+ [`/releases/tags/v${TAG}`]: {
842
+ status: 404,
843
+ ok: false,
844
+ jsonBody: { message: 'Not Found' },
845
+ },
846
+ });
847
+
848
+ const tarExtractor = makeTarExtractor({});
849
+
850
+ let thrown = null;
851
+ try {
852
+ await defaultGithubFetcher(REPO, TAG, TOKEN, { httpClient, tarExtractor });
853
+ } catch (err) {
854
+ thrown = err;
855
+ }
856
+
857
+ assert.ok(thrown !== null, 'must throw on 404');
858
+ assert.ok(
859
+ thrown.message.includes(TAG) || thrown.message.includes(`v${TAG}`),
860
+ `error message must mention the missing tag, got: ${thrown.message}`,
861
+ );
862
+ });
863
+
864
+ it('404 yields exitCode 2', async () => {
865
+ const TAG = '9.9.9';
866
+
867
+ const httpClient = makeHttpClient({
868
+ [`/releases/tags/v${TAG}`]: {
869
+ status: 404,
870
+ ok: false,
871
+ jsonBody: { message: 'Not Found' },
872
+ },
873
+ });
874
+
875
+ const tarExtractor = makeTarExtractor({});
876
+
877
+ let exitCode = null;
878
+ try {
879
+ await defaultGithubFetcher(REPO, TAG, TOKEN, { httpClient, tarExtractor });
880
+ } catch (err) {
881
+ exitCode = err.exitCode;
882
+ }
883
+
884
+ assert.equal(exitCode, 2, '404 must yield exitCode 2');
885
+ });
886
+
887
+ it('404 does NOT use STUB_NOT_IMPLEMENTED error code', async () => {
888
+ const TAG = '9.9.9';
889
+
890
+ const httpClient = makeHttpClient({
891
+ [`/releases/tags/v${TAG}`]: {
892
+ status: 404,
893
+ ok: false,
894
+ jsonBody: { message: 'Not Found' },
895
+ },
896
+ });
897
+
898
+ const tarExtractor = makeTarExtractor({});
899
+
900
+ let code = null;
901
+ try {
902
+ await defaultGithubFetcher(REPO, TAG, TOKEN, { httpClient, tarExtractor });
903
+ } catch (err) {
904
+ code = err.code;
905
+ }
906
+
907
+ assert.ok(
908
+ code !== STUB_NOT_IMPLEMENTED,
909
+ `404 error must not use STUB_NOT_IMPLEMENTED code; got: ${code}`,
910
+ );
911
+ });
912
+
913
+ it('throws AuthError-shaped error on 401 (exitCode 2, mentions GITHUB_TOKEN)', async () => {
914
+ const TAG = '1.0.0';
915
+
916
+ const httpClient = makeHttpClient({
917
+ [`/releases/tags/v${TAG}`]: {
918
+ status: 401,
919
+ ok: false,
920
+ jsonBody: { message: 'Requires authentication' },
921
+ },
922
+ });
923
+
924
+ const tarExtractor = makeTarExtractor({});
925
+
926
+ let thrown = null;
927
+ try {
928
+ await defaultGithubFetcher(REPO, TAG, TOKEN, { httpClient, tarExtractor });
929
+ } catch (err) {
930
+ thrown = err;
931
+ }
932
+
933
+ assert.ok(thrown !== null, 'must throw on 401');
934
+ assert.equal(thrown.exitCode, 2, 'exitCode must be 2 on 401');
935
+ assert.ok(
936
+ thrown.message.includes('GITHUB_TOKEN'),
937
+ `error message must mention GITHUB_TOKEN on 401, got: ${thrown.message}`,
938
+ );
939
+ });
940
+
941
+ it('throws AuthError-shaped error on 403 (mentions gh auth login)', async () => {
942
+ const TAG = '1.0.0';
943
+
944
+ const httpClient = makeHttpClient({
945
+ [`/releases/tags/v${TAG}`]: {
946
+ status: 403,
947
+ ok: false,
948
+ jsonBody: { message: 'Forbidden' },
949
+ },
950
+ });
951
+
952
+ const tarExtractor = makeTarExtractor({});
953
+
954
+ let thrown = null;
955
+ try {
956
+ await defaultGithubFetcher(REPO, TAG, TOKEN, { httpClient, tarExtractor });
957
+ } catch (err) {
958
+ thrown = err;
959
+ }
960
+
961
+ assert.ok(thrown !== null, 'must throw on 403');
962
+ assert.ok(
963
+ thrown.message.includes('gh auth login'),
964
+ `error message must mention 'gh auth login' on 403, got: ${thrown.message}`,
965
+ );
966
+ });
967
+
968
+ it('token never appears in error messages (404 case)', async () => {
969
+ const TAG = '9.9.9';
970
+
971
+ const httpClient = makeHttpClient({
972
+ [`/releases/tags/v${TAG}`]: {
973
+ status: 404,
974
+ ok: false,
975
+ jsonBody: { message: 'Not Found' },
976
+ },
977
+ });
978
+
979
+ const tarExtractor = makeTarExtractor({});
980
+
981
+ let message = '';
982
+ try {
983
+ await defaultGithubFetcher(REPO, TAG, TOKEN, { httpClient, tarExtractor });
984
+ } catch (err) {
985
+ message = err.message;
986
+ }
987
+
988
+ assert.ok(
989
+ !message.includes(TOKEN),
990
+ `error message must not contain the token, got: ${message}`,
991
+ );
992
+ });
993
+
994
+ it('token never appears in error messages (401 case)', async () => {
995
+ const TAG = '1.0.0';
996
+
997
+ const httpClient = makeHttpClient({
998
+ [`/releases/tags/v${TAG}`]: {
999
+ status: 401,
1000
+ ok: false,
1001
+ jsonBody: { message: 'Requires authentication' },
1002
+ },
1003
+ });
1004
+
1005
+ const tarExtractor = makeTarExtractor({});
1006
+
1007
+ let message = '';
1008
+ try {
1009
+ await defaultGithubFetcher(REPO, TAG, TOKEN, { httpClient, tarExtractor });
1010
+ } catch (err) {
1011
+ message = err.message;
1012
+ }
1013
+
1014
+ assert.ok(
1015
+ !message.includes(TOKEN),
1016
+ `error message must not contain the token, got: ${message}`,
1017
+ );
1018
+ });
1019
+ });
1020
+
1021
+ // ---------------------------------------------------------------------------
1022
+ // defaultGithubFetcher — redirect handling (auth stripping on hop 2)
1023
+ // ---------------------------------------------------------------------------
1024
+
1025
+ describe('defaultGithubFetcher — redirect handling', () => {
1026
+ it('strips Authorization header when following 302 from GitHub asset URL to S3', async () => {
1027
+ const TAG = '1.0.0';
1028
+ const S3_URL = 'https://s3.example/presigned/dist.tar.gz?sig=abc';
1029
+ const calls = [];
1030
+
1031
+ const tarExtractor = makeTarExtractor(FAKE_TARBALL_ENTRIES);
1032
+
1033
+ const httpClient = async (url, init) => {
1034
+ calls.push({ url, init });
1035
+ if (url.includes(`/releases/tags/v${TAG}`)) {
1036
+ return {
1037
+ ok: true,
1038
+ status: 200,
1039
+ async json() {
1040
+ return makeReleaseBody({
1041
+ tagName: `v${TAG}`,
1042
+ assets: [
1043
+ makeAsset({ name: `dist-v${TAG}.tar.gz`, url: 'https://api.github.com/assets/123' }),
1044
+ ],
1045
+ });
1046
+ },
1047
+ headers: { get: () => null },
1048
+ };
1049
+ }
1050
+ if (url === 'https://api.github.com/assets/123') {
1051
+ return {
1052
+ ok: false,
1053
+ status: 302,
1054
+ async arrayBuffer() { return new ArrayBuffer(0); },
1055
+ headers: {
1056
+ get(name) {
1057
+ return name.toLowerCase() === 'location' ? S3_URL : null;
1058
+ },
1059
+ },
1060
+ };
1061
+ }
1062
+ if (url === S3_URL) {
1063
+ return {
1064
+ ok: true,
1065
+ status: 200,
1066
+ async arrayBuffer() { return new ArrayBuffer(8); },
1067
+ headers: { get: () => null },
1068
+ };
1069
+ }
1070
+ throw new Error(`stubHttpClient: unexpected URL ${url}`);
1071
+ };
1072
+
1073
+ await defaultGithubFetcher(REPO, TAG, TOKEN, { httpClient, tarExtractor });
1074
+
1075
+ const s3Call = calls.find((c) => c.url === S3_URL);
1076
+ assert.ok(s3Call, 'must issue a second request to the S3 redirect target');
1077
+
1078
+ const s3AuthHeader =
1079
+ (s3Call.init?.headers?.Authorization) ??
1080
+ (s3Call.init?.headers?.authorization);
1081
+ assert.equal(
1082
+ s3AuthHeader,
1083
+ undefined,
1084
+ `redirect hop must NOT include Authorization header, got: ${s3AuthHeader}`,
1085
+ );
1086
+
1087
+ const s3Accept =
1088
+ (s3Call.init?.headers?.Accept) ??
1089
+ (s3Call.init?.headers?.accept);
1090
+ assert.equal(
1091
+ s3Accept,
1092
+ 'application/octet-stream',
1093
+ `redirect hop must use Accept: application/octet-stream, got: ${s3Accept}`,
1094
+ );
1095
+
1096
+ const serialized = JSON.stringify(s3Call.init ?? {});
1097
+ assert.ok(
1098
+ !serialized.includes(TOKEN),
1099
+ `token must not appear in redirect hop init, got: ${serialized}`,
1100
+ );
1101
+
1102
+ const firstAssetCall = calls.find((c) => c.url === 'https://api.github.com/assets/123');
1103
+ assert.equal(
1104
+ firstAssetCall.init?.redirect,
1105
+ 'manual',
1106
+ 'first asset hop must use redirect: manual',
1107
+ );
1108
+ });
1109
+
1110
+ it('strips Authorization header when following 302 from sidecar URL to S3', async () => {
1111
+ const TAG = '1.0.0';
1112
+ const SIDECAR_S3 = 'https://s3.example/presigned/dist.sha256?sig=def';
1113
+ const TARBALL_S3 = 'https://s3.example/presigned/dist.tar.gz?sig=abc';
1114
+ const calls = [];
1115
+
1116
+ const tarExtractor = makeTarExtractor(FAKE_TARBALL_ENTRIES);
1117
+
1118
+ const { createHash } = await import('node:crypto');
1119
+ const correctHash = createHash('sha256').update(Buffer.alloc(8)).digest('hex');
1120
+
1121
+ const httpClient = async (url, init) => {
1122
+ calls.push({ url, init });
1123
+ if (url.includes(`/releases/tags/v${TAG}`)) {
1124
+ return {
1125
+ ok: true,
1126
+ status: 200,
1127
+ async json() {
1128
+ return makeReleaseBody({
1129
+ tagName: `v${TAG}`,
1130
+ assets: [
1131
+ makeAsset({ name: `dist-v${TAG}.tar.gz`, url: 'https://api.github.com/assets/tar' }),
1132
+ makeAsset({ name: `dist-v${TAG}.tar.gz.sha256`, url: 'https://api.github.com/assets/sha' }),
1133
+ ],
1134
+ });
1135
+ },
1136
+ headers: { get: () => null },
1137
+ };
1138
+ }
1139
+ if (url === 'https://api.github.com/assets/tar') {
1140
+ return {
1141
+ ok: false,
1142
+ status: 302,
1143
+ async arrayBuffer() { return new ArrayBuffer(0); },
1144
+ headers: {
1145
+ get(name) {
1146
+ return name.toLowerCase() === 'location' ? TARBALL_S3 : null;
1147
+ },
1148
+ },
1149
+ };
1150
+ }
1151
+ if (url === TARBALL_S3) {
1152
+ return {
1153
+ ok: true,
1154
+ status: 200,
1155
+ async arrayBuffer() { return new ArrayBuffer(8); },
1156
+ headers: { get: () => null },
1157
+ };
1158
+ }
1159
+ if (url === 'https://api.github.com/assets/sha') {
1160
+ return {
1161
+ ok: false,
1162
+ status: 302,
1163
+ async arrayBuffer() { return new ArrayBuffer(0); },
1164
+ headers: {
1165
+ get(name) {
1166
+ return name.toLowerCase() === 'location' ? SIDECAR_S3 : null;
1167
+ },
1168
+ },
1169
+ };
1170
+ }
1171
+ if (url === SIDECAR_S3) {
1172
+ const enc = new TextEncoder();
1173
+ const buf = enc.encode(`${correctHash} dist-v${TAG}.tar.gz\n`);
1174
+ return {
1175
+ ok: true,
1176
+ status: 200,
1177
+ async arrayBuffer() { return buf.buffer; },
1178
+ headers: { get: () => null },
1179
+ };
1180
+ }
1181
+ throw new Error(`stubHttpClient: unexpected URL ${url}`);
1182
+ };
1183
+
1184
+ await defaultGithubFetcher(REPO, TAG, TOKEN, { httpClient, tarExtractor });
1185
+
1186
+ const sidecarS3Call = calls.find((c) => c.url === SIDECAR_S3);
1187
+ assert.ok(sidecarS3Call, 'must issue a second request to the sidecar S3 URL');
1188
+
1189
+ const authHeader =
1190
+ (sidecarS3Call.init?.headers?.Authorization) ??
1191
+ (sidecarS3Call.init?.headers?.authorization);
1192
+ assert.equal(
1193
+ authHeader,
1194
+ undefined,
1195
+ `sidecar redirect hop must NOT include Authorization header, got: ${authHeader}`,
1196
+ );
1197
+
1198
+ const serialized = JSON.stringify(sidecarS3Call.init ?? {});
1199
+ assert.ok(
1200
+ !serialized.includes(TOKEN),
1201
+ `token must not appear in sidecar redirect hop init, got: ${serialized}`,
1202
+ );
1203
+ });
1204
+ });
1205
+
1206
+ // ---------------------------------------------------------------------------
1207
+ // defaultTarExtractor — real extraction from an in-memory .tar.gz fixture
1208
+ // ---------------------------------------------------------------------------
1209
+
1210
+ describe('defaultTarExtractor — real extraction', () => {
1211
+ it('extracts a real .tar.gz buffer into a FileMap, skipping directories', async () => {
1212
+ const tmp = await mkdtemp(join(tmpdir(), 'tar-fixture-'));
1213
+ try {
1214
+ await writeFile(join(tmp, 'a.md'), '# alpha\n', 'utf8');
1215
+ await mkdir(join(tmp, 'nested'), { recursive: true });
1216
+ await writeFile(join(tmp, 'nested', 'b.md'), '# beta\n', 'utf8');
1217
+
1218
+ const chunks = [];
1219
+ const stream = tar.create(
1220
+ { gzip: true, cwd: tmp, portable: true },
1221
+ ['a.md', 'nested/b.md'],
1222
+ );
1223
+ for await (const chunk of stream) {
1224
+ chunks.push(chunk);
1225
+ }
1226
+ const buffer = Buffer.concat(chunks);
1227
+
1228
+ const entries = await defaultTarExtractor(buffer);
1229
+
1230
+ assert.equal(entries['a.md'], '# alpha\n', 'a.md must be present with content');
1231
+ assert.equal(
1232
+ entries['nested/b.md'],
1233
+ '# beta\n',
1234
+ 'nested/b.md must be present with content',
1235
+ );
1236
+
1237
+ for (const key of Object.keys(entries)) {
1238
+ assert.ok(
1239
+ !key.endsWith('/'),
1240
+ `extractor must skip directory entries, got: ${key}`,
1241
+ );
1242
+ }
1243
+ } finally {
1244
+ await rm(tmp, { recursive: true, force: true });
1245
+ }
1246
+ });
1247
+ });