@skilltap/core 0.5.4 → 0.5.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skilltap/core",
3
- "version": "0.5.4",
3
+ "version": "0.5.5",
4
4
  "description": "Core library for skilltap — agent skill management",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -4,6 +4,17 @@ import { join } from "node:path";
4
4
  import { makeTmpDir, removeTmpDir } from "@skilltap/test-utils";
5
5
  import { runDoctor } from "./doctor";
6
6
 
7
+ const SKILL_RECORD = {
8
+ description: "",
9
+ ref: null,
10
+ sha: null,
11
+ path: null,
12
+ tap: null,
13
+ also: [],
14
+ installedAt: "2024-01-01T00:00:00.000Z",
15
+ updatedAt: "2024-01-01T00:00:00.000Z",
16
+ };
17
+
7
18
  let homeDir: string;
8
19
  let configDir: string;
9
20
 
@@ -508,3 +519,191 @@ describe("runDoctor", () => {
508
519
  expect(called).toContain("npm");
509
520
  });
510
521
  });
522
+
523
+ // ─── Per-project installed.json ───────────────────────────────────────────────
524
+
525
+ describe("per-project: checkInstalled", () => {
526
+ let projectDir: string;
527
+
528
+ beforeEach(async () => {
529
+ projectDir = await makeTmpDir();
530
+ });
531
+
532
+ afterEach(async () => {
533
+ await removeTmpDir(projectDir);
534
+ });
535
+
536
+ test("counts project skills separately in detail", async () => {
537
+ await mkdir(join(projectDir, ".agents"), { recursive: true });
538
+ await writeFile(
539
+ join(projectDir, ".agents", "installed.json"),
540
+ JSON.stringify({
541
+ version: 1,
542
+ skills: [
543
+ {
544
+ ...SKILL_RECORD,
545
+ name: "proj-skill",
546
+ repo: "https://github.com/example/proj-skill",
547
+ scope: "project",
548
+ },
549
+ ],
550
+ }, null, 2),
551
+ );
552
+
553
+ const result = await runDoctor({ projectRoot: projectDir });
554
+ const check = result.checks.find((c) => c.name === "installed")!;
555
+ expect(check.status).toBe("pass");
556
+ expect(check.detail).toMatch(/1\s+skill/);
557
+ expect(check.detail).toContain("project");
558
+ });
559
+
560
+ test("fails when project installed.json is corrupt", async () => {
561
+ await mkdir(join(projectDir, ".agents"), { recursive: true });
562
+ await writeFile(join(projectDir, ".agents", "installed.json"), "bad json {{{");
563
+
564
+ const result = await runDoctor({ projectRoot: projectDir });
565
+ const check = result.checks.find((c) => c.name === "installed")!;
566
+ expect(check.status).toBe("fail");
567
+ expect(check.issues?.some((i) => i.message.includes("corrupt"))).toBe(true);
568
+ });
569
+
570
+ test("merges global and project skills", async () => {
571
+ const skilltapDir = join(configDir, "skilltap");
572
+ await mkdir(skilltapDir, { recursive: true });
573
+ await writeFile(
574
+ join(skilltapDir, "installed.json"),
575
+ JSON.stringify({
576
+ version: 1,
577
+ skills: [
578
+ {
579
+ ...SKILL_RECORD,
580
+ name: "global-skill",
581
+ repo: "https://github.com/example/global-skill",
582
+ scope: "global",
583
+ },
584
+ ],
585
+ }, null, 2),
586
+ );
587
+
588
+ await mkdir(join(projectDir, ".agents"), { recursive: true });
589
+ await writeFile(
590
+ join(projectDir, ".agents", "installed.json"),
591
+ JSON.stringify({
592
+ version: 1,
593
+ skills: [
594
+ {
595
+ ...SKILL_RECORD,
596
+ name: "proj-skill",
597
+ repo: "https://github.com/example/proj-skill",
598
+ scope: "project",
599
+ },
600
+ ],
601
+ }, null, 2),
602
+ );
603
+
604
+ const result = await runDoctor({ projectRoot: projectDir });
605
+ const check = result.checks.find((c) => c.name === "installed")!;
606
+ expect(check.status).toBe("pass");
607
+ expect(check.detail).toContain("2");
608
+ expect(check.detail).toContain("1 global");
609
+ expect(check.detail).toContain("1 project");
610
+ });
611
+ });
612
+
613
+ describe("per-project: checkSkills", () => {
614
+ let projectDir: string;
615
+
616
+ beforeEach(async () => {
617
+ projectDir = await makeTmpDir();
618
+ });
619
+
620
+ afterEach(async () => {
621
+ await removeTmpDir(projectDir);
622
+ });
623
+
624
+ test("warns when project skill directory is missing", async () => {
625
+ await mkdir(join(projectDir, ".agents"), { recursive: true });
626
+ await writeFile(
627
+ join(projectDir, ".agents", "installed.json"),
628
+ JSON.stringify({
629
+ version: 1,
630
+ skills: [
631
+ {
632
+ ...SKILL_RECORD,
633
+ name: "missing-proj-skill",
634
+ repo: "https://github.com/example/skill",
635
+ scope: "project",
636
+ },
637
+ ],
638
+ }, null, 2),
639
+ );
640
+
641
+ const result = await runDoctor({ projectRoot: projectDir });
642
+ const check = result.checks.find((c) => c.name === "skills")!;
643
+ expect(check.status).toBe("warn");
644
+ expect(
645
+ check.issues?.some((i) => i.message.includes("missing-proj-skill")),
646
+ ).toBe(true);
647
+ expect(
648
+ check.issues?.find((i) => i.message.includes("missing-proj-skill"))?.message,
649
+ ).toContain(join(projectDir, ".agents", "skills", "missing-proj-skill"));
650
+ });
651
+
652
+ test("passes when project skill directory exists", async () => {
653
+ await mkdir(join(projectDir, ".agents", "skills", "my-proj-skill"), { recursive: true });
654
+ await writeFile(
655
+ join(projectDir, ".agents", "installed.json"),
656
+ JSON.stringify({
657
+ version: 1,
658
+ skills: [
659
+ {
660
+ ...SKILL_RECORD,
661
+ name: "my-proj-skill",
662
+ repo: "https://github.com/example/skill",
663
+ scope: "project",
664
+ },
665
+ ],
666
+ }, null, 2),
667
+ );
668
+
669
+ const result = await runDoctor({ projectRoot: projectDir });
670
+ const check = result.checks.find((c) => c.name === "skills")!;
671
+ expect(check.status).toBe("pass");
672
+ });
673
+
674
+ test("detects orphan directories in project skills dir", async () => {
675
+ await mkdir(join(projectDir, ".agents", "skills", "orphan"), { recursive: true });
676
+ await writeFile(
677
+ join(projectDir, ".agents", "installed.json"),
678
+ JSON.stringify({
679
+ version: 1,
680
+ skills: [
681
+ {
682
+ ...SKILL_RECORD,
683
+ name: "tracked-skill",
684
+ repo: "https://github.com/example/skill",
685
+ scope: "project",
686
+ },
687
+ ],
688
+ }, null, 2),
689
+ );
690
+
691
+ const result = await runDoctor({ projectRoot: projectDir });
692
+ const check = result.checks.find((c) => c.name === "skills")!;
693
+ expect(check.status).toBe("warn");
694
+ expect(check.issues?.some((i) => i.message.includes("orphan"))).toBe(true);
695
+ });
696
+
697
+ test("does not scan project orphans when no project skills tracked", async () => {
698
+ // Project has a .agents/skills/ dir but no installed.json tracking project skills
699
+ await mkdir(join(projectDir, ".agents", "skills", "untracked"), { recursive: true });
700
+
701
+ const result = await runDoctor({ projectRoot: projectDir });
702
+ const check = result.checks.find((c) => c.name === "skills")!;
703
+ // No project skills tracked → no orphan scan → only global check runs
704
+ const orphanIssues = check.issues?.filter((i) =>
705
+ i.message.includes(join(projectDir, ".agents")),
706
+ ) ?? [];
707
+ expect(orphanIssues).toHaveLength(0);
708
+ });
709
+ });
package/src/doctor.ts CHANGED
@@ -49,6 +49,7 @@ export interface DoctorResult {
49
49
 
50
50
  export interface DoctorOptions {
51
51
  fix?: boolean;
52
+ projectRoot?: string;
52
53
  onCheck?: (check: DoctorCheck) => void;
53
54
  }
54
55
 
@@ -257,96 +258,105 @@ async function checkDirs(): Promise<DoctorCheck> {
257
258
 
258
259
  // ─── Check 4: installed.json ──────────────────────────────────────────────────
259
260
 
260
- async function checkInstalled(): Promise<{
261
- check: DoctorCheck;
262
- installed: InstalledJson | null;
263
- }> {
264
- const installedFile = join(getConfigDir(), "installed.json");
265
-
266
- if (!(await fileExists(installedFile))) {
267
- return {
268
- check: {
269
- name: "installed",
270
- status: "pass",
271
- detail: "0 skills (no installed.json)",
272
- },
273
- installed: { version: 1, skills: [] },
274
- };
275
- }
261
+ async function readInstalledFile(
262
+ file: string,
263
+ label: string,
264
+ issues: DoctorIssue[],
265
+ ): Promise<InstalledJson | null> {
266
+ if (!(await fileExists(file))) return null;
276
267
 
277
268
  let raw: unknown;
278
269
  try {
279
- raw = await Bun.file(installedFile).json();
270
+ raw = await Bun.file(file).json();
280
271
  } catch (e) {
281
- const backupFile = `${installedFile}.bak`;
282
- return {
283
- check: {
284
- name: "installed",
285
- status: "fail",
286
- issues: [
287
- {
288
- message: `installed.json is corrupt: ${e}`,
289
- fixable: true,
290
- fixDescription: `backed up to installed.json.bak, created fresh`,
291
- fix: async () => {
292
- await copyFile(installedFile, backupFile).catch(() => {});
293
- await writeFile(
294
- installedFile,
295
- JSON.stringify({ version: 1, skills: [] }, null, 2),
296
- );
297
- },
298
- },
299
- ],
272
+ const backupFile = `${file}.bak`;
273
+ const backupName = `${label}.bak`;
274
+ issues.push({
275
+ message: `${label} is corrupt: ${e}`,
276
+ fixable: true,
277
+ fixDescription: `backed up to ${backupName}, created fresh`,
278
+ fix: async () => {
279
+ await copyFile(file, backupFile).catch(() => {});
280
+ await writeFile(file, JSON.stringify({ version: 1, skills: [] }, null, 2));
300
281
  },
301
- installed: null,
302
- };
282
+ });
283
+ return null;
303
284
  }
304
285
 
305
286
  const result = InstalledJsonSchema.safeParse(raw);
306
287
  if (!result.success) {
307
- const backupFile = `${installedFile}.bak`;
308
- return {
309
- check: {
310
- name: "installed",
311
- status: "fail",
312
- issues: [
313
- {
314
- message: `installed.json is invalid: ${z.prettifyError(result.error)}`,
315
- fixable: true,
316
- fixDescription: `backed up to installed.json.bak, created fresh`,
317
- fix: async () => {
318
- await copyFile(installedFile, backupFile).catch(() => {});
319
- await writeFile(
320
- installedFile,
321
- JSON.stringify({ version: 1, skills: [] }, null, 2),
322
- );
323
- },
324
- },
325
- ],
288
+ const backupFile = `${file}.bak`;
289
+ const backupName = `${label}.bak`;
290
+ issues.push({
291
+ message: `${label} is invalid: ${z.prettifyError(result.error)}`,
292
+ fixable: true,
293
+ fixDescription: `backed up to ${backupName}, created fresh`,
294
+ fix: async () => {
295
+ await copyFile(file, backupFile).catch(() => {});
296
+ await writeFile(file, JSON.stringify({ version: 1, skills: [] }, null, 2));
326
297
  },
327
- installed: null,
328
- };
298
+ });
299
+ return null;
329
300
  }
330
301
 
331
- const { skills } = result.data;
332
- return {
333
- check: {
334
- name: "installed",
335
- status: "pass",
336
- detail: `${skills.length} skill${skills.length === 1 ? "" : "s"}`,
337
- },
338
- installed: result.data,
339
- };
302
+ return result.data;
303
+ }
304
+
305
+ async function checkInstalled(projectRoot?: string): Promise<{
306
+ check: DoctorCheck;
307
+ installed: InstalledJson | null;
308
+ }> {
309
+ const globalFile = join(getConfigDir(), "installed.json");
310
+ const issues: DoctorIssue[] = [];
311
+
312
+ const globalInstalled = await readInstalledFile(globalFile, "installed.json", issues);
313
+ const projectInstalled = projectRoot
314
+ ? await readInstalledFile(
315
+ join(projectRoot, ".agents", "installed.json"),
316
+ ".agents/installed.json",
317
+ issues,
318
+ )
319
+ : null;
320
+
321
+ const allSkills = [
322
+ ...(globalInstalled?.skills ?? []),
323
+ ...(projectInstalled?.skills ?? []),
324
+ ];
325
+ const merged: InstalledJson = { version: 1 as const, skills: allSkills };
326
+
327
+ if (issues.length > 0) {
328
+ return { check: { name: "installed", status: "fail", issues }, installed: merged };
329
+ }
330
+
331
+ const globalCount = globalInstalled?.skills.length ?? 0;
332
+ const projectCount = projectInstalled?.skills.length ?? 0;
333
+ const total = allSkills.length;
334
+
335
+ let detail: string;
336
+ if (!globalInstalled && !projectInstalled) {
337
+ detail = "0 skills (no installed.json)";
338
+ } else if (projectInstalled !== null) {
339
+ detail = `${total} skill${total === 1 ? "" : "s"} (${globalCount} global, ${projectCount} project)`;
340
+ } else {
341
+ detail = `${total} skill${total === 1 ? "" : "s"}`;
342
+ }
343
+
344
+ return { check: { name: "installed", status: "pass", detail }, installed: merged };
340
345
  }
341
346
 
342
347
  // ─── Check 5: Skills Integrity ────────────────────────────────────────────────
343
348
 
344
- async function checkSkills(installed: InstalledJson): Promise<DoctorCheck> {
349
+ async function checkSkills(installed: InstalledJson, projectRoot?: string): Promise<DoctorCheck> {
345
350
  const issues: DoctorIssue[] = [];
346
- const trackedNames = new Set<string>();
351
+ const globalTracked = new Set<string>();
352
+ const projectTracked = new Set<string>();
347
353
 
348
354
  for (const skill of installed.skills) {
349
- trackedNames.add(skill.name);
355
+ if (skill.scope === "project") {
356
+ projectTracked.add(skill.name);
357
+ } else if (skill.scope !== "linked") {
358
+ globalTracked.add(skill.name);
359
+ }
350
360
 
351
361
  if (skill.scope === "linked") {
352
362
  if (skill.path && !(await resolvedDirExists(skill.path))) {
@@ -368,35 +378,42 @@ async function checkSkills(installed: InstalledJson): Promise<DoctorCheck> {
368
378
  continue;
369
379
  }
370
380
 
371
- const installDir = skillInstallDir(skill.name, "global");
381
+ const isProject = skill.scope === "project" && !!projectRoot;
382
+ const installDir = isProject
383
+ ? skillInstallDir(skill.name, "project", projectRoot)
384
+ : skillInstallDir(skill.name, "global");
385
+
372
386
  if (!(await resolvedDirExists(installDir))) {
373
387
  const skillName = skill.name;
388
+ const skillScope = skill.scope as "global" | "project";
389
+ const capturedRoot = projectRoot;
374
390
  issues.push({
375
391
  message: `${skillName}: recorded in installed.json but directory missing at ${installDir}`,
376
392
  fixable: true,
377
393
  fixDescription: `removed from installed.json`,
378
394
  fix: async () => {
379
- const r = await loadInstalled();
395
+ const effectiveRoot = skillScope === "project" ? capturedRoot : undefined;
396
+ const r = await loadInstalled(effectiveRoot);
380
397
  if (!r.ok) return;
381
- await saveInstalled({
382
- ...r.value,
383
- skills: r.value.skills.filter((s) => s.name !== skillName),
384
- });
398
+ await saveInstalled(
399
+ { ...r.value, skills: r.value.skills.filter((s) => s.name !== skillName) },
400
+ effectiveRoot,
401
+ );
385
402
  },
386
403
  });
387
404
  }
388
405
  }
389
406
 
390
- // Check for orphan directories
391
- const skillsDir = join(globalBase(), ".agents", "skills");
392
- if (await resolvedDirExists(skillsDir)) {
407
+ // Global orphan scan
408
+ const globalSkillsDir = join(globalBase(), ".agents", "skills");
409
+ if (await resolvedDirExists(globalSkillsDir)) {
393
410
  try {
394
- const entries = await readdir(skillsDir, { withFileTypes: true });
411
+ const entries = await readdir(globalSkillsDir, { withFileTypes: true });
395
412
  for (const entry of entries) {
396
413
  if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
397
- if (!trackedNames.has(entry.name)) {
414
+ if (!globalTracked.has(entry.name)) {
398
415
  issues.push({
399
- message: `${entry.name}: directory exists at ${join(skillsDir, entry.name)} but not tracked in installed.json`,
416
+ message: `${entry.name}: directory exists at ${join(globalSkillsDir, entry.name)} but not tracked in installed.json`,
400
417
  fixable: false,
401
418
  });
402
419
  }
@@ -406,6 +423,27 @@ async function checkSkills(installed: InstalledJson): Promise<DoctorCheck> {
406
423
  }
407
424
  }
408
425
 
426
+ // Project orphan scan (only when there are project-tracked skills)
427
+ if (projectRoot && projectTracked.size > 0) {
428
+ const projectSkillsDir = join(projectRoot, ".agents", "skills");
429
+ if (await resolvedDirExists(projectSkillsDir)) {
430
+ try {
431
+ const entries = await readdir(projectSkillsDir, { withFileTypes: true });
432
+ for (const entry of entries) {
433
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
434
+ if (!projectTracked.has(entry.name)) {
435
+ issues.push({
436
+ message: `${entry.name}: directory exists at ${join(projectSkillsDir, entry.name)} but not tracked in installed.json`,
437
+ fixable: false,
438
+ });
439
+ }
440
+ }
441
+ } catch {
442
+ // ignore
443
+ }
444
+ }
445
+ }
446
+
409
447
  const total = installed.skills.length;
410
448
  const missing = issues.filter((i) => i.fixable).length;
411
449
  const onDisk = total - missing;
@@ -427,7 +465,7 @@ async function checkSkills(installed: InstalledJson): Promise<DoctorCheck> {
427
465
 
428
466
  // ─── Check 6: Agent Symlinks ──────────────────────────────────────────────────
429
467
 
430
- async function checkSymlinks(installed: InstalledJson): Promise<DoctorCheck> {
468
+ async function checkSymlinks(installed: InstalledJson, projectRoot?: string): Promise<DoctorCheck> {
431
469
  const issues: DoctorIssue[] = [];
432
470
  let total = 0;
433
471
  let valid = 0;
@@ -435,12 +473,17 @@ async function checkSymlinks(installed: InstalledJson): Promise<DoctorCheck> {
435
473
  for (const skill of installed.skills) {
436
474
  if (skill.also.length === 0) continue;
437
475
 
476
+ const isProject = skill.scope === "project" && !!projectRoot;
477
+ const expectedTarget = isProject
478
+ ? skillInstallDir(skill.name, "project", projectRoot)
479
+ : skillInstallDir(skill.name, "global");
480
+ const base = isProject ? projectRoot! : globalBase();
481
+
438
482
  for (const agent of skill.also) {
439
483
  const agentRelDir = AGENT_PATHS[agent];
440
484
  if (!agentRelDir) continue;
441
485
 
442
- const expectedTarget = skillInstallDir(skill.name, "global");
443
- const linkPath = join(globalBase(), agentRelDir, skill.name);
486
+ const linkPath = join(base, agentRelDir, skill.name);
444
487
  total++;
445
488
 
446
489
  const isLink = await isSymlinkAt(linkPath);
@@ -735,6 +778,7 @@ async function checkNpm(installed: InstalledJson): Promise<DoctorCheck | null> {
735
778
  export async function runDoctor(options?: DoctorOptions): Promise<DoctorResult> {
736
779
  const fix = options?.fix ?? false;
737
780
  const onCheck = options?.onCheck;
781
+ const projectRoot = options?.projectRoot;
738
782
  const checks: DoctorCheck[] = [];
739
783
 
740
784
  async function emit(check: DoctorCheck): Promise<DoctorCheck> {
@@ -766,17 +810,17 @@ export async function runDoctor(options?: DoctorOptions): Promise<DoctorResult>
766
810
  await emit(await checkDirs());
767
811
 
768
812
  // 4. installed.json (provides installed for later checks)
769
- const { check: installedCheck, installed } = await checkInstalled();
813
+ const { check: installedCheck, installed } = await checkInstalled(projectRoot);
770
814
  await emit(installedCheck);
771
815
 
772
816
  const safeInstalled = installed ?? { version: 1 as const, skills: [] };
773
817
  const safeConfig = config ?? ConfigSchema.parse({});
774
818
 
775
819
  // 5. Skills integrity
776
- await emit(await checkSkills(safeInstalled));
820
+ await emit(await checkSkills(safeInstalled, projectRoot));
777
821
 
778
822
  // 6. Agent symlinks
779
- await emit(await checkSymlinks(safeInstalled));
823
+ await emit(await checkSymlinks(safeInstalled, projectRoot));
780
824
 
781
825
  // 7. Taps
782
826
  await emit(await checkTaps(safeConfig));
@@ -0,0 +1,224 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdir } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { makeTmpDir, removeTmpDir } from "@skilltap/test-utils";
5
+ import { checkForUpdate, downloadAndInstall, isCompiledBinary } from "./self-update";
6
+
7
+ type Env = { XDG_CONFIG_HOME?: string };
8
+
9
+ let savedEnv: Env;
10
+ let configDir: string;
11
+ let tmpDir: string;
12
+
13
+ beforeEach(async () => {
14
+ savedEnv = { XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME };
15
+ configDir = await makeTmpDir();
16
+ tmpDir = await makeTmpDir();
17
+ process.env.XDG_CONFIG_HOME = configDir;
18
+ // Ensure the skilltap config subdir exists (writeCache needs it)
19
+ await mkdir(join(configDir, "skilltap"), { recursive: true });
20
+ });
21
+
22
+ afterEach(async () => {
23
+ if (savedEnv.XDG_CONFIG_HOME === undefined) delete process.env.XDG_CONFIG_HOME;
24
+ else process.env.XDG_CONFIG_HOME = savedEnv.XDG_CONFIG_HOME;
25
+ await removeTmpDir(configDir);
26
+ await removeTmpDir(tmpDir);
27
+ });
28
+
29
+ async function writeCache(latest: string, hoursAgo = 0): Promise<void> {
30
+ const checkedAt = new Date(Date.now() - hoursAgo * 3_600_000).toISOString();
31
+ await Bun.write(
32
+ join(configDir, "skilltap", "update-check.json"),
33
+ JSON.stringify({ checkedAt, latest }),
34
+ );
35
+ }
36
+
37
+ // ─── checkForUpdate ───────────────────────────────────────────────────────────
38
+
39
+ describe("checkForUpdate", () => {
40
+ test("returns null when no cache file exists", async () => {
41
+ const result = await checkForUpdate("1.0.0");
42
+ expect(result).toBeNull();
43
+ });
44
+
45
+ test("returns null when cached version equals current", async () => {
46
+ await writeCache("1.0.0");
47
+ const result = await checkForUpdate("1.0.0");
48
+ expect(result).toBeNull();
49
+ });
50
+
51
+ test("returns null when cached version is older than current", async () => {
52
+ await writeCache("0.9.0");
53
+ const result = await checkForUpdate("1.0.0");
54
+ expect(result).toBeNull();
55
+ });
56
+
57
+ test("detects patch update", async () => {
58
+ await writeCache("1.0.1");
59
+ const result = await checkForUpdate("1.0.0");
60
+ expect(result).not.toBeNull();
61
+ expect(result?.type).toBe("patch");
62
+ expect(result?.current).toBe("1.0.0");
63
+ expect(result?.latest).toBe("1.0.1");
64
+ });
65
+
66
+ test("detects minor update", async () => {
67
+ await writeCache("1.1.0");
68
+ const result = await checkForUpdate("1.0.5");
69
+ expect(result?.type).toBe("minor");
70
+ });
71
+
72
+ test("detects major update", async () => {
73
+ await writeCache("2.0.0");
74
+ const result = await checkForUpdate("1.9.9");
75
+ expect(result?.type).toBe("major");
76
+ });
77
+
78
+ test("returns null when cache has malformed version", async () => {
79
+ await writeCache("not-a-version");
80
+ const result = await checkForUpdate("1.0.0");
81
+ expect(result).toBeNull();
82
+ });
83
+
84
+ test("returns null when current version is malformed", async () => {
85
+ await writeCache("1.0.1");
86
+ const result = await checkForUpdate("not-a-version");
87
+ expect(result).toBeNull();
88
+ });
89
+
90
+ test("returns cached data even when cache is stale (background refresh fires)", async () => {
91
+ // Write a stale cache (25 hours ago)
92
+ await writeCache("1.2.0", 25);
93
+ // Should still return the cached data (background fetch is fire-and-forget)
94
+ const result = await checkForUpdate("1.0.0", 24);
95
+ expect(result).not.toBeNull();
96
+ expect(result?.latest).toBe("1.2.0");
97
+ });
98
+
99
+ test("respects custom intervalHours — fresh cache not stale", async () => {
100
+ // Cache is 1 hour old; interval is 2 hours → not stale → no background fetch
101
+ await writeCache("2.0.0", 1);
102
+ const result = await checkForUpdate("1.0.0", 2);
103
+ expect(result).not.toBeNull();
104
+ expect(result?.type).toBe("major");
105
+ });
106
+ });
107
+
108
+ // ─── isCompiledBinary ─────────────────────────────────────────────────────────
109
+
110
+ describe("isCompiledBinary", () => {
111
+ test("returns false when running under bun (test env)", () => {
112
+ // Tests run via `bun test`, so process.execPath is the bun binary
113
+ expect(isCompiledBinary()).toBe(false);
114
+ });
115
+ });
116
+
117
+ // ─── downloadAndInstall ───────────────────────────────────────────────────────
118
+
119
+ const fakeBinary = new Uint8Array([0x7f, 0x45, 0x4c, 0x46]); // ELF magic bytes
120
+
121
+ function okFetch(_url: string): Promise<Response> {
122
+ return Promise.resolve(new Response(fakeBinary, { status: 200 }));
123
+ }
124
+
125
+ function notFoundFetch(_url: string): Promise<Response> {
126
+ return Promise.resolve(new Response(null, { status: 404 }));
127
+ }
128
+
129
+ function networkErrorFetch(_url: string): Promise<Response> {
130
+ return Promise.reject(new Error("ECONNREFUSED: connection refused"));
131
+ }
132
+
133
+ describe("downloadAndInstall", () => {
134
+ test("returns error on HTTP 404", async () => {
135
+ const execPath = join(tmpDir, "skilltap");
136
+ await Bun.write(execPath, "old");
137
+
138
+ const result = await downloadAndInstall("9.9.9", notFoundFetch, execPath);
139
+
140
+ expect(result.ok).toBe(false);
141
+ if (result.ok) return;
142
+ expect(result.error.message).toContain("HTTP 404");
143
+ });
144
+
145
+ test("returns error on network failure", async () => {
146
+ const execPath = join(tmpDir, "skilltap");
147
+ await Bun.write(execPath, "old");
148
+
149
+ const result = await downloadAndInstall("9.9.9", networkErrorFetch, execPath);
150
+
151
+ expect(result.ok).toBe(false);
152
+ if (result.ok) return;
153
+ expect(result.error.message).toContain("Download failed");
154
+ });
155
+
156
+ test("replaces binary with downloaded content on success", async () => {
157
+ const execPath = join(tmpDir, "skilltap");
158
+ await Bun.write(execPath, "old content");
159
+
160
+ const result = await downloadAndInstall("9.9.9", okFetch, execPath);
161
+
162
+ expect(result.ok).toBe(true);
163
+ const written = new Uint8Array(await Bun.file(execPath).arrayBuffer());
164
+ expect(written).toEqual(fakeBinary);
165
+ });
166
+
167
+ test("temp file is cleaned up after successful install", async () => {
168
+ const execPath = join(tmpDir, "skilltap");
169
+ await Bun.write(execPath, "old");
170
+
171
+ await downloadAndInstall("9.9.9", okFetch, execPath);
172
+
173
+ expect(await Bun.file(`${execPath}.update`).exists()).toBe(false);
174
+ });
175
+
176
+ test("binary is made executable after install", async () => {
177
+ const execPath = join(tmpDir, "skilltap");
178
+ await Bun.write(execPath, "old");
179
+
180
+ await downloadAndInstall("9.9.9", okFetch, execPath);
181
+
182
+ const stat = await Bun.file(execPath).stat();
183
+ // Check owner execute bit (0o100)
184
+ expect(stat.mode & 0o100).toBe(0o100);
185
+ });
186
+
187
+ test("updates version cache after successful install", async () => {
188
+ const execPath = join(tmpDir, "skilltap");
189
+ await Bun.write(execPath, "old");
190
+
191
+ await downloadAndInstall("9.9.9", okFetch, execPath);
192
+
193
+ const cacheFile = join(configDir, "skilltap", "update-check.json");
194
+ const cache = (await Bun.file(cacheFile).json()) as { latest: string };
195
+ expect(cache.latest).toBe("9.9.9");
196
+ });
197
+
198
+ test("returns error when binary replacement fails", async () => {
199
+ // Put execPath inside a read-only directory so Bun.write to tmpPath (execPath.update) fails
200
+ const roDir = join(tmpDir, "readonly");
201
+ await mkdir(roDir, { recursive: true });
202
+ await Bun.$`chmod 555 ${roDir}`.quiet();
203
+ const execPath = join(roDir, "skilltap");
204
+
205
+ const result = await downloadAndInstall("9.9.9", okFetch, execPath);
206
+
207
+ expect(result.ok).toBe(false);
208
+ if (result.ok) return;
209
+ expect(result.error.message).toContain("Failed to replace binary");
210
+ expect(result.error.hint).toContain("sudo");
211
+ });
212
+
213
+ test("temp file cleaned up when replacement fails", async () => {
214
+ const roDir = join(tmpDir, "readonly2");
215
+ await mkdir(roDir, { recursive: true });
216
+ await Bun.$`chmod 555 ${roDir}`.quiet();
217
+ const execPath = join(roDir, "skilltap");
218
+
219
+ await downloadAndInstall("9.9.9", okFetch, execPath);
220
+
221
+ // tmpPath = ${execPath}.update — write failed (EACCES), so it should not exist
222
+ expect(await Bun.file(`${execPath}.update`).exists()).toBe(false);
223
+ });
224
+ });
@@ -124,12 +124,16 @@ function getPlatformAsset(): string | null {
124
124
  return null;
125
125
  }
126
126
 
127
+ type FetchFn = typeof fetch;
128
+
127
129
  /**
128
130
  * Download the specified release from GitHub and atomically replace the
129
131
  * running binary. Only works when running as a compiled binary.
130
132
  */
131
133
  export async function downloadAndInstall(
132
134
  version: string,
135
+ _fetch: FetchFn = fetch,
136
+ _execPath: string = process.execPath,
133
137
  ): Promise<Result<void, UserError>> {
134
138
  const asset = getPlatformAsset();
135
139
  if (!asset) {
@@ -142,12 +146,11 @@ export async function downloadAndInstall(
142
146
  }
143
147
 
144
148
  const url = `https://github.com/${GITHUB_OWNER}/${GITHUB_REPO}/releases/download/v${version}/${asset}`;
145
- const execPath = process.execPath;
146
- const tmpPath = `${execPath}.update`;
149
+ const tmpPath = `${_execPath}.update`;
147
150
 
148
151
  let response: Response;
149
152
  try {
150
- response = await fetch(url, { signal: AbortSignal.timeout(60_000) });
153
+ response = await _fetch(url, { signal: AbortSignal.timeout(60_000) });
151
154
  } catch (e) {
152
155
  return err(
153
156
  new NetworkError(`Download failed: ${e}`) as unknown as UserError,
@@ -164,7 +167,7 @@ export async function downloadAndInstall(
164
167
  const buffer = await response.arrayBuffer();
165
168
  await Bun.write(tmpPath, buffer);
166
169
  await Bun.$`chmod +x ${tmpPath}`.quiet();
167
- await Bun.$`mv -f ${tmpPath} ${execPath}`.quiet();
170
+ await Bun.$`mv -f ${tmpPath} ${_execPath}`.quiet();
168
171
  } catch (e) {
169
172
  // Clean up temp file if possible
170
173
  Bun.$`rm -f ${tmpPath}`.quiet();