@skilltap/core 0.5.3 → 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 +1 -1
- package/src/doctor.test.ts +199 -0
- package/src/doctor.ts +133 -89
- package/src/self-update.test.ts +224 -0
- package/src/self-update.ts +7 -4
package/package.json
CHANGED
package/src/doctor.test.ts
CHANGED
|
@@ -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
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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(
|
|
270
|
+
raw = await Bun.file(file).json();
|
|
280
271
|
} catch (e) {
|
|
281
|
-
const backupFile = `${
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
302
|
-
|
|
282
|
+
});
|
|
283
|
+
return null;
|
|
303
284
|
}
|
|
304
285
|
|
|
305
286
|
const result = InstalledJsonSchema.safeParse(raw);
|
|
306
287
|
if (!result.success) {
|
|
307
|
-
const backupFile = `${
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
328
|
-
|
|
298
|
+
});
|
|
299
|
+
return null;
|
|
329
300
|
}
|
|
330
301
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
|
351
|
+
const globalTracked = new Set<string>();
|
|
352
|
+
const projectTracked = new Set<string>();
|
|
347
353
|
|
|
348
354
|
for (const skill of installed.skills) {
|
|
349
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
391
|
-
const
|
|
392
|
-
if (await resolvedDirExists(
|
|
407
|
+
// Global orphan scan
|
|
408
|
+
const globalSkillsDir = join(globalBase(), ".agents", "skills");
|
|
409
|
+
if (await resolvedDirExists(globalSkillsDir)) {
|
|
393
410
|
try {
|
|
394
|
-
const entries = await readdir(
|
|
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 (!
|
|
414
|
+
if (!globalTracked.has(entry.name)) {
|
|
398
415
|
issues.push({
|
|
399
|
-
message: `${entry.name}: directory exists at ${join(
|
|
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
|
|
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
|
+
});
|
package/src/self-update.ts
CHANGED
|
@@ -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
|
|
146
|
-
const tmpPath = `${execPath}.update`;
|
|
149
|
+
const tmpPath = `${_execPath}.update`;
|
|
147
150
|
|
|
148
151
|
let response: Response;
|
|
149
152
|
try {
|
|
150
|
-
response = await
|
|
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.$`
|
|
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();
|