skillvault 0.11.0 → 0.11.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/dist/cli.js CHANGED
@@ -24,7 +24,7 @@ import { resolveScope, resolveRoots, } from './scope.js';
24
24
  import { loadCredentials, saveCredentials, loadProjectConfig, saveProjectConfig, } from './credentials.js';
25
25
  import { registerProject, unregisterProject, getCurrentProject, listProjects, } from './projects-registry.js';
26
26
  import { confirmInstall } from './prompts.js';
27
- const VERSION = '0.11.0';
27
+ const VERSION = '0.11.2';
28
28
  const HOME = process.env.HOME || process.env.USERPROFILE || '~';
29
29
  const API_URL = process.env.SKILLVAULT_API_URL || 'https://api.getskillvault.com';
30
30
  const CONFIG_DIR = join(HOME, '.skillvault');
@@ -1242,13 +1242,12 @@ function scrubSkillvaultHooks(settingsPath) {
1242
1242
  return removed;
1243
1243
  }
1244
1244
  /**
1245
- * Walk <roots.claudeDir>/skills/ and remove every stub whose manifest.json
1246
- * has an `encrypted: true` flag (the marker we set during install). Returns
1247
- * the list of removed skill names. Vault data is left in place so a future
1248
- * --invite or migrate can reuse it.
1245
+ * Walk a single skills directory and remove every stub whose manifest.json
1246
+ * has an `encrypted: true` flag or a `publisher_id` (the markers we set during
1247
+ * install). Returns the list of removed skill names. Pre-existing skills from
1248
+ * other tools (no manifest, or non-skillvault manifest) are left untouched.
1249
1249
  */
