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
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `skillrepo uninstall` interactive DECLINE path.
|
|
3
|
+
*
|
|
4
|
+
* uninstall is destructive (it deletes skill placements + config from the
|
|
5
|
+
* project). When run without --yes/--json it prompts "Proceed? (y/N)" and
|
|
6
|
+
* a "no" answer MUST remove nothing. The whole in-process suite uses
|
|
7
|
+
* --yes/--json/--dry-run and never drives this prompt — so the abort path
|
|
8
|
+
* (the single most safety-critical branch in the command) had zero
|
|
9
|
+
* coverage. confirm() reads process.stdin via readline (no TTY required),
|
|
10
|
+
* so we spawn the real binary and pipe "n" to stdin.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import { spawnSync } from "node:child_process";
|
|
16
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } from "node:fs";
|
|
17
|
+
import { join, dirname, resolve } from "node:path";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
|
|
21
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
// This file lives at src/test/e2e/ — three levels below packages/cli/.
|
|
23
|
+
const CLI_BIN = resolve(__dirname, "../../../bin/skillrepo.mjs");
|
|
24
|
+
|
|
25
|
+
let sandbox;
|
|
26
|
+
let home;
|
|
27
|
+
let project;
|
|
28
|
+
let skillDir;
|
|
29
|
+
|
|
30
|
+
function setup() {
|
|
31
|
+
sandbox = mkdtempSync(join(tmpdir(), "cli-uninstall-decline-"));
|
|
32
|
+
home = join(sandbox, "home");
|
|
33
|
+
project = join(sandbox, "project");
|
|
34
|
+
mkdirSync(home, { recursive: true });
|
|
35
|
+
// Seed a SkillRepo artifact so uninstall has something to remove and
|
|
36
|
+
// therefore reaches the confirmation prompt (it short-circuits with
|
|
37
|
+
// "nothing to remove" otherwise).
|
|
38
|
+
skillDir = join(project, ".claude", "skills", "dummy");
|
|
39
|
+
mkdirSync(skillDir, { recursive: true });
|
|
40
|
+
writeFileSync(join(skillDir, "SKILL.md"), "---\nname: dummy\ndescription: d\n---\nbody\n");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function teardown() {
|
|
44
|
+
if (sandbox) rmSync(sandbox, { recursive: true, force: true });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function runUninstallWithInput(input) {
|
|
48
|
+
return spawnSync(process.execPath, [CLI_BIN, "uninstall", "--key", "sk_live_dummy"], {
|
|
49
|
+
cwd: project,
|
|
50
|
+
encoding: "utf-8",
|
|
51
|
+
input,
|
|
52
|
+
timeout: 10_000,
|
|
53
|
+
env: {
|
|
54
|
+
...process.env,
|
|
55
|
+
HOME: home,
|
|
56
|
+
USERPROFILE: home,
|
|
57
|
+
NO_COLOR: "1",
|
|
58
|
+
CI: "true", // silence the update-check nudge
|
|
59
|
+
SKILLREPO_ACCESS_KEY: "",
|
|
60
|
+
SKILLREPO_TIMEOUT_MS: "2000",
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe("uninstall — interactive decline removes nothing", () => {
|
|
66
|
+
beforeEach(setup);
|
|
67
|
+
afterEach(teardown);
|
|
68
|
+
|
|
69
|
+
it("answering 'n' at the prompt cancels and leaves all artifacts intact", () => {
|
|
70
|
+
const r = runUninstallWithInput("n\n");
|
|
71
|
+
const out = (r.stdout ?? "") + (r.stderr ?? "");
|
|
72
|
+
assert.match(out, /Cancelled\. Nothing was removed\./, "decline must print the cancel message");
|
|
73
|
+
assert.ok(
|
|
74
|
+
existsSync(join(skillDir, "SKILL.md")),
|
|
75
|
+
"the skill placement MUST survive a declined uninstall",
|
|
76
|
+
);
|
|
77
|
+
// It must NOT have printed a per-artifact removal confirmation.
|
|
78
|
+
assert.doesNotMatch(out, /has been removed from this project/);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("answering 'y' at the prompt proceeds with removal (control — proves the prompt is live)", () => {
|
|
82
|
+
// Without this control, the decline test could pass simply because
|
|
83
|
+
// the prompt never fired. A "y" answer must actually remove the
|
|
84
|
+
// artifact, proving the prompt gates a real deletion.
|
|
85
|
+
const r = runUninstallWithInput("y\n");
|
|
86
|
+
const out = (r.stdout ?? "") + (r.stderr ?? "");
|
|
87
|
+
assert.doesNotMatch(out, /Cancelled/);
|
|
88
|
+
assert.ok(
|
|
89
|
+
!existsSync(join(skillDir, "SKILL.md")),
|
|
90
|
+
"accepting the prompt must remove the skill placement",
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dispatcher npm update-check: nudge emission + --json suppression (#1554).
|
|
3
|
+
*
|
|
4
|
+
* The closest analog to the #1911 class of "command reports success while
|
|
5
|
+
* silently doing the wrong thing": the dispatcher gates the post-command
|
|
6
|
+
* upgrade nudge on `if (!rest.includes("--json"))`. If that guard ever
|
|
7
|
+
* regressed, every `--json` invocation would leak human text into a
|
|
8
|
+
* structured stream — invisible to the per-command tests (which call
|
|
9
|
+
* runX() directly and never reach the dispatcher's post-command hook) and
|
|
10
|
+
* to npm-update-check.test.mjs (which tests the module, not the binary).
|
|
11
|
+
*
|
|
12
|
+
* This spawns the REAL binary and arms the update-check cache so a nudge
|
|
13
|
+
* fires deterministically WITHOUT a network call (a cache hit resolves
|
|
14
|
+
* synchronously, before the dispatcher's Promise.race timer). Then it
|
|
15
|
+
* proves: nudge present on a normal command, fully absent under --json.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
19
|
+
import assert from "node:assert/strict";
|
|
20
|
+
import { execFile } from "node:child_process";
|
|
21
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync } from "node:fs";
|
|
22
|
+
import { join, dirname, resolve } from "node:path";
|
|
23
|
+
import { tmpdir } from "node:os";
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
25
|
+
|
|
26
|
+
import { createMockServer } from "./mock-server.mjs";
|
|
27
|
+
|
|
28
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
29
|
+
// This file lives at src/test/e2e/ — three levels below packages/cli/.
|
|
30
|
+
const CLI_BIN = resolve(__dirname, "../../../bin/skillrepo.mjs");
|
|
31
|
+
const PKG = JSON.parse(readFileSync(resolve(__dirname, "../../../package.json"), "utf-8"));
|
|
32
|
+
const CURRENT_VERSION = PKG.version;
|
|
33
|
+
const NUDGE = /A newer .*skillrepo.* is available/;
|
|
34
|
+
|
|
35
|
+
let sandbox;
|
|
36
|
+
let home;
|
|
37
|
+
let server;
|
|
38
|
+
let serverUrl;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Write a fresh positive cache entry advertising a much higher published
|
|
42
|
+
* version. currentCliVersion MUST equal the live version or isCacheFresh
|
|
43
|
+
* discards the entry and falls back to a (here, unroutable) network fetch.
|
|
44
|
+
*/
|
|
45
|
+
function armCacheNudge() {
|
|
46
|
+
const dir = join(home, ".claude", "skillrepo");
|
|
47
|
+
mkdirSync(dir, { recursive: true });
|
|
48
|
+
writeFileSync(
|
|
49
|
+
join(dir, ".npm-version-check"),
|
|
50
|
+
JSON.stringify({
|
|
51
|
+
schemaVersion: 1,
|
|
52
|
+
checkedAt: new Date().toISOString(),
|
|
53
|
+
currentCliVersion: CURRENT_VERSION,
|
|
54
|
+
latestPublishedVersion: "999.0.0",
|
|
55
|
+
fetchOk: true,
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function setup() {
|
|
61
|
+
sandbox = mkdtempSync(join(tmpdir(), "cli-update-nudge-"));
|
|
62
|
+
home = join(sandbox, "home");
|
|
63
|
+
mkdirSync(home, { recursive: true });
|
|
64
|
+
server = createMockServer({});
|
|
65
|
+
const port = await server.start();
|
|
66
|
+
serverUrl = `http://127.0.0.1:${port}`;
|
|
67
|
+
server.setEtag('"v1"');
|
|
68
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "2025-01-01T00:00:00Z" });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function teardown() {
|
|
72
|
+
if (server) await server.stop();
|
|
73
|
+
if (sandbox) rmSync(sandbox, { recursive: true, force: true });
|
|
74
|
+
server = null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function runCli(args, extraEnv = {}) {
|
|
78
|
+
return new Promise((res) => {
|
|
79
|
+
execFile(
|
|
80
|
+
process.execPath,
|
|
81
|
+
[CLI_BIN, ...args],
|
|
82
|
+
{
|
|
83
|
+
encoding: "utf-8",
|
|
84
|
+
timeout: 10_000,
|
|
85
|
+
env: {
|
|
86
|
+
...process.env,
|
|
87
|
+
HOME: home,
|
|
88
|
+
USERPROFILE: home,
|
|
89
|
+
NO_COLOR: "1",
|
|
90
|
+
// Force the update-check ON: CI auto-disables it, and the test
|
|
91
|
+
// suite itself often runs under CI=true. Clear both switches.
|
|
92
|
+
CI: "",
|
|
93
|
+
SKILLREPO_NO_UPDATE_CHECK: "",
|
|
94
|
+
SKILLREPO_ACCESS_KEY: "",
|
|
95
|
+
SKILLREPO_TIMEOUT_MS: "2000",
|
|
96
|
+
...extraEnv,
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
(err, stdout, stderr) =>
|
|
100
|
+
res({ stdout: stdout ?? "", stderr: stderr ?? "", status: err ? (err.code ?? 1) : 0 }),
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
describe("dispatcher — update-check nudge + --json suppression (#1554)", () => {
|
|
106
|
+
beforeEach(setup);
|
|
107
|
+
afterEach(teardown);
|
|
108
|
+
|
|
109
|
+
it("emits the upgrade nudge on stderr after a successful non-JSON command", async () => {
|
|
110
|
+
// This is BOTH a real assertion and the control that proves the next
|
|
111
|
+
// test isn't vacuous: if the armed cache didn't produce a nudge here,
|
|
112
|
+
// the suppression test would pass for the wrong reason.
|
|
113
|
+
armCacheNudge();
|
|
114
|
+
const r = await runCli(["list", "--key", "sk_live_x", "--url", serverUrl]);
|
|
115
|
+
assert.equal(r.status, 0, `list should succeed; stderr: ${r.stderr}`);
|
|
116
|
+
assert.match(r.stderr, NUDGE, "armed cache must produce the upgrade nudge on a non-JSON command");
|
|
117
|
+
assert.match(
|
|
118
|
+
r.stderr,
|
|
119
|
+
new RegExp(`${CURRENT_VERSION.replace(/\./g, "\\.")}.*999\\.0\\.0`),
|
|
120
|
+
"nudge shows current → latest version",
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("suppresses the nudge entirely under --json (stdout pure JSON, stderr nudge-free)", async () => {
|
|
125
|
+
armCacheNudge();
|
|
126
|
+
const r = await runCli(["list", "--key", "sk_live_x", "--url", serverUrl, "--json"]);
|
|
127
|
+
assert.equal(r.status, 0, `list --json should succeed; stderr: ${r.stderr}`);
|
|
128
|
+
assert.doesNotMatch(r.stderr, NUDGE, "no nudge on stderr under --json");
|
|
129
|
+
assert.doesNotMatch(r.stdout, NUDGE, "no nudge on stdout under --json");
|
|
130
|
+
assert.doesNotThrow(
|
|
131
|
+
() => JSON.parse(r.stdout),
|
|
132
|
+
"stdout under --json must be pure JSON even with an armed update-check cache",
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -883,6 +883,72 @@ describe("remove → list cross-command contract", () => {
|
|
|
883
883
|
assert.equal(state.etag, '"v1"', "remove must not clobber the library etag");
|
|
884
884
|
});
|
|
885
885
|
|
|
886
|
+
it("remove → re-add round trip: skill lands again and list shows it current (#1911 acceptance)", async () => {
|
|
887
|
+
// #1911 acceptance criterion that had no test: "Remove then re-add an
|
|
888
|
+
// older skill → re-delivered." remove.mjs purges the .last-sync
|
|
889
|
+
// baseline entry specifically so a future re-add doesn't compare
|
|
890
|
+
// against a stale baseline and mis-report drift. Exercise the full
|
|
891
|
+
// round trip and prove the re-added skill is on disk AND reported
|
|
892
|
+
// `current` (not `edited`/`missing` from a leftover baseline).
|
|
893
|
+
process.env.CLAUDECODE = "1";
|
|
894
|
+
|
|
895
|
+
// 1) Seed: sync the skill so it's on disk + tracked in .last-sync.
|
|
896
|
+
server.setEtag('"v1"');
|
|
897
|
+
server.setLibraryResponse({
|
|
898
|
+
skills: [makeSkill("alice", "roundtrip", "1.0.0")],
|
|
899
|
+
removals: [],
|
|
900
|
+
syncedAt: "2026-01-01T00:00:00Z",
|
|
901
|
+
});
|
|
902
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
903
|
+
const dir = resolvePlacementDir("claudeProject", "roundtrip");
|
|
904
|
+
assert.ok(existsSync(join(dir, "SKILL.md")), "precondition: roundtrip on disk after sync");
|
|
905
|
+
assert.ok(readLastSync().skills["alice/roundtrip"], "precondition: tracked in .last-sync");
|
|
906
|
+
|
|
907
|
+
// 2) remove: deletes local files + purges the baseline entry.
|
|
908
|
+
server.setRemoveResponseForAny({
|
|
909
|
+
status: 200,
|
|
910
|
+
body: { removed: { owner: "alice", name: "roundtrip" } },
|
|
911
|
+
});
|
|
912
|
+
await runRemove(["@alice/roundtrip", "--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
913
|
+
assert.ok(!existsSync(join(dir, "SKILL.md")), "remove deletes the local directory");
|
|
914
|
+
assert.equal(
|
|
915
|
+
readLastSync().skills["alice/roundtrip"],
|
|
916
|
+
undefined,
|
|
917
|
+
"remove purges the .last-sync baseline entry",
|
|
918
|
+
);
|
|
919
|
+
|
|
920
|
+
// 3) re-add the SAME skill.
|
|
921
|
+
server.setAddResponseForAny({
|
|
922
|
+
status: 201,
|
|
923
|
+
body: { added: { owner: "alice", name: "roundtrip", version: "1.0.0", addedAt: "2026-02-01T00:00:00Z" } },
|
|
924
|
+
});
|
|
925
|
+
server.setSkillResponse("alice", "roundtrip", makeSkill("alice", "roundtrip", "1.0.0"));
|
|
926
|
+
stdout = createCaptureStream();
|
|
927
|
+
await runAdd(["@alice/roundtrip", "--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
928
|
+
assert.ok(
|
|
929
|
+
existsSync(join(dir, "SKILL.md")),
|
|
930
|
+
"re-added skill MUST land back on disk",
|
|
931
|
+
);
|
|
932
|
+
|
|
933
|
+
// 4) list: must report `current`, proving the re-add wrote a fresh
|
|
934
|
+
// baseline rather than colliding with the purged-then-stale one.
|
|
935
|
+
server.setEtag('"v2"');
|
|
936
|
+
server.setLibraryResponse({
|
|
937
|
+
skills: [makeSkill("alice", "roundtrip", "1.0.0")],
|
|
938
|
+
removals: [],
|
|
939
|
+
syncedAt: "2026-02-01T00:00:00Z",
|
|
940
|
+
});
|
|
941
|
+
stdout = createCaptureStream();
|
|
942
|
+
await runList(["--key", VALID_KEY, "--url", serverUrl, "--json"], { stdout });
|
|
943
|
+
const [item] = JSON.parse(stdout.text());
|
|
944
|
+
assert.equal(item.name, "roundtrip");
|
|
945
|
+
assert.equal(
|
|
946
|
+
item.state,
|
|
947
|
+
"current",
|
|
948
|
+
"re-added skill must be current — remove must have cleared the stale baseline",
|
|
949
|
+
);
|
|
950
|
+
});
|
|
951
|
+
|
|
886
952
|
it("removing a skill that was never in .last-sync is idempotent (no throw, no spurious write)", async () => {
|
|
887
953
|
// Edge case: user runs `skillrepo remove @alice/never-synced`
|
|
888
954
|
// for a skill that was never on disk and was never tracked. The
|
|
@@ -184,4 +184,47 @@ describe("openBrowser", () => {
|
|
|
184
184
|
});
|
|
185
185
|
assert.equal(calls[0].options.shell, undefined);
|
|
186
186
|
});
|
|
187
|
+
|
|
188
|
+
// ── default-parameter branches (no options object) ────────────────
|
|
189
|
+
|
|
190
|
+
it("falls back to process.platform when `platform` is omitted", () => {
|
|
191
|
+
// Covers the `platform ?? process.platform` default. We inject a spawnFn
|
|
192
|
+
// so no real browser launches; the host CI platform (darwin/linux on the
|
|
193
|
+
// Linux+macOS runners, win32 on the Windows runner) is supported, so the
|
|
194
|
+
// default resolves to a real launch command and the spawn fires.
|
|
195
|
+
const { spawnFn, calls } = makeSpawnStub();
|
|
196
|
+
const result = openBrowser("https://skillrepo.dev/cli/auth", { spawnFn });
|
|
197
|
+
assert.equal(result, true);
|
|
198
|
+
assert.equal(calls.length, 1);
|
|
199
|
+
// Platform-agnostic: darwin → "open", linux → "xdg-open", win32 →
|
|
200
|
+
// "cmd.exe". Asserting a specific command would break on whichever host
|
|
201
|
+
// runs the suite; a non-empty resolved command proves the default fired
|
|
202
|
+
// (an undefined platform would make resolveLaunchCommand return false).
|
|
203
|
+
assert.ok(
|
|
204
|
+
typeof calls[0].command === "string" && calls[0].command.length > 0,
|
|
205
|
+
`the default host platform must resolve to a launch command, got ${calls[0].command}`,
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("falls back to the real child_process.spawn when `spawnFn` is omitted", () => {
|
|
210
|
+
// Covers the `spawnFn ?? nodeSpawn` default WITHOUT actually
|
|
211
|
+
// launching anything: an unsupported platform returns false from
|
|
212
|
+
// resolveLaunchCommand BEFORE the spawn call, so the default
|
|
213
|
+
// nodeSpawn is selected (exercising the `??`) but never invoked.
|
|
214
|
+
const result = openBrowser("https://skillrepo.dev/cli/auth", {
|
|
215
|
+
platform: "plan9",
|
|
216
|
+
});
|
|
217
|
+
assert.equal(result, false, "unsupported platform returns false before spawning");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("works with no options object at all (both defaults resolve)", () => {
|
|
221
|
+
// The signature defaults the whole options object to `{}`. Calling
|
|
222
|
+
// with a rejected URL exits at the `isLaunchableUrl` guard before
|
|
223
|
+
// touching the defaults — but calling with a real http URL on the
|
|
224
|
+
// host platform exercises both `?? process.platform` and
|
|
225
|
+
// `?? nodeSpawn`. We can't safely launch a real browser in CI, so we
|
|
226
|
+
// assert the early-return path with an invalid URL instead, which is
|
|
227
|
+
// the safe way to call the zero-arg form deterministically.
|
|
228
|
+
assert.equal(openBrowser("not-a-url"), false);
|
|
229
|
+
});
|
|
187
230
|
});
|
|
@@ -138,6 +138,36 @@ describe("readConfig", () => {
|
|
|
138
138
|
writeFileSync(path, "[]");
|
|
139
139
|
assert.equal(readConfig(), null);
|
|
140
140
|
});
|
|
141
|
+
|
|
142
|
+
it("returns null when the JSON parses to a non-object primitive (e.g. `null`)", () => {
|
|
143
|
+
// `!parsed || typeof parsed !== "object"` — a file containing the
|
|
144
|
+
// literal `null` parses successfully but is falsy, so readConfig
|
|
145
|
+
// bails on the !parsed branch. The `[]`/`{}` cases above are both
|
|
146
|
+
// `typeof === "object"` and never reach this branch.
|
|
147
|
+
const path = globalConfigPath();
|
|
148
|
+
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
|
|
149
|
+
writeFileSync(path, "null");
|
|
150
|
+
assert.equal(readConfig(), null);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("returns null when the JSON parses to a number (typeof !== object)", () => {
|
|
154
|
+
const path = globalConfigPath();
|
|
155
|
+
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
|
|
156
|
+
writeFileSync(path, "42");
|
|
157
|
+
assert.equal(readConfig(), null);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it(
|
|
161
|
+
"returns null when readFileSync throws (path is a directory → EISDIR)",
|
|
162
|
+
{ skip: process.platform === "win32" },
|
|
163
|
+
() => {
|
|
164
|
+
// existsSync(path) is true (the directory exists) but readFileSync
|
|
165
|
+
// on a directory throws EISDIR → the read try/catch returns null.
|
|
166
|
+
const path = globalConfigPath();
|
|
167
|
+
mkdirSync(path, { recursive: true });
|
|
168
|
+
assert.equal(readConfig(), null);
|
|
169
|
+
},
|
|
170
|
+
);
|
|
141
171
|
});
|
|
142
172
|
|
|
143
173
|
// ── writeConfig ────────────────────────────────────────────────────────
|
|
@@ -275,6 +305,46 @@ describe("writeConfig", () => {
|
|
|
275
305
|
chmodSync(grandParent, 0o755);
|
|
276
306
|
}
|
|
277
307
|
});
|
|
308
|
+
|
|
309
|
+
it(
|
|
310
|
+
"throws diskError when the temp file can't be written (tmp path is a directory)",
|
|
311
|
+
{ skip: process.platform === "win32" },
|
|
312
|
+
() => {
|
|
313
|
+
// The parent dir exists and is writable, but `<path>.tmp` is itself
|
|
314
|
+
// a directory, so writeFileSync(tmpPath) throws EISDIR → the
|
|
315
|
+
// temp-write try/catch surfaces a diskError. This covers the
|
|
316
|
+
// tmp-write failure branch distinct from the parent-dir branch.
|
|
317
|
+
const path = globalConfigPath();
|
|
318
|
+
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
|
|
319
|
+
mkdirSync(`${path}.tmp`, { recursive: true });
|
|
320
|
+
assert.throws(
|
|
321
|
+
() => writeConfig({ apiKey: "sk_live_abc", serverUrl: "https://example.com" }),
|
|
322
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_DISK,
|
|
323
|
+
);
|
|
324
|
+
},
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
it(
|
|
328
|
+
"throws diskError when the rename fails and still cleans up the temp file",
|
|
329
|
+
{ skip: process.platform === "win32" },
|
|
330
|
+
() => {
|
|
331
|
+
// Destination `path` is a NON-EMPTY directory: the temp write +
|
|
332
|
+
// chmod succeed, but renameSync(tmp, path) throws (can't replace a
|
|
333
|
+
// non-empty dir with a file). This exercises the rename try/catch
|
|
334
|
+
// AND the best-effort tmp cleanup (the .tmp file must be removed so
|
|
335
|
+
// a live key isn't left on disk).
|
|
336
|
+
const path = globalConfigPath();
|
|
337
|
+
mkdirSync(path, { recursive: true });
|
|
338
|
+
// Make the destination directory non-empty so rename can't clobber it.
|
|
339
|
+
writeFileSync(join(path, "occupant"), "x");
|
|
340
|
+
assert.throws(
|
|
341
|
+
() => writeConfig({ apiKey: "sk_live_abc", serverUrl: "https://example.com" }),
|
|
342
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_DISK,
|
|
343
|
+
);
|
|
344
|
+
// The temp file must have been cleaned up (no live key left behind).
|
|
345
|
+
assert.ok(!existsSync(`${path}.tmp`), "stale .tmp must be unlinked on rename failure");
|
|
346
|
+
},
|
|
347
|
+
);
|
|
278
348
|
});
|
|
279
349
|
|
|
280
350
|
// ── clearConfig ────────────────────────────────────────────────────────
|
|
@@ -293,4 +363,21 @@ describe("clearConfig", () => {
|
|
|
293
363
|
assert.equal(clearConfig(), true);
|
|
294
364
|
assert.ok(!existsSync(globalConfigPath()));
|
|
295
365
|
});
|
|
366
|
+
|
|
367
|
+
it(
|
|
368
|
+
"throws diskError when the path can't be unlinked (path is a non-empty directory)",
|
|
369
|
+
{ skip: process.platform === "win32" },
|
|
370
|
+
() => {
|
|
371
|
+
// existsSync(path) is true so clearConfig attempts unlinkSync, but
|
|
372
|
+
// unlinking a non-empty directory throws (EISDIR/ENOTEMPTY/EPERM) →
|
|
373
|
+
// the catch surfaces a diskError rather than silently swallowing.
|
|
374
|
+
const path = globalConfigPath();
|
|
375
|
+
mkdirSync(path, { recursive: true });
|
|
376
|
+
writeFileSync(join(path, "occupant"), "x");
|
|
377
|
+
assert.throws(
|
|
378
|
+
() => clearConfig(),
|
|
379
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_DISK,
|
|
380
|
+
);
|
|
381
|
+
},
|
|
382
|
+
);
|
|
296
383
|
});
|
|
@@ -156,6 +156,23 @@ describe("computeSkillShas", () => {
|
|
|
156
156
|
assert.equal(result.filesSha256, sha256Utf8(expectedProjection));
|
|
157
157
|
});
|
|
158
158
|
|
|
159
|
+
it("handles equal paths in the sort comparator (returns 0 arm)", () => {
|
|
160
|
+
// The internal byte-order comparator's equal-path arm
|
|
161
|
+
// (`a.path === b.path → 0`) is only reached when two entries share
|
|
162
|
+
// a path. computeSkillShas does not dedupe (that's validateSkill's
|
|
163
|
+
// job upstream), so identical paths are valid input here and must
|
|
164
|
+
// not crash. With two identical entries the projection is the same
|
|
165
|
+
// line twice, joined by "\n".
|
|
166
|
+
const result = computeSkillShas([
|
|
167
|
+
{ path: "dup.md", content: "one\n" },
|
|
168
|
+
{ path: "dup.md", content: "one\n" },
|
|
169
|
+
]);
|
|
170
|
+
const shaDup = sha256Utf8("one\n");
|
|
171
|
+
const expectedProjection = `dup.md|${shaDup}\ndup.md|${shaDup}`;
|
|
172
|
+
assert.equal(result.filesSha256, sha256Utf8(expectedProjection));
|
|
173
|
+
assert.equal(result.skillMdSha256, null);
|
|
174
|
+
});
|
|
175
|
+
|
|
159
176
|
it("throws TypeError on non-array input", () => {
|
|
160
177
|
assert.throws(() => computeSkillShas(null), TypeError);
|
|
161
178
|
assert.throws(() => computeSkillShas(undefined), TypeError);
|