skillrepo 4.8.0 → 4.8.2
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/bin/skillrepo.mjs +6 -3
- package/package.json +1 -1
- package/src/commands/add.mjs +2 -1
- package/src/commands/init.mjs +15 -5
- package/src/lib/http.mjs +25 -11
- package/src/test/commands/add.test.mjs +79 -2
- package/src/test/commands/get.test.mjs +131 -2
- package/src/test/commands/init-session-sync.test.mjs +724 -0
- package/src/test/commands/init.test.mjs +159 -2
- package/src/test/commands/list.test.mjs +573 -1
- package/src/test/commands/publish.test.mjs +136 -3
- package/src/test/commands/push.test.mjs +280 -1
- package/src/test/commands/remove.test.mjs +221 -2
- package/src/test/commands/search.test.mjs +203 -1
- package/src/test/commands/session-sync.test.mjs +227 -1
- package/src/test/commands/uninstall.test.mjs +216 -0
- package/src/test/commands/update.test.mjs +218 -0
- package/src/test/dispatcher.test.mjs +103 -2
- package/src/test/e2e/advertised-surface.test.mjs +207 -0
- package/src/test/e2e/cli-commands.test.mjs +1 -1
- package/src/test/e2e/uninstall-interactive.test.mjs +93 -0
- package/src/test/e2e/update-check-suppression.test.mjs +135 -0
- package/src/test/integration/update-list-contract.integration.test.mjs +66 -0
- package/src/test/lib/browser-open.test.mjs +43 -0
- package/src/test/lib/config.test.mjs +87 -0
- package/src/test/lib/crypto-shas.test.mjs +17 -0
- package/src/test/lib/file-write.test.mjs +244 -0
- package/src/test/lib/fs-utils.test.mjs +259 -0
- package/src/test/lib/global-install.test.mjs +134 -0
- package/src/test/lib/http-timeout.test.mjs +114 -0
- package/src/test/lib/http.test.mjs +616 -1
- package/src/test/lib/mcp-merge.test.mjs +157 -0
- package/src/test/lib/npm-update-check.test.mjs +180 -0
- package/src/test/lib/placement-walk.test.mjs +132 -0
- package/src/test/lib/skill-walk.test.mjs +39 -1
- package/src/test/lib/sync.test.mjs +139 -5
- package/src/test/lib/telemetry.test.mjs +34 -0
- package/src/test/mergers/claude-mcp.test.mjs +30 -0
- package/src/test/mergers/cursor-mcp.test.mjs +115 -0
- package/src/test/mergers/env-local.test.mjs +126 -0
- package/src/test/mergers/vscode-mcp.test.mjs +177 -0
- package/src/test/mergers/windsurf-mcp.test.mjs +144 -0
- package/src/test/resolve-key.test.mjs +33 -0
|
@@ -27,7 +27,10 @@ import { join } from "node:path";
|
|
|
27
27
|
import { tmpdir } from "node:os";
|
|
28
28
|
|
|
29
29
|
import { runSessionSync } from "../../commands/session-sync.mjs";
|
|
30
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
buildHookCommand,
|
|
32
|
+
resolveSkillrepoBinary,
|
|
33
|
+
} from "../../lib/mergers/session-hook.mjs";
|
|
31
34
|
import { SESSION_HOOK_FINGERPRINT } from "../../lib/artifact-registry.mjs";
|
|
32
35
|
import { createCaptureStream } from "../helpers/capture-stream.mjs";
|
|
33
36
|
import { CliError, EXIT_VALIDATION } from "../../lib/errors.mjs";
|
|
@@ -146,6 +149,43 @@ describe("session-sync enable", () => {
|
|
|
146
149
|
assert.ok(hasHook, "SkillRepo hook must be present");
|
|
147
150
|
});
|
|
148
151
|
|
|
152
|
+
it("reports 'updated' when an existing SkillRepo hook has a stale command", async () => {
|
|
153
|
+
// INTENT: a prior SkillRepo hook pointing at a DIFFERENT binary path
|
|
154
|
+
// (e.g. from an older install location) must be upgraded in place —
|
|
155
|
+
// not duplicated, not left stale. This is the 'updated' branch of
|
|
156
|
+
// printEnableResult, which no test exercised (only installed/unchanged
|
|
157
|
+
// were covered).
|
|
158
|
+
ASSERT_HOME_ISOLATED();
|
|
159
|
+
mkdirSync(join(process.cwd(), ".claude"), { recursive: true });
|
|
160
|
+
const settingsPath = join(process.cwd(), ".claude", "settings.local.json");
|
|
161
|
+
const staleCommand = buildHookCommand("/old/location/skillrepo");
|
|
162
|
+
assert.ok(
|
|
163
|
+
staleCommand.includes(SESSION_HOOK_FINGERPRINT),
|
|
164
|
+
"precondition: seeded command is a SkillRepo hook",
|
|
165
|
+
);
|
|
166
|
+
writeFileSync(
|
|
167
|
+
settingsPath,
|
|
168
|
+
JSON.stringify(
|
|
169
|
+
{ hooks: { SessionStart: [{ hooks: [{ type: "command", command: staleCommand }] }] } },
|
|
170
|
+
null,
|
|
171
|
+
2,
|
|
172
|
+
),
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
await runSessionSync(["enable"], { stdout, stderr });
|
|
176
|
+
|
|
177
|
+
assert.match(stdout.text(), /updated/i, "a stale SkillRepo hook must report 'updated'");
|
|
178
|
+
const parsed = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
|
179
|
+
const skillrepoHooks = parsed.hooks.SessionStart.flatMap((g) => g.hooks)
|
|
180
|
+
.map((h) => h.command)
|
|
181
|
+
.filter((c) => c.includes(SESSION_HOOK_FINGERPRINT));
|
|
182
|
+
assert.equal(skillrepoHooks.length, 1, "must not duplicate the SkillRepo hook");
|
|
183
|
+
assert.ok(
|
|
184
|
+
!skillrepoHooks.includes(staleCommand),
|
|
185
|
+
"the stale command must be replaced with the current one",
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
|
|
149
189
|
it("is idempotent — second enable returns 'unchanged'", async () => {
|
|
150
190
|
// INTENT: users re-running `session-sync enable` must get a clear
|
|
151
191
|
// "nothing to do" response, not a duplicate hook.
|
|
@@ -219,16 +259,103 @@ describe("session-sync disable", () => {
|
|
|
219
259
|
assert.match(stdout.text(), /removed/i);
|
|
220
260
|
});
|
|
221
261
|
|
|
262
|
+
it("--global removes the hook from the user-wide settings file", async () => {
|
|
263
|
+
// Symmetry with `enable --global`: disabling with --global must strip
|
|
264
|
+
// the hook from ~/.claude/settings.local.json (not the project file).
|
|
265
|
+
// enable --global was tested; disable --global was the gap.
|
|
266
|
+
ASSERT_HOME_ISOLATED();
|
|
267
|
+
await runSessionSync(["enable", "--global"], { stdout, stderr });
|
|
268
|
+
const globalSettings = join(process.env.HOME, ".claude", "settings.local.json");
|
|
269
|
+
assert.ok(existsSync(globalSettings), "precondition: global hook installed");
|
|
270
|
+
stdout.clear();
|
|
271
|
+
|
|
272
|
+
await runSessionSync(["disable", "--global"], { stdout, stderr });
|
|
273
|
+
|
|
274
|
+
const parsed = JSON.parse(readFileSync(globalSettings, "utf-8"));
|
|
275
|
+
const hasHook = (parsed.hooks?.SessionStart ?? [])
|
|
276
|
+
.flatMap((g) => g?.hooks ?? [])
|
|
277
|
+
.some((h) => h?.command?.includes(SESSION_HOOK_FINGERPRINT));
|
|
278
|
+
assert.ok(!hasHook, "SkillRepo hook must be gone from the global file after disable --global");
|
|
279
|
+
assert.match(stdout.text(), /removed/i);
|
|
280
|
+
});
|
|
281
|
+
|
|
222
282
|
it("reports cleanly when disable runs on a file without the hook", async () => {
|
|
223
283
|
// INTENT: a user running `session-sync disable` when the hook
|
|
224
284
|
// isn't installed must get a clear "nothing to do" response, not
|
|
225
285
|
// an error. This is how automation scripts confirm the final
|
|
226
286
|
// state is "disabled" regardless of prior state.
|
|
287
|
+
//
|
|
288
|
+
// NOTE: there is NO settings file here, so the remover returns
|
|
289
|
+
// `skipped` (no error) and printDisableResult takes the
|
|
290
|
+
// "no settings file" branch (lines 148-151). The "unchanged"
|
|
291
|
+
// branch (lines 132-136) is covered by the next test, which
|
|
292
|
+
// seeds a file that EXISTS but has no SkillRepo hook.
|
|
227
293
|
ASSERT_HOME_ISOLATED();
|
|
228
294
|
await runSessionSync(["disable"], { stdout, stderr });
|
|
229
295
|
assert.match(stdout.text(), /not enabled|not installed|nothing to do/i);
|
|
230
296
|
});
|
|
231
297
|
|
|
298
|
+
it("reports 'was not installed — nothing to do' when the file exists but has no SkillRepo hook", async () => {
|
|
299
|
+
// INTENT: distinct from the no-file case above. The settings file
|
|
300
|
+
// EXISTS and is valid JSON, but carries only a user-authored hook —
|
|
301
|
+
// no SkillRepo entry. The remover returns `unchanged` (not
|
|
302
|
+
// `skipped`), and printDisableResult must take the "was not
|
|
303
|
+
// installed" branch (lines 132-136), NOT the "no settings file"
|
|
304
|
+
// fallback. This is the branch the no-file test does NOT hit.
|
|
305
|
+
ASSERT_HOME_ISOLATED();
|
|
306
|
+
mkdirSync(join(process.cwd(), ".claude"), { recursive: true });
|
|
307
|
+
writeFileSync(
|
|
308
|
+
join(process.cwd(), ".claude", "settings.local.json"),
|
|
309
|
+
JSON.stringify(
|
|
310
|
+
{
|
|
311
|
+
hooks: {
|
|
312
|
+
SessionStart: [
|
|
313
|
+
{ hooks: [{ type: "command", command: "echo hi" }] },
|
|
314
|
+
],
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
null,
|
|
318
|
+
2,
|
|
319
|
+
),
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
await runSessionSync(["disable"], { stdout, stderr });
|
|
323
|
+
|
|
324
|
+
const out = stdout.text();
|
|
325
|
+
assert.match(
|
|
326
|
+
out,
|
|
327
|
+
/was not installed|nothing to do/i,
|
|
328
|
+
"an existing file with no SkillRepo hook must report 'was not installed — nothing to do'",
|
|
329
|
+
);
|
|
330
|
+
// It must NOT claim the file is absent — that's the other branch.
|
|
331
|
+
assert.doesNotMatch(
|
|
332
|
+
out,
|
|
333
|
+
/No settings file/i,
|
|
334
|
+
"must not take the 'no settings file' branch when the file exists",
|
|
335
|
+
);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it("--json emits the 'unchanged' action when the file exists without a SkillRepo hook", async () => {
|
|
339
|
+
// Locks the structured-output counterpart of the human branch
|
|
340
|
+
// above: the remover's `unchanged` action surfaces verbatim in
|
|
341
|
+
// --json mode.
|
|
342
|
+
ASSERT_HOME_ISOLATED();
|
|
343
|
+
mkdirSync(join(process.cwd(), ".claude"), { recursive: true });
|
|
344
|
+
writeFileSync(
|
|
345
|
+
join(process.cwd(), ".claude", "settings.local.json"),
|
|
346
|
+
JSON.stringify(
|
|
347
|
+
{ hooks: { SessionStart: [{ hooks: [{ type: "command", command: "echo hi" }] }] } },
|
|
348
|
+
null,
|
|
349
|
+
2,
|
|
350
|
+
),
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
await runSessionSync(["disable", "--json"], { stdout, stderr });
|
|
354
|
+
|
|
355
|
+
const json = JSON.parse(stdout.text());
|
|
356
|
+
assert.equal(json.action, "unchanged");
|
|
357
|
+
});
|
|
358
|
+
|
|
232
359
|
it("preserves user-authored hooks in the same settings file", async () => {
|
|
233
360
|
// INTENT: disable must only strip SkillRepo's entry — everything
|
|
234
361
|
// the user added must survive.
|
|
@@ -350,3 +477,102 @@ describe("session-sync — flag ordering tolerance", () => {
|
|
|
350
477
|
assert.equal(json.action, "installed");
|
|
351
478
|
});
|
|
352
479
|
});
|
|
480
|
+
|
|
481
|
+
// Skipped on Windows: this scrubs process.env.PATH to a single empty dir,
|
|
482
|
+
// which is unsafe on the Windows runner (an empty PATH can stall process
|
|
483
|
+
// machinery the test harness relies on). The "skipped" branch is platform-
|
|
484
|
+
// agnostic and covered on the Linux/macOS CI run.
|
|
485
|
+
describe("session-sync enable — binary unresolvable (skipped)", { skip: process.platform === "win32" }, () => {
|
|
486
|
+
// This block deliberately does NOT install the PATH shim. The
|
|
487
|
+
// standard `setup` installs a `skillrepo` shim so mergeSessionHook
|
|
488
|
+
// can resolve a binary path; here we need the OPPOSITE — an
|
|
489
|
+
// environment where `skillrepo` is UNresolvable so mergeSessionHook
|
|
490
|
+
// returns `{ action: "skipped", reason: ... }` and printEnableResult
|
|
491
|
+
// takes the "Could not enable session sync" branch (lines 123-124),
|
|
492
|
+
// the only enable branch no other test exercised.
|
|
493
|
+
let localSandbox;
|
|
494
|
+
let localOriginalCwd;
|
|
495
|
+
/** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
|
|
496
|
+
let localHomeEnv;
|
|
497
|
+
let savedPath;
|
|
498
|
+
let localStdout;
|
|
499
|
+
let localStderr;
|
|
500
|
+
|
|
501
|
+
beforeEach(() => {
|
|
502
|
+
localSandbox = mkdtempSync(join(tmpdir(), "cli-cmd-session-sync-noshim-"));
|
|
503
|
+
mkdirSync(join(localSandbox, "project"), { recursive: true });
|
|
504
|
+
mkdirSync(join(localSandbox, "home"), { recursive: true });
|
|
505
|
+
// An empty bin dir is the ENTIRE PATH — guarantees `skillrepo` is
|
|
506
|
+
// not resolvable from anywhere (no global install can leak in).
|
|
507
|
+
mkdirSync(join(localSandbox, "emptybin"), { recursive: true });
|
|
508
|
+
localOriginalCwd = process.cwd();
|
|
509
|
+
localHomeEnv = captureHome();
|
|
510
|
+
process.chdir(join(localSandbox, "project"));
|
|
511
|
+
setSandboxHome(join(localSandbox, "home"));
|
|
512
|
+
|
|
513
|
+
// Replace PATH with a single empty directory. resolveBinaryOnPath
|
|
514
|
+
// scans PATH dirs for a `skillrepo` file and finds none → returns
|
|
515
|
+
// null → mergeSessionHook short-circuits to "skipped". Save the
|
|
516
|
+
// real PATH so teardown restores it (other suites depend on it).
|
|
517
|
+
savedPath = process.env.PATH;
|
|
518
|
+
process.env.PATH = join(localSandbox, "emptybin");
|
|
519
|
+
|
|
520
|
+
assertHomeIsolated(tmpdir(), "session-sync skipped test");
|
|
521
|
+
|
|
522
|
+
localStdout = createCaptureStream();
|
|
523
|
+
localStderr = createCaptureStream();
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
afterEach(() => {
|
|
527
|
+
process.chdir(localOriginalCwd);
|
|
528
|
+
if (savedPath === undefined) {
|
|
529
|
+
delete process.env.PATH;
|
|
530
|
+
} else {
|
|
531
|
+
process.env.PATH = savedPath;
|
|
532
|
+
}
|
|
533
|
+
restoreHome(localHomeEnv);
|
|
534
|
+
if (localSandbox) rmSync(localSandbox, { recursive: true, force: true });
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
it("reports 'Could not enable session sync' when no skillrepo binary is on PATH", async () => {
|
|
538
|
+
// Precondition: prove the binary genuinely can't be resolved, so
|
|
539
|
+
// the test isn't silently passing for the wrong reason (e.g. a
|
|
540
|
+
// global install leaking onto PATH).
|
|
541
|
+
assert.equal(
|
|
542
|
+
resolveSkillrepoBinary(),
|
|
543
|
+
null,
|
|
544
|
+
"precondition: skillrepo must be unresolvable on the scrubbed PATH",
|
|
545
|
+
);
|
|
546
|
+
|
|
547
|
+
await runSessionSync(["enable"], { stdout: localStdout, stderr: localStderr });
|
|
548
|
+
|
|
549
|
+
const out = localStdout.text();
|
|
550
|
+
assert.match(
|
|
551
|
+
out,
|
|
552
|
+
/Could not enable session sync/i,
|
|
553
|
+
"the skipped branch must surface the human 'Could not enable' message",
|
|
554
|
+
);
|
|
555
|
+
// The skipped reason points the user at how to fix it.
|
|
556
|
+
assert.match(out, /skillrepo/i, "the reason text must be present");
|
|
557
|
+
// No settings file should have been written.
|
|
558
|
+
assert.ok(
|
|
559
|
+
!existsSync(join(process.cwd(), ".claude", "settings.local.json")),
|
|
560
|
+
"a skipped enable must not write a settings file",
|
|
561
|
+
);
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
it("--json emits the skipped action + reason when the binary is unresolvable", async () => {
|
|
565
|
+
// Structured-output counterpart: automation needs to detect the
|
|
566
|
+
// skipped state programmatically.
|
|
567
|
+
assert.equal(resolveSkillrepoBinary(), null, "precondition: unresolvable");
|
|
568
|
+
|
|
569
|
+
await runSessionSync(["enable", "--json"], {
|
|
570
|
+
stdout: localStdout,
|
|
571
|
+
stderr: localStderr,
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
const json = JSON.parse(localStdout.text());
|
|
575
|
+
assert.equal(json.action, "skipped");
|
|
576
|
+
assert.ok(json.reason, "reason field must be present in JSON output");
|
|
577
|
+
});
|
|
578
|
+
});
|
|
@@ -31,6 +31,8 @@ import {
|
|
|
31
31
|
readFileSync,
|
|
32
32
|
writeFileSync,
|
|
33
33
|
existsSync,
|
|
34
|
+
symlinkSync,
|
|
35
|
+
chmodSync,
|
|
34
36
|
} from "node:fs";
|
|
35
37
|
import { join } from "node:path";
|
|
36
38
|
import { tmpdir } from "node:os";
|
|
@@ -771,4 +773,218 @@ describe("runUninstall — error handling", () => {
|
|
|
771
773
|
const giSurvivors = readFileSync(join(projectDir, ".gitignore"), "utf-8");
|
|
772
774
|
assert.doesNotMatch(giSurvivors, /SkillRepo/);
|
|
773
775
|
});
|
|
776
|
+
|
|
777
|
+
// ── Basename safety-net (removeDirectoryArtifact 145-151) ─────────
|
|
778
|
+
//
|
|
779
|
+
// The inline directory remover resolves `.claude/skills` with
|
|
780
|
+
// `realpathSync` BEFORE rmSync and refuses to recurse if the
|
|
781
|
+
// resolved basename is neither "skills" nor "skillrepo". The prior
|
|
782
|
+
// structural test only greps the source for the assertion text; the
|
|
783
|
+
// two tests below actually EXERCISE the guard at runtime by pointing
|
|
784
|
+
// `.claude/skills` at a symlink whose real target is a directory with
|
|
785
|
+
// a dangerous basename ("important-data"). This is the exact attack
|
|
786
|
+
// the architect's round-1 review flagged: a `.claude/skills` symlink
|
|
787
|
+
// pointing at `/home/user/important-data/` whose string-basename is
|
|
788
|
+
// "skills" but whose rmSync target is the user's real data.
|
|
789
|
+
//
|
|
790
|
+
// POSIX-only: symlink semantics differ on Windows and the guard's
|
|
791
|
+
// realpath-then-basename logic is exercised identically by these
|
|
792
|
+
// tests on POSIX, so we skip on win32 rather than fight junction
|
|
793
|
+
// semantics. The guard's source is the same on both platforms.
|
|
794
|
+
it(
|
|
795
|
+
"dry-run + json: basename guard surfaces a refusal error for a symlinked skills dir",
|
|
796
|
+
{ skip: process.platform === "win32" ? "POSIX-only symlink test" : false },
|
|
797
|
+
async () => {
|
|
798
|
+
ASSERT_HOME_ISOLATED();
|
|
799
|
+
// `.claude/skills` → <project>/important-data (target EXISTS, so
|
|
800
|
+
// existsSync passes and realpathSync resolves — the only way to
|
|
801
|
+
// reach the basename branch, since a dangling symlink would
|
|
802
|
+
// short-circuit at the earlier existsSync check).
|
|
803
|
+
const dangerous = join(projectDir, "important-data");
|
|
804
|
+
mkdirSync(dangerous, { recursive: true });
|
|
805
|
+
writeFileSync(join(dangerous, "keepme.txt"), "user data");
|
|
806
|
+
mkdirSync(join(projectDir, ".claude"), { recursive: true });
|
|
807
|
+
symlinkSync(dangerous, join(projectDir, ".claude", "skills"));
|
|
808
|
+
|
|
809
|
+
await runUninstall(["--dry-run", "--json"], { stdout, stderr });
|
|
810
|
+
|
|
811
|
+
const json = JSON.parse(stdout.text());
|
|
812
|
+
assert.equal(json.action, "dry-run");
|
|
813
|
+
const dirError = json.errors.find((e) => e.id === "skills-dir-project");
|
|
814
|
+
assert.ok(
|
|
815
|
+
dirError,
|
|
816
|
+
"the symlinked skills dir must appear in errors[] during scan",
|
|
817
|
+
);
|
|
818
|
+
assert.match(dirError.error, /Refusing to recursively remove/);
|
|
819
|
+
assert.match(dirError.error, /important-data/);
|
|
820
|
+
// Nothing was touched — dry run.
|
|
821
|
+
assert.ok(existsSync(join(dangerous, "keepme.txt")));
|
|
822
|
+
},
|
|
823
|
+
);
|
|
824
|
+
|
|
825
|
+
it(
|
|
826
|
+
"execute: basename guard refuses rmSync, reports the error, and leaves the target intact (covers stderr error summary + EXIT_DISK)",
|
|
827
|
+
{ skip: process.platform === "win32" ? "POSIX-only symlink test" : false },
|
|
828
|
+
async () => {
|
|
829
|
+
ASSERT_HOME_ISOLATED();
|
|
830
|
+
const dangerous = join(projectDir, "important-data");
|
|
831
|
+
mkdirSync(dangerous, { recursive: true });
|
|
832
|
+
writeFileSync(join(dangerous, "keepme.txt"), "user data");
|
|
833
|
+
mkdirSync(join(projectDir, ".claude"), { recursive: true });
|
|
834
|
+
symlinkSync(dangerous, join(projectDir, ".claude", "skills"));
|
|
835
|
+
|
|
836
|
+
// Non-JSON so the human-readable stderr error summary path
|
|
837
|
+
// (the "⚠ Some artifacts could not be removed" block) runs.
|
|
838
|
+
let thrown;
|
|
839
|
+
try {
|
|
840
|
+
await runUninstall(["--yes"], { stdout, stderr });
|
|
841
|
+
} catch (err) {
|
|
842
|
+
thrown = err;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// Aggregated failures propagate as EXIT_DISK.
|
|
846
|
+
assert.ok(thrown, "a refused removal must throw");
|
|
847
|
+
assert.equal(thrown.exitCode, 3, "must carry EXIT_DISK");
|
|
848
|
+
|
|
849
|
+
// The stderr summary block rendered the refusal.
|
|
850
|
+
assert.match(stderr.text(), /Some artifacts could not be removed/);
|
|
851
|
+
assert.match(stderr.text(), /Refusing to recursively remove/);
|
|
852
|
+
assert.match(
|
|
853
|
+
stderr.text(),
|
|
854
|
+
/re-run `skillrepo uninstall`/,
|
|
855
|
+
"the remediation hint must render",
|
|
856
|
+
);
|
|
857
|
+
|
|
858
|
+
// CRITICAL: the symlink target survived. The guard did its job.
|
|
859
|
+
assert.ok(
|
|
860
|
+
existsSync(join(dangerous, "keepme.txt")),
|
|
861
|
+
"the symlink target must NOT be removed by rmSync",
|
|
862
|
+
);
|
|
863
|
+
},
|
|
864
|
+
);
|
|
865
|
+
|
|
866
|
+
// ── Thrown-remover catch (execute phase 311-321) ─────────────────
|
|
867
|
+
//
|
|
868
|
+
// The corrupt-JSON test above covers a remover that RETURNS an error.
|
|
869
|
+
// This one covers a remover that THROWS during the execute phase — a
|
|
870
|
+
// different branch (the `catch (err)` that wraps the result). We make
|
|
871
|
+
// the .gitignore remover throw by making the project directory
|
|
872
|
+
// read-only AFTER seeding: the scan phase only reads (succeeds), but
|
|
873
|
+
// the execute phase's atomic write (temp-file + rename) fails with
|
|
874
|
+
// EACCES, which `writeFileAtomic` re-throws.
|
|
875
|
+
//
|
|
876
|
+
// POSIX-only: chmod 0555 has no equivalent enforcement on Windows.
|
|
877
|
+
it(
|
|
878
|
+
"continues and aggregates when a remover throws mid-execute (read-only project dir)",
|
|
879
|
+
{ skip: process.platform === "win32" ? "POSIX-only chmod test" : false },
|
|
880
|
+
async () => {
|
|
881
|
+
ASSERT_HOME_ISOLATED();
|
|
882
|
+
// Seed ONLY a .gitignore section so exactly one artifact is in the
|
|
883
|
+
// scanned set — keeps the assertion focused on the throw path.
|
|
884
|
+
seedInstalledProject({
|
|
885
|
+
mcpSkillrepo: false,
|
|
886
|
+
envLocalHasKey: false,
|
|
887
|
+
gitignoreHasSection: true,
|
|
888
|
+
skillsDir: false,
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
chmodSync(projectDir, 0o555);
|
|
892
|
+
let thrown;
|
|
893
|
+
try {
|
|
894
|
+
await runUninstall(["--yes", "--json"], { stdout, stderr });
|
|
895
|
+
} catch (err) {
|
|
896
|
+
thrown = err;
|
|
897
|
+
} finally {
|
|
898
|
+
// Restore write permission so teardown's rmSync can clean up.
|
|
899
|
+
chmodSync(projectDir, 0o755);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
assert.ok(thrown, "a throwing remover must still produce EXIT_DISK");
|
|
903
|
+
assert.equal(thrown.exitCode, 3);
|
|
904
|
+
|
|
905
|
+
const json = JSON.parse(stdout.text());
|
|
906
|
+
assert.equal(json.action, "partially-uninstalled");
|
|
907
|
+
const giError = json.errors.find((e) => e.id === "gitignore-entries");
|
|
908
|
+
assert.ok(giError, ".gitignore write failure must be aggregated");
|
|
909
|
+
assert.match(giError.error, /EACCES|permission denied|Cannot write/i);
|
|
910
|
+
},
|
|
911
|
+
);
|
|
912
|
+
|
|
913
|
+
// ── Color wrappers (GREEN/YELLOW/BOLD under a TTY stdout) ─────────
|
|
914
|
+
//
|
|
915
|
+
// The three ANSI color helpers only run when stdout is a TTY and
|
|
916
|
+
// NO_COLOR is unset (`makeColors`). The capture stream is not a TTY
|
|
917
|
+
// by default, so these wrappers are never exercised — they show as
|
|
918
|
+
// uncovered functions. We force `isTTY = true` on the capture stream
|
|
919
|
+
// and seed a state with BOTH a successful removal (green ✓ + bold
|
|
920
|
+
// header) AND a refused artifact (yellow ⚠) so all three wrappers
|
|
921
|
+
// fire in a single run. We assert the ANSI escape leaked into the
|
|
922
|
+
// captured (non-real) stream — proof the colored branch ran.
|
|
923
|
+
//
|
|
924
|
+
// POSIX-only: relies on the symlink basename guard to produce the
|
|
925
|
+
// yellow error branch.
|
|
926
|
+
it(
|
|
927
|
+
"emits ANSI color codes when stdout is a TTY (covers GREEN/YELLOW/BOLD)",
|
|
928
|
+
{ skip: process.platform === "win32" ? "POSIX-only symlink test" : false },
|
|
929
|
+
async () => {
|
|
930
|
+
ASSERT_HOME_ISOLATED();
|
|
931
|
+
// makeColors gates on NO_COLOR too — neutralize it for this test
|
|
932
|
+
// and restore afterward so we don't leak env state across tests.
|
|
933
|
+
const savedNoColor = process.env.NO_COLOR;
|
|
934
|
+
delete process.env.NO_COLOR;
|
|
935
|
+
stdout.isTTY = true;
|
|
936
|
+
stderr.isTTY = true;
|
|
937
|
+
|
|
938
|
+
// A removable artifact (green ✓) ...
|
|
939
|
+
seedInstalledProject({
|
|
940
|
+
mcpSkillrepo: false,
|
|
941
|
+
envLocalHasKey: false,
|
|
942
|
+
gitignoreHasSection: true,
|
|
943
|
+
skillsDir: false,
|
|
944
|
+
});
|
|
945
|
+
// ... plus a symlinked skills dir that the basename guard refuses
|
|
946
|
+
// (yellow ⚠).
|
|
947
|
+
const dangerous = join(projectDir, "important-data");
|
|
948
|
+
mkdirSync(dangerous, { recursive: true });
|
|
949
|
+
mkdirSync(join(projectDir, ".claude"), { recursive: true });
|
|
950
|
+
symlinkSync(dangerous, join(projectDir, ".claude", "skills"));
|
|
951
|
+
|
|
952
|
+
let thrown;
|
|
953
|
+
try {
|
|
954
|
+
await runUninstall(["--yes"], { stdout, stderr });
|
|
955
|
+
} catch (err) {
|
|
956
|
+
thrown = err;
|
|
957
|
+
} finally {
|
|
958
|
+
if (savedNoColor === undefined) delete process.env.NO_COLOR;
|
|
959
|
+
else process.env.NO_COLOR = savedNoColor;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
assert.ok(thrown, "the refused artifact still drives EXIT_DISK");
|
|
963
|
+
// ANSI escape (\x1b[) present → the colored branch ran. bold is
|
|
964
|
+
// in the preview header on stdout, green on the removed line,
|
|
965
|
+
// yellow on the stderr warning.
|
|
966
|
+
assert.match(stdout.text(), /\x1b\[/, "stdout must carry ANSI codes under TTY");
|
|
967
|
+
assert.match(stderr.text(), /\x1b\[33m/, "the yellow warning wrapper must run");
|
|
968
|
+
},
|
|
969
|
+
);
|
|
970
|
+
|
|
971
|
+
// ── Unknown-flag rejection (parseUninstallFlags acceptPositional) ──
|
|
972
|
+
//
|
|
973
|
+
// An unrecognized arg falls through `parseUninstallFlags`'s
|
|
974
|
+
// acceptPositional callback (which returns `false`), so resolveFlags
|
|
975
|
+
// raises a validationError. Covers the `return false` tail of the
|
|
976
|
+
// callback.
|
|
977
|
+
it("rejects an unknown flag with a validation error", async () => {
|
|
978
|
+
ASSERT_HOME_ISOLATED();
|
|
979
|
+
seedInstalledProject();
|
|
980
|
+
|
|
981
|
+
await assert.rejects(
|
|
982
|
+
() => runUninstall(["--bogus"], { stdout, stderr }),
|
|
983
|
+
(err) => {
|
|
984
|
+
assert.equal(err.exitCode, 5, "unknown flag must be a validation error");
|
|
985
|
+
assert.match(err.message, /Unknown argument/);
|
|
986
|
+
return true;
|
|
987
|
+
},
|
|
988
|
+
);
|
|
989
|
+
});
|
|
774
990
|
});
|