skillrepo 3.1.0 → 3.1.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.
Files changed (45) hide show
  1. package/README.md +6 -2
  2. package/package.json +1 -1
  3. package/src/commands/init-session-sync.mjs +307 -0
  4. package/src/commands/init.mjs +111 -101
  5. package/src/commands/session-sync-actions.mjs +92 -0
  6. package/src/lib/artifact-registry.mjs +43 -3
  7. package/src/lib/binary-locator.mjs +99 -0
  8. package/src/lib/cli-config.mjs +16 -3
  9. package/src/lib/cli-version.mjs +56 -0
  10. package/src/lib/config.mjs +6 -3
  11. package/src/lib/file-write.mjs +8 -3
  12. package/src/lib/fs-utils.mjs +9 -10
  13. package/src/lib/global-install.mjs +387 -0
  14. package/src/lib/mcp-merge.mjs +16 -5
  15. package/src/lib/mergers/session-hook.mjs +125 -33
  16. package/src/lib/platform.mjs +124 -0
  17. package/src/lib/sync.mjs +26 -0
  18. package/src/lib/transient-runners.mjs +204 -0
  19. package/src/test/commands/add.test.mjs +10 -4
  20. package/src/test/commands/get.test.mjs +10 -4
  21. package/src/test/commands/init.test.mjs +889 -15
  22. package/src/test/commands/list.test.mjs +10 -4
  23. package/src/test/commands/remove.test.mjs +10 -4
  24. package/src/test/commands/search.test.mjs +10 -4
  25. package/src/test/commands/session-sync-actions.test.mjs +74 -0
  26. package/src/test/commands/session-sync.test.mjs +25 -23
  27. package/src/test/commands/uninstall.test.mjs +20 -14
  28. package/src/test/commands/update.test.mjs +10 -4
  29. package/src/test/helpers/mock-spawn.mjs +121 -0
  30. package/src/test/helpers/sandbox-home.mjs +161 -0
  31. package/src/test/helpers/skillrepo-shim.mjs +133 -0
  32. package/src/test/integration/file-write.integration.test.mjs +10 -4
  33. package/src/test/lib/cli-config.test.mjs +182 -4
  34. package/src/test/lib/cli-version.test.mjs +47 -0
  35. package/src/test/lib/config.test.mjs +10 -4
  36. package/src/test/lib/file-write.test.mjs +24 -10
  37. package/src/test/lib/global-install.test.mjs +424 -0
  38. package/src/test/lib/mcp-merge.test.mjs +13 -7
  39. package/src/test/lib/paths.test.mjs +10 -4
  40. package/src/test/lib/platform.test.mjs +135 -0
  41. package/src/test/lib/sync.test.mjs +20 -4
  42. package/src/test/lib/transient-runners.test.mjs +270 -0
  43. package/src/test/mergers/session-hook.test.mjs +722 -22
  44. package/src/test/mergers/uninstall-settings.test.mjs +12 -1
  45. package/src/test/mergers/uninstall-windsurf-mcp.test.mjs +10 -4
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Unit tests for src/lib/platform.mjs (v3.1.1).
3
+ *
4
+ * INTENT-BASED. The platform module is the single source of truth
5
+ * for every platform-specific difference the CLI honors. These
6
+ * tests lock in the contract that consumers (session-hook merger,
7
+ * future vendors) depend on. If a future refactor adds a new
8
+ * convention or changes an existing one, these tests must be
9
+ * updated — the data flow through this module is load-bearing.
10
+ */
11
+
12
+ import { describe, it } from "node:test";
13
+ import assert from "node:assert/strict";
14
+ import { platform as osPlatform } from "node:os";
15
+
16
+ import { platformConventions, isWindows } from "../../lib/platform.mjs";
17
+
18
+ describe("platformConventions", () => {
19
+ it("returns POSIX conventions on macOS (darwin)", () => {
20
+ // INTENT: macOS is a POSIX family platform. `which` is the
21
+ // binary locator; shell supports `|| true` backstop; chmod
22
+ // works meaningfully; atomic directory rename works. A refactor
23
+ // that treats macOS as a special case would break every
24
+ // developer on the team.
25
+ const conv = platformConventions({ platform: "darwin" });
26
+ assert.equal(conv.family, "posix");
27
+ assert.equal(conv.binaryLocator, "which");
28
+ assert.equal(conv.hookShellSuffix, " || true");
29
+ assert.equal(conv.supportsPosixPermissions, true);
30
+ assert.equal(conv.supportsAtomicDirectoryRename, true);
31
+ });
32
+
33
+ it("returns POSIX conventions on Linux", () => {
34
+ const conv = platformConventions({ platform: "linux" });
35
+ assert.equal(conv.family, "posix");
36
+ assert.equal(conv.binaryLocator, "which");
37
+ assert.equal(conv.hookShellSuffix, " || true");
38
+ assert.equal(conv.supportsPosixPermissions, true);
39
+ assert.equal(conv.supportsAtomicDirectoryRename, true);
40
+ });
41
+
42
+ it("returns Windows conventions on win32", () => {
43
+ // INTENT: Windows has four material differences that can't be
44
+ // abstracted away:
45
+ // 1. `where.exe` instead of `which` (no shell binary lookup)
46
+ // 2. no `|| true` (cmd.exe doesn't know the `true` builtin)
47
+ // 3. chmod mode bits don't map to the ACL model — applying
48
+ // 0o600 on Windows looks like it worked but doesn't
49
+ // restrict access the way a POSIX 0600 does
50
+ // 4. renameSync fails on existing directory targets, so
51
+ // directory replacement needs a remove-then-rename dance
52
+ // with a small non-atomic window
53
+ // This test locks all four values for Windows — a refactor
54
+ // that breaks any one of them breaks every Windows user.
55
+ const conv = platformConventions({ platform: "win32" });
56
+ assert.equal(conv.family, "windows");
57
+ assert.equal(conv.binaryLocator, "where");
58
+ assert.equal(conv.hookShellSuffix, "");
59
+ assert.equal(conv.supportsPosixPermissions, false);
60
+ assert.equal(conv.supportsAtomicDirectoryRename, false);
61
+ });
62
+
63
+ it("treats unknown platforms as POSIX (conservative default)", () => {
64
+ // INTENT: Node supports aix, freebsd, openbsd, sunos, netbsd,
65
+ // android — all POSIX-family. A newly-released `NodeJS.Platform`
66
+ // value we've never seen must NOT crash the CLI. Falling back
67
+ // to POSIX is correct for every non-Windows Node target and
68
+ // matches how `node:path` / `node:fs` handle the same cases.
69
+ const conv = platformConventions({ platform: "aix" });
70
+ assert.equal(conv.family, "posix");
71
+ assert.equal(conv.binaryLocator, "which");
72
+ });
73
+
74
+ it("uses os.platform() when called without an override", () => {
75
+ // INTENT: production callers let the override default. Verify
76
+ // the default correctly reflects the current runtime, so a
77
+ // CI run on Linux sees POSIX conventions without the test
78
+ // needing to know the specific platform.
79
+ const conv = platformConventions();
80
+ const actualPlatform = osPlatform();
81
+ if (actualPlatform === "win32") {
82
+ assert.equal(conv.family, "windows");
83
+ } else {
84
+ assert.equal(conv.family, "posix");
85
+ }
86
+ });
87
+
88
+ it("returns a frozen object — callers cannot mutate conventions at runtime", () => {
89
+ // INTENT: the whole point of the single-source-of-truth design
90
+ // is that nobody gets to reach in and rewrite the rules. If a
91
+ // caller mutated the returned object, the next caller would
92
+ // see unexpected values. Freezing guarantees isolation.
93
+ const conv = platformConventions({ platform: "linux" });
94
+ assert.ok(Object.isFrozen(conv), "conventions object must be frozen");
95
+ assert.throws(
96
+ () => {
97
+ conv.binaryLocator = "pwned";
98
+ },
99
+ /Cannot assign to read only property/,
100
+ "attempting to mutate a frozen convention must throw in strict mode",
101
+ );
102
+ });
103
+
104
+ it("returns the SAME frozen instance across calls for the same platform", () => {
105
+ // INTENT: identity guarantee. Two calls with the same platform
106
+ // return the same object — callers can cache without worrying
107
+ // about subtle drift between invocations.
108
+ const a = platformConventions({ platform: "darwin" });
109
+ const b = platformConventions({ platform: "darwin" });
110
+ assert.strictEqual(a, b);
111
+
112
+ const c = platformConventions({ platform: "win32" });
113
+ const d = platformConventions({ platform: "win32" });
114
+ assert.strictEqual(c, d);
115
+
116
+ // And of course, two different platforms don't collide:
117
+ assert.notStrictEqual(a, c);
118
+ });
119
+ });
120
+
121
+ describe("isWindows", () => {
122
+ it("returns true only when platform is win32", () => {
123
+ assert.equal(isWindows({ platform: "win32" }), true);
124
+ assert.equal(isWindows({ platform: "darwin" }), false);
125
+ assert.equal(isWindows({ platform: "linux" }), false);
126
+ assert.equal(isWindows({ platform: "aix" }), false);
127
+ });
128
+
129
+ it("defaults to os.platform() when called without an override", () => {
130
+ // INTENT: same default as platformConventions — production
131
+ // callers get the right answer for their runtime platform.
132
+ const actual = osPlatform();
133
+ assert.equal(isWindows(), actual === "win32");
134
+ });
135
+ });
@@ -39,10 +39,16 @@ import { resolvePlacementDir } from "../../lib/file-write.mjs";
39
39
  import { globalLastSyncPath } from "../../lib/paths.mjs";
40
40
  import { CliError, EXIT_VALIDATION } from "../../lib/errors.mjs";
41
41
  import { createMockServer } from "../e2e/mock-server.mjs";
42
+ import {
43
+ captureHome,
44
+ setSandboxHome,
45
+ restoreHome,
46
+ } from "../helpers/sandbox-home.mjs";
42
47
 
43
48
  let sandbox;
44
49
  let originalCwd;
45
- let originalHome;
50
+ /** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
51
+ let originalHomeEnv;
46
52
  let server;
47
53
  let serverUrl;
48
54
  const VALID_KEY = "sk_live_test123";
@@ -71,9 +77,9 @@ async function setupServer() {
71
77
  mkdirSync(join(sandbox, "project"), { recursive: true });
72
78
  mkdirSync(join(sandbox, "home"), { recursive: true });
73
79
  originalCwd = process.cwd();
74
- originalHome = process.env.HOME;
80
+ originalHomeEnv = captureHome();
75
81
  process.chdir(join(sandbox, "project"));
76
- process.env.HOME = join(sandbox, "home");
82
+ setSandboxHome(join(sandbox, "home"));
77
83
 
78
84
  server = createMockServer({});
79
85
  const port = await server.start();
@@ -83,7 +89,7 @@ async function setupServer() {
83
89
  async function teardownServer() {
84
90
  if (server) await server.stop();
85
91
  process.chdir(originalCwd);
86
- process.env.HOME = originalHome;
92
+ restoreHome(originalHomeEnv);
87
93
  if (sandbox) rmSync(sandbox, { recursive: true, force: true });
88
94
  server = null;
89
95
  }
@@ -146,6 +152,10 @@ describe("runSync — empty library", () => {
146
152
  assert.equal(result.updated, 0);
147
153
  assert.equal(result.removed, 0);
148
154
  assert.equal(result.notModified, false);
155
+ // v3.1.1: no prior .last-sync state → this was a full sync.
156
+ // init.mjs distinguishes full vs delta to render accurate
157
+ // zero-counters messages (empty library vs up to date).
158
+ assert.equal(result.fullSync, true, "no last-sync state means fullSync:true");
149
159
  });
150
160
  });
151
161
 
@@ -338,6 +348,12 @@ describe("runSync — ETag round-trip", () => {
338
348
  assert.equal(second.added, 0);
339
349
  assert.equal(second.updated, 0);
340
350
  assert.equal(second.removed, 0);
351
+ // v3.1.1: first sync had no prior state → fullSync:true. Second
352
+ // sync had the first's .last-sync written → fullSync:false.
353
+ // init.mjs uses this to distinguish the "empty library" vs
354
+ // "nothing changed since last sync" cases when counters are all 0.
355
+ assert.equal(first.fullSync, true, "first sync must report fullSync:true");
356
+ assert.equal(second.fullSync, false, "304 after prior sync must report fullSync:false");
341
357
  });
342
358
 
343
359
  it("304 short-circuit does not delete or modify on-disk skills", async () => {
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Unit tests for src/lib/transient-runners.mjs (#894 / v3.1.2).
3
+ *
4
+ * Covers the three exports:
5
+ * - detectTransientRunner — returns the runner's display NAME
6
+ * (npx / pnpx / yarn dlx / bunx) or null. Used by init.mjs's
7
+ * Next-Steps prefix.
8
+ * - isTransientRunnerInvocation — boolean shortcut. Used as the
9
+ * gate for auto-install and the npx-guard in mergers/session-hook.
10
+ * - isTransientCachePath — boolean for filtering `where`/`which`
11
+ * output. Used by the binary locator.
12
+ *
13
+ * The boolean shortcut's behavior is also covered transitively by
14
+ * `cli-config.test.mjs` (which re-exports it). This file specifically
15
+ * locks in:
16
+ * - The runner-NAME return contract (v3.1.2 added this so init's
17
+ * Next-Steps shows `pnpx skillrepo list` for pnpm dlx users
18
+ * instead of always `npx skillrepo list`).
19
+ * - The `isTransientCachePath` filter behavior the binary locator
20
+ * depends on.
21
+ */
22
+
23
+ import { describe, it } from "node:test";
24
+ import assert from "node:assert/strict";
25
+
26
+ import {
27
+ detectTransientRunner,
28
+ isTransientRunnerInvocation,
29
+ isTransientCachePath,
30
+ globalInstallCommandFor,
31
+ } from "../../lib/transient-runners.mjs";
32
+
33
+ // ── detectTransientRunner — returns the runner display name ────────
34
+
35
+ describe("detectTransientRunner", () => {
36
+ it("returns null for a vanilla stable-install argv", () => {
37
+ const result = detectTransientRunner({
38
+ argv: ["/usr/local/bin/node", "/usr/local/bin/skillrepo"],
39
+ env: {},
40
+ });
41
+ assert.equal(result, null);
42
+ });
43
+
44
+ it("returns 'npx' for an npm npx invocation", () => {
45
+ const result = detectTransientRunner({
46
+ argv: [
47
+ "/usr/local/bin/node",
48
+ "/Users/alice/.npm/_npx/abc123/node_modules/.bin/skillrepo",
49
+ ],
50
+ env: {},
51
+ });
52
+ assert.equal(result, "npx");
53
+ });
54
+
55
+ it("returns 'pnpx' for a pnpm dlx invocation (cache substring)", () => {
56
+ const result = detectTransientRunner({
57
+ argv: [
58
+ "/usr/local/bin/node",
59
+ "/Users/alice/.local/share/pnpm/store/dlx-abc123/node_modules/.bin/skillrepo",
60
+ ],
61
+ env: {},
62
+ });
63
+ assert.equal(result, "pnpx");
64
+ });
65
+
66
+ it("returns 'yarn dlx' for a yarn berry dlx invocation", () => {
67
+ const result = detectTransientRunner({
68
+ argv: [
69
+ "/usr/local/bin/node",
70
+ "/Users/alice/project/.yarn/berry/cache/skillrepo-npm-3.1.2-abc/node_modules/skillrepo/bin/skillrepo.mjs",
71
+ ],
72
+ env: {},
73
+ });
74
+ assert.equal(result, "yarn dlx");
75
+ });
76
+
77
+ it("returns 'bunx' for a bun bunx invocation (cache substring)", () => {
78
+ const result = detectTransientRunner({
79
+ argv: [
80
+ "/usr/local/bin/node",
81
+ "/Users/alice/.bun/install/cache/skillrepo@3.1.2/node_modules/.bin/skillrepo",
82
+ ],
83
+ env: {},
84
+ });
85
+ assert.equal(result, "bunx");
86
+ });
87
+
88
+ it("returns 'npx' from `_` env-var fallback when argv path doesn't match", () => {
89
+ // E.g., a wrapper script symlinks the npx binary somewhere
90
+ // outside the npx cache. The `_` signal still identifies the
91
+ // launcher.
92
+ const result = detectTransientRunner({
93
+ argv: ["/usr/local/bin/node", "/somewhere/bin/skillrepo"],
94
+ env: { _: "/usr/local/bin/npx" },
95
+ });
96
+ assert.equal(result, "npx");
97
+ });
98
+
99
+ it("returns 'pnpx' from `_` env-var fallback", () => {
100
+ const result = detectTransientRunner({
101
+ argv: ["/usr/local/bin/node", "/somewhere/bin/skillrepo"],
102
+ env: { _: "/usr/local/bin/pnpx" },
103
+ });
104
+ assert.equal(result, "pnpx");
105
+ });
106
+
107
+ it("returns 'bunx' from `_` env-var fallback", () => {
108
+ const result = detectTransientRunner({
109
+ argv: ["/usr/local/bin/node", "/somewhere/bin/skillrepo"],
110
+ env: { _: "/Users/alice/.bun/bin/bunx" },
111
+ });
112
+ assert.equal(result, "bunx");
113
+ });
114
+
115
+ it("argv signal beats `_` env-var (first-match wins on argv)", () => {
116
+ // If both signal a runner, argv wins. Realistically these would
117
+ // signal the SAME runner (npx invocation sets argv to the npx
118
+ // cache AND `_` to the npx binary), but the test pins the
119
+ // priority for the rare disagreement case.
120
+ const result = detectTransientRunner({
121
+ argv: [
122
+ "/usr/local/bin/node",
123
+ "/Users/alice/.bun/install/cache/skillrepo@3.1.2/node_modules/.bin/skillrepo",
124
+ ],
125
+ env: { _: "/usr/local/bin/npx" },
126
+ });
127
+ // bunx wins because argv is checked first.
128
+ assert.equal(result, "bunx");
129
+ });
130
+
131
+ it("does not false-positive on a directory named 'dlx-utils' (substring not in pnpm cache)", () => {
132
+ // Defensive: `/dlx-` is the substring, but a user-named directory
133
+ // like `/Users/alice/utility-dlx/` doesn't trigger because the
134
+ // substring requires a leading `/` separator before `dlx-`.
135
+ const result = detectTransientRunner({
136
+ argv: ["/usr/local/bin/node", "/Users/alice/utility-dlx/bin/skillrepo"],
137
+ env: {},
138
+ });
139
+ assert.equal(result, null);
140
+ });
141
+
142
+ it("does not false-positive on an `npx`-named user dir without the _npx cache pattern", () => {
143
+ const result = detectTransientRunner({
144
+ argv: ["/usr/local/bin/node", "/opt/npxtools/bin/skillrepo"],
145
+ env: {},
146
+ });
147
+ assert.equal(result, null);
148
+ });
149
+ });
150
+
151
+ // ── isTransientRunnerInvocation — boolean shortcut ──────────────────
152
+
153
+ describe("isTransientRunnerInvocation (boolean shortcut)", () => {
154
+ // The boolean version reads from process.argv / process.env directly
155
+ // (no override hook for this convenience wrapper). The full table
156
+ // of detection cases is covered above via detectTransientRunner;
157
+ // these tests just verify the boolean shape.
158
+ let originalArgv;
159
+ let originalUnderscore;
160
+
161
+ function setup() {
162
+ originalArgv = process.argv;
163
+ originalUnderscore = process.env._;
164
+ delete process.env._;
165
+ }
166
+ function teardown() {
167
+ process.argv = originalArgv;
168
+ if (originalUnderscore === undefined) delete process.env._;
169
+ else process.env._ = originalUnderscore;
170
+ }
171
+
172
+ it("returns true when argv signals a transient runner", () => {
173
+ setup();
174
+ try {
175
+ process.argv = [
176
+ "/usr/local/bin/node",
177
+ "/Users/alice/.npm/_npx/abc/skillrepo",
178
+ ];
179
+ assert.equal(isTransientRunnerInvocation(), true);
180
+ } finally {
181
+ teardown();
182
+ }
183
+ });
184
+
185
+ it("returns false for a stable-install argv", () => {
186
+ setup();
187
+ try {
188
+ process.argv = ["/usr/local/bin/node", "/usr/local/bin/skillrepo"];
189
+ assert.equal(isTransientRunnerInvocation(), false);
190
+ } finally {
191
+ teardown();
192
+ }
193
+ });
194
+ });
195
+
196
+ // ── isTransientCachePath — used by binary locator ──────────────────
197
+
198
+ describe("isTransientCachePath", () => {
199
+ it("identifies an npx cache path", () => {
200
+ assert.equal(
201
+ isTransientCachePath("/Users/alice/.npm/_npx/abc/node_modules/.bin/skillrepo"),
202
+ true,
203
+ );
204
+ });
205
+
206
+ it("identifies a pnpm dlx cache path", () => {
207
+ assert.equal(
208
+ isTransientCachePath("/Users/alice/.local/share/pnpm/store/dlx-abc/node_modules/.bin/skillrepo"),
209
+ true,
210
+ );
211
+ });
212
+
213
+ it("identifies a yarn berry cache path", () => {
214
+ assert.equal(
215
+ isTransientCachePath(
216
+ "/Users/alice/proj/.yarn/berry/cache/skillrepo-npm-3.1.2-abc/...",
217
+ ),
218
+ true,
219
+ );
220
+ });
221
+
222
+ it("identifies a bun cache path", () => {
223
+ assert.equal(
224
+ isTransientCachePath("/Users/alice/.bun/install/cache/skillrepo@3.1.2/bin/skillrepo"),
225
+ true,
226
+ );
227
+ });
228
+
229
+ it("identifies a Windows-style npx cache path", () => {
230
+ assert.equal(
231
+ isTransientCachePath(
232
+ "C:\\Users\\alice\\.npm\\_npx\\abc\\node_modules\\.bin\\skillrepo.cmd",
233
+ ),
234
+ true,
235
+ );
236
+ });
237
+
238
+ it("returns false for a stable global path", () => {
239
+ assert.equal(isTransientCachePath("/usr/local/bin/skillrepo"), false);
240
+ assert.equal(isTransientCachePath("/opt/homebrew/bin/skillrepo"), false);
241
+ assert.equal(
242
+ isTransientCachePath("C:\\Program Files\\nodejs\\skillrepo.cmd"),
243
+ false,
244
+ );
245
+ assert.equal(
246
+ isTransientCachePath("/Users/alice/.npm-global/bin/skillrepo"),
247
+ false,
248
+ );
249
+ });
250
+ });
251
+
252
+ // ── globalInstallCommandFor — per-runner install hint ──────────────
253
+
254
+ describe("globalInstallCommandFor", () => {
255
+ it("returns the right install command for each known runner", () => {
256
+ assert.equal(globalInstallCommandFor("npx"), "npm install -g skillrepo");
257
+ assert.equal(globalInstallCommandFor("pnpx"), "pnpm add -g skillrepo");
258
+ // yarn berry has no `yarn global add`; falls back to npm
259
+ // (documented in the TRANSIENT_RUNNERS comment).
260
+ assert.equal(globalInstallCommandFor("yarn dlx"), "npm install -g skillrepo");
261
+ assert.equal(globalInstallCommandFor("bunx"), "bun add -g skillrepo");
262
+ });
263
+
264
+ it("returns null for unknown / null runner names so callers can fall back", () => {
265
+ assert.equal(globalInstallCommandFor(null), null);
266
+ assert.equal(globalInstallCommandFor(undefined), null);
267
+ assert.equal(globalInstallCommandFor(""), null);
268
+ assert.equal(globalInstallCommandFor("not-a-real-runner"), null);
269
+ });
270
+ });