1250
- function removeAllSkillvaultStubs(roots) {
1251
- const skillsDir = join(roots.claudeDir, 'skills');
1250
+ function removeStubsFromDir(skillsDir) {
1252
1251
  if (!existsSync(skillsDir))
1253
1252
  return [];
1254
1253
  const removed = [];
@@ -1268,6 +1267,68 @@ function removeAllSkillvaultStubs(roots) {
1268
1267
  }
1269
1268
  return removed;
1270
1269
  }
1270
+ /**
1271
+ * Walk every directory where install would have written a stub and remove
1272
+ * the SkillVault-managed ones. For project mode this is just
1273
+ * <roots.claudeDir>/skills/. For global mode it also covers ~/.agents/skills/
1274
+ * and the agent-agnostic platform mirrors (Cursor, Windsurf, Codex). Vault
1275
+ * data in <roots.vaultDir> is left in place so a future --invite or
1276
+ * migrate-to-project can reuse it.
1277
+ */
1278
+ function removeAllSkillvaultStubs(roots) {
1279
+ const all = [];
1280
+ // Always: <roots.claudeDir>/skills/ (the install's primary stub location)
1281
+ all.push(...removeStubsFromDir(join(roots.claudeDir, 'skills')));
1282
+ // Global only: ~/.agents/skills/ + every detected platform mirror.
1283
+ // installSkillStubs writes to all of these in global mode, so the global
1284
+ // uninstall has to clean all of them up too.
1285
+ if (roots.scope === 'global') {
1286
+ all.push(...removeStubsFromDir(AGENTS_SKILLS_DIR));
1287
+ for (const platform of detectAgentPlatforms()) {
1288
+ all.push(...removeStubsFromDir(platform.dir));
1289
+ }
1290
+ }
1291
+ // Dedupe — the same skill_name can appear in multiple mirrors.
1292
+ return [...new Set(all)];
1293
+ }
1294
+ /**
1295
+ * Remove SkillVault entries from a shared skill-lock.json file, preserving
1296
+ * any entries that belong to other tools (e.g. skill-creator's
1297
+ * sourceType=github wallet skills coexist with our sourceType=skillvault
1298
+ * entries in ~/.agents/.skill-lock.json). If the file ends up with zero
1299
+ * entries, the whole file is deleted.
1300
+ */
1301
+ function pruneSkillvaultLockEntries(lockPath) {
1302
+ if (!existsSync(lockPath))
1303
+ return 0;
1304
+ try {
1305
+ const data = JSON.parse(readFileSync(lockPath, 'utf8'));
1306
+ if (!data.skills || typeof data.skills !== 'object')
1307
+ return 0;
1308
+ let removed = 0;
1309
+ for (const [name, entry] of Object.entries(data.skills)) {
1310
+ if (entry && typeof entry === 'object' && (entry.sourceType === 'skillvault' || entry.encrypted === true)) {
1311
+ delete data.skills[name];
1312
+ removed++;
1313
+ }
1314
+ }
1315
+ if (removed === 0)
1316
+ return 0;
1317
+ if (Object.keys(data.skills).length === 0) {
1318
+ try {
1319
+ rmSync(lockPath);
1320
+ }
1321
+ catch { }
1322
+ }
1323
+ else {
1324
+ writeFileSync(lockPath, JSON.stringify(data, null, 2), { mode: 0o600 });
1325
+ }
1326
+ return removed;
1327
+ }
1328
+ catch {
1329
+ return 0;
1330
+ }
1331
+ }
1271
1332
  /**
1272
1333
  * Uninstall a single SkillVault install at the given roots.
1273
1334
  *
@@ -1303,8 +1364,17 @@ function uninstallInstall(roots) {
1303
1364
  catch { }
1304
1365
  }
1305
1366
  else {
1306
- // Global install — preserve credentials.json + projects.json.
1307
- const PRESERVE = new Set(['credentials.json', 'projects.json']);
1367
+ // Global install — preserve customer identity + publisher CLI state.
1368
+ // credentials.json + projects.json are the customer-side files we
1369
+ // documented as always-preserved. config.json + grants-cache.json are
1370
+ // owned by the publisher CLI (skillvault-publisher) — the customer-
1371
+ // side uninstall has no business deleting publisher login state.
1372
+ const PRESERVE = new Set([
1373
+ 'credentials.json',
1374
+ 'projects.json',
1375
+ 'config.json', // publisher CLI session
1376
+ 'grants-cache.json', // publisher CLI grant cache
1377
+ ]);
1308
1378
  for (const entry of readdirSync(roots.configDir)) {
1309
1379
  if (PRESERVE.has(entry))
1310
1380
  continue;
@@ -1317,8 +1387,16 @@ function uninstallInstall(roots) {
1317
1387
  }
1318
1388
  }
1319
1389
  }
1320
- // Skill stubs in <roots.claudeDir>/skills/
1390
+ // Skill stubs in every location install would have written them: always
1391
+ // <roots.claudeDir>/skills/, and (global mode) ~/.agents/skills/ + each
1392
+ // detected platform mirror.
1321
1393
  summary.removedStubs = removeAllSkillvaultStubs(roots);
1394
+ // Prune SkillVault entries from the shared lock file. In global mode this
1395
+ // is ~/.agents/.skill-lock.json which can also hold non-skillvault entries
1396
+ // (e.g. from skill-creator), so we preserve other entries.
1397
+ if (roots.scope === 'global') {
1398
+ pruneSkillvaultLockEntries(AGENTS_LOCK_PATH);
1399
+ }
1322
1400
  // Hooks in settings.json
1323
1401
  summary.removedHooks = scrubSkillvaultHooks(roots.settingsPath);
1324
1402
  // Registry: drop the entry if this was a project install. Use the install
@@ -12,10 +12,63 @@
12
12
  * <roots.configDir>/project.json (per install, project- or global-scoped)
13
13
  * Active publishers in this specific install plus install metadata.
14
14
  */
15
- import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
15
+ import { mkdirSync, readFileSync, writeFileSync, existsSync, openSync, closeSync, unlinkSync, } from 'node:fs';
16
16
  import { join, dirname } from 'node:path';
17
17
  import { homedir } from 'node:os';
18
18
  import { credentialsPath } from './scope.js';
19
+ /**
20
+ * Run `fn` while holding an exclusive advisory lock on `<targetPath>.lock`.
21
+ * Same primitive as projects-registry.ts withFileLock — duplicated here so
22
+ * credentials.ts can be locked without crossing module boundaries. See that
23
+ * file for the design notes (POSIX O_EXCL semantics, busy-wait backoff,
24
+ * 1s stale-lock reclaim).
25
+ */
26
+ function withFileLock(targetPath, fn) {
27
+ const lockPath = targetPath + '.lock';
28
+ mkdirSync(dirname(lockPath), { recursive: true });
29
+ const start = Date.now();
30
+ const TIMEOUT_MS = 1000;
31
+ let fd = null;
32
+ while (fd === null) {
33
+ try {
34
+ fd = openSync(lockPath, 'wx', 0o600);
35
+ }
36
+ catch (err) {
37
+ const code = err.code;
38
+ if (code !== 'EEXIST')
39
+ throw err;
40
+ if (Date.now() - start > TIMEOUT_MS) {
41
+ try {
42
+ unlinkSync(lockPath);
43
+ }
44
+ catch { }
45
+ try {
46
+ fd = openSync(lockPath, 'wx', 0o600);
47
+ }
48
+ catch {
49
+ throw new Error(`Could not acquire lock at ${lockPath} after ${TIMEOUT_MS}ms`);
50
+ }
51
+ break;
52
+ }
53
+ const buf = new SharedArrayBuffer(4);
54
+ const view = new Int32Array(buf);
55
+ Atomics.wait(view, 0, 0, 5 + Math.floor(Math.random() * 10));
56
+ }
57
+ }
58
+ try {
59
+ return fn();
60
+ }
61
+ finally {
62
+ try {
63
+ closeSync(fd);
64
+ }
65
+ catch { }
66
+ try {
67
+ unlinkSync(lockPath);
68
+ }
69
+ catch { }
70
+ }
71
+ }
19
72
  /**
20
73
  * Load customer credentials from ~/.skillvault/credentials.json.
21
74
  *
@@ -75,11 +128,20 @@ export function loadCredentials() {
75
128
  }
76
129
  /**
77
130
  * Persist customer credentials to ~/.skillvault/credentials.json with mode 0600.
131
+ *
132
+ * Wrapped in withFileLock so two concurrent invites with different invite
133
+ * codes can't race and silently drop one of each other's publishers from
134
+ * the publishers[] array. The caller is expected to read-modify-write the
135
+ * full credentials object themselves; this function only serializes the
136
+ * write step. For full read-modify-write atomicity, callers should hold
137
+ * the lock across both load + save (see redeemInvite() in cli.ts).
78
138
  */
79
139
  export function saveCredentials(creds) {
80
140
  const path = credentialsPath();
81
- mkdirSync(dirname(path), { recursive: true });
82
- writeFileSync(path, JSON.stringify(creds, null, 2), { mode: 0o600 });
141
+ withFileLock(path, () => {
142
+ mkdirSync(dirname(path), { recursive: true });
143
+ writeFileSync(path, JSON.stringify(creds, null, 2), { mode: 0o600 });
144
+ });
83
145
  }
84
146
  function projectConfigPath(roots) {
85
147
  return join(roots.configDir, 'project.json');
@@ -6,10 +6,80 @@
6
6
  * `getCurrentProject()` to figure out whether the current CWD already has a
7
7
  * registered SkillVault install.
8
8
  */
9
- import { mkdirSync, readFileSync, writeFileSync, existsSync, statSync } from 'node:fs';
9
+ import { mkdirSync, readFileSync, writeFileSync, existsSync, statSync, openSync, closeSync, unlinkSync, } from 'node:fs';
10
10
  import { dirname, resolve } from 'node:path';
11
11
  import { projectsRegistryPath } from './scope.js';
12
12
  const REGISTRY_VERSION = 1;
13
+ /**
14
+ * Run `fn` while holding an exclusive advisory lock on `<targetPath>.lock`.
15
+ *
16
+ * Uses `openSync(..., 'wx')` to atomically create the lockfile (POSIX
17
+ * O_EXCL semantics — exactly one process succeeds, the rest get EEXIST).
18
+ * On contention, busy-waits with a small backoff up to ~1s before giving
19
+ * up. The lockfile is unlinked on completion (success or thrown error)
20
+ * via try/finally so a crash mid-operation can't permanently wedge
21
+ * subsequent invocations — though if a process is SIGKILL'd between the
22
+ * openSync and the closeSync, the lockfile will linger and the next
23
+ * caller will time out and either inherit the stale lock (forced) or
24
+ * throw. For our use case (a single-user CLI doing read-modify-write on
25
+ * a small JSON file), 1s is plenty.
26
+ *
27
+ * Used by registerProject + unregisterProject to fix the lost-update race
28
+ * when two `--invite` commands run concurrently against the same
29
+ * ~/.skillvault/projects.json (audit scenario 12-concurrent-install).
30
+ */
31
+ function withFileLock(targetPath, fn) {
32
+ const lockPath = targetPath + '.lock';
33
+ mkdirSync(dirname(lockPath), { recursive: true });
34
+ const start = Date.now();
35
+ const TIMEOUT_MS = 1000;
36
+ let fd = null;
37
+ while (fd === null) {
38
+ try {
39
+ fd = openSync(lockPath, 'wx', 0o600);
40
+ }
41
+ catch (err) {
42
+ const code = err.code;
43
+ if (code !== 'EEXIST')
44
+ throw err;
45
+ if (Date.now() - start > TIMEOUT_MS) {
46
+ // Stale lock — assume the previous holder crashed and reclaim it.
47
+ try {
48
+ unlinkSync(lockPath);
49
+ }
50
+ catch { }
51
+ // Loop one more time, but if it still fails, throw.
52
+ try {
53
+ fd = openSync(lockPath, 'wx', 0o600);
54
+ }
55
+ catch {
56
+ throw new Error(`Could not acquire lock at ${lockPath} after ${TIMEOUT_MS}ms`);
57
+ }
58
+ break;
59
+ }
60
+ // Busy-wait briefly. The Node.js event loop is single-threaded so
61
+ // we can't yield to a peer process via Promise without making the
62
+ // function async — and registerProject is sync. Atomics.wait on a
63
+ // SharedArrayBuffer is the cleanest sync sleep:
64
+ const buf = new SharedArrayBuffer(4);
65
+ const view = new Int32Array(buf);
66
+ Atomics.wait(view, 0, 0, 5 + Math.floor(Math.random() * 10));
67
+ }
68
+ }
69
+ try {
70
+ return fn();
71
+ }
72
+ finally {
73
+ try {
74
+ closeSync(fd);
75
+ }
76
+ catch { }
77
+ try {
78
+ unlinkSync(lockPath);
79
+ }
80
+ catch { }
81
+ }
82
+ }
13
83
  function readRegistry() {
14
84
  const path = projectsRegistryPath();
15
85
  if (!existsSync(path))
@@ -75,29 +145,39 @@ export function listProjects() {
75
145
  /**
76
146
  * Insert or update a project entry. Matching is by absolute path; if an entry
77
147
  * with the same path already exists, it is replaced.
148
+ *
149
+ * The read-modify-write is wrapped in withFileLock so two concurrent
150
+ * `--invite` calls can't race and lose one of each other's updates.
78
151
  */
79
152
  export function registerProject(entry) {
80
- const reg = readRegistry();
81
- const normalized = { ...entry, path: resolve(entry.path) };
82
- const idx = reg.projects.findIndex((p) => resolve(p.path) === normalized.path);
83
- if (idx >= 0) {
84
- reg.projects[idx] = normalized;
85
- }
86
- else {
87
- reg.projects.push(normalized);
88
- }
89
- writeRegistry(reg);
153
+ withFileLock(projectsRegistryPath(), () => {
154
+ const reg = readRegistry();
155
+ const normalized = { ...entry, path: resolve(entry.path) };
156
+ const idx = reg.projects.findIndex((p) => resolve(p.path) === normalized.path);
157
+ if (idx >= 0) {
158
+ reg.projects[idx] = normalized;
159
+ }
160
+ else {
161
+ reg.projects.push(normalized);
162
+ }
163
+ writeRegistry(reg);
164
+ });
90
165
  }
91
166
  /**
92
167
  * Remove the project entry whose path matches. No-op if not present.
168
+ *
169
+ * Wrapped in withFileLock for the same reason as registerProject — a
170
+ * concurrent uninstall + register would otherwise race.
93
171
  */
94
172
  export function unregisterProject(path) {
95
- const reg = readRegistry();
96
- const target = resolve(path);
97
- const before = reg.projects.length;
98
- reg.projects = reg.projects.filter((p) => resolve(p.path) !== target);
99
- if (reg.projects.length !== before)
100
- writeRegistry(reg);
173
+ withFileLock(projectsRegistryPath(), () => {
174
+ const reg = readRegistry();
175
+ const target = resolve(path);
176
+ const before = reg.projects.length;
177
+ reg.projects = reg.projects.filter((p) => resolve(p.path) !== target);
178
+ if (reg.projects.length !== before)
179
+ writeRegistry(reg);
180
+ });
101
181
  }
102
182
  /**
103
183
  * Look up the registry entry that matches the current working directory (or
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillvault",
3
- "version": "0.11.0",
3
+ "version": "0.11.2",
4
4
  "description": "SkillVault — secure skill distribution for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {