skillvault-publisher 0.11.1 → 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.
@@ -15,10 +15,60 @@
15
15
  * Stored at <roots.configDir>/project.json. Tracks active publishers and
16
16
  * install metadata for one install (project- or global-scoped).
17
17
  */
18
- import { mkdirSync, readFileSync, writeFileSync, rmSync, existsSync } from 'node:fs';
19
- import { join } from 'node:path';
18
+ import { mkdirSync, readFileSync, writeFileSync, rmSync, existsSync, openSync, closeSync, unlinkSync, } from 'node:fs';
19
+ import { join, dirname } from 'node:path';
20
20
  import { homedir } from 'node:os';
21
21
  import { credentialsPath } from './scope.js';
22
+ /**
23
+ * Run `fn` while holding an exclusive advisory lock on `<targetPath>.lock`.
24
+ * Mirrors the helper in projects-registry.ts. See that file for design notes.
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
+ }
22
72
  const CONFIG_DIR = join(homedir(), '.skillvault');
23
73
  const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
24
74
  const GRANTS_CACHE_FILE = join(CONFIG_DIR, 'grants-cache.json');
@@ -73,10 +123,15 @@ export function loadCredentials() {
73
123
  /**
74
124
  * Persist customer credentials to ~/.skillvault/credentials.json with mode 0600.
75
125
  * The credentials file holds bearer tokens, so it must never be world-readable.
126
+ *
127
+ * Wrapped in withFileLock to serialize concurrent writes — see the equivalent
128
+ * fix in projects-registry.ts.
76
129
  */
77
130
  export function saveCredentials(creds) {
78
- ensureConfigDir();
79
- writeFileSync(credentialsPath(), JSON.stringify(creds, null, 2), { mode: 0o600 });
131
+ withFileLock(credentialsPath(), () => {
132
+ ensureConfigDir();
133
+ writeFileSync(credentialsPath(), JSON.stringify(creds, null, 2), { mode: 0o600 });
134
+ });
80
135
  }
81
136
  function projectConfigPath(roots) {
82
137
  return join(roots.configDir, 'project.json');
package/dist/index.js CHANGED
@@ -151807,7 +151807,7 @@ var init_pdf_report = __esm({
151807
151807
  import { Command as Command35 } from "commander";
151808
151808
  import { readFileSync as readFileSync18 } from "node:fs";
151809
151809
  import { fileURLToPath } from "node:url";
151810
- import { dirname as dirname4, join as join17 } from "node:path";
151810
+ import { dirname as dirname5, join as join17 } from "node:path";
151811
151811
 
151812
151812
  // dist/commands/login.js
151813
151813
  import { Command } from "commander";
@@ -151815,8 +151815,8 @@ import chalk from "chalk";
151815
151815
  import { randomBytes } from "node:crypto";
151816
151816
 
151817
151817
  // dist/credentials.js
151818
- import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2, rmSync, existsSync as existsSync2 } from "node:fs";
151819
- import { join as join2 } from "node:path";
151818
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2, rmSync, existsSync as existsSync2, openSync as openSync2, closeSync as closeSync2, unlinkSync as unlinkSync2 } from "node:fs";
151819
+ import { join as join2, dirname as dirname2 } from "node:path";
151820
151820
  import { homedir as homedir2 } from "node:os";
151821
151821
 
151822
151822
  // dist/scope.js
@@ -151824,7 +151824,7 @@ import { homedir } from "node:os";
151824
151824
  import { join } from "node:path";
151825
151825
 
151826
151826
  // dist/projects-registry.js
151827
- import { mkdirSync, readFileSync, writeFileSync, existsSync, statSync } from "node:fs";
151827
+ import { mkdirSync, readFileSync, writeFileSync, existsSync, statSync, openSync, closeSync, unlinkSync } from "node:fs";
151828
151828
  import { dirname, resolve } from "node:path";
151829
151829
 
151830
151830
  // dist/credentials.js
@@ -152534,7 +152534,7 @@ import chalk4 from "chalk";
152534
152534
 
152535
152535
  // dist/publisher-workspace.js
152536
152536
  import { readFileSync as readFileSync4, writeFileSync as writeFileSync4, mkdirSync as mkdirSync4, existsSync as existsSync4 } from "node:fs";
152537
- import { join as join4, dirname as dirname2, resolve as resolve2 } from "node:path";
152537
+ import { join as join4, dirname as dirname3, resolve as resolve2 } from "node:path";
152538
152538
  import { homedir as homedir3 } from "node:os";
152539
152539
  var MANIFEST_DIRNAME = ".skillvault-publisher";
152540
152540
  var MANIFEST_FILENAME = "workspace.json";
@@ -152582,7 +152582,7 @@ function loadWorkspace(dir) {
152582
152582
  }
152583
152583
  function saveWorkspace(dir, manifest) {
152584
152584
  const { manifestPath } = workspaceRootsFor(dir);
152585
- mkdirSync4(dirname2(manifestPath), { recursive: true });
152585
+ mkdirSync4(dirname3(manifestPath), { recursive: true });
152586
152586
  writeFileSync4(manifestPath, JSON.stringify(manifest, null, 2), { mode: 420 });
152587
152587
  }
152588
152588
  function findWorkspace(cwd) {
@@ -152605,7 +152605,7 @@ function findWorkspace(cwd) {
152605
152605
  return null;
152606
152606
  if (dir === home)
152607
152607
  return null;
152608
- const parent = dirname2(dir);
152608
+ const parent = dirname3(dir);
152609
152609
  if (parent === dir)
152610
152610
  return null;
152611
152611
  dir = parent;
@@ -152696,7 +152696,7 @@ function requireCommandContext(opts = {}) {
152696
152696
 
152697
152697
  // dist/publisher-workspaces-registry.js
152698
152698
  import { mkdirSync as mkdirSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync5, existsSync as existsSync5, statSync as statSync2 } from "node:fs";
152699
- import { dirname as dirname3, join as join5, resolve as resolve3 } from "node:path";
152699
+ import { dirname as dirname4, join as join5, resolve as resolve3 } from "node:path";
152700
152700
  import { homedir as homedir4 } from "node:os";
152701
152701
  var REGISTRY_VERSION = 1;
152702
152702
  var MANIFEST_DIRNAME2 = ".skillvault-publisher";
@@ -152722,7 +152722,7 @@ function readRegistry() {
152722
152722
  }
152723
152723
  function writeRegistry(reg) {
152724
152724
  const path = publisherWorkspacesRegistryPath();
152725
- mkdirSync5(dirname3(path), { recursive: true });
152725
+ mkdirSync5(dirname4(path), { recursive: true });
152726
152726
  writeFileSync5(path, JSON.stringify(reg, null, 2), { mode: 384 });
152727
152727
  }
152728
152728
  function listWorkspaces() {
@@ -154307,12 +154307,12 @@ var inviteCommand = new Command15("invite").description("Manage customer invites
154307
154307
 
154308
154308
  // dist/commands/scan-output.js
154309
154309
  import { Command as Command16 } from "commander";
154310
- import { readFileSync as readFileSync12, writeFileSync as writeFileSync8, mkdirSync as mkdirSync8, existsSync as existsSync11, readdirSync as readdirSync3, unlinkSync as unlinkSync2 } from "node:fs";
154310
+ import { readFileSync as readFileSync12, writeFileSync as writeFileSync8, mkdirSync as mkdirSync8, existsSync as existsSync11, readdirSync as readdirSync3, unlinkSync as unlinkSync4 } from "node:fs";
154311
154311
  import { join as join11 } from "node:path";
154312
154312
  import { homedir as homedir6 } from "node:os";
154313
154313
 
154314
154314
  // dist/commands/session-common.js
154315
- import { readFileSync as readFileSync11, writeFileSync as writeFileSync7, mkdirSync as mkdirSync7, existsSync as existsSync10, unlinkSync } from "node:fs";
154315
+ import { readFileSync as readFileSync11, writeFileSync as writeFileSync7, mkdirSync as mkdirSync7, existsSync as existsSync10, unlinkSync as unlinkSync3 } from "node:fs";
154316
154316
  import { join as join10 } from "node:path";
154317
154317
  import { homedir as homedir5 } from "node:os";
154318
154318
  import { createCipheriv as createCipheriv4, createDecipheriv as createDecipheriv4, randomBytes as randomBytes6, hkdfSync as hkdfSync2 } from "node:crypto";
@@ -154372,7 +154372,7 @@ function loadActiveSession() {
154372
154372
  const decrypted = decryptData(raw, HKDF_SALT2);
154373
154373
  if (!decrypted) {
154374
154374
  try {
154375
- unlinkSync(ACTIVE_SESSION_PATH);
154375
+ unlinkSync3(ACTIVE_SESSION_PATH);
154376
154376
  } catch {
154377
154377
  }
154378
154378
  return null;
@@ -154380,7 +154380,7 @@ function loadActiveSession() {
154380
154380
  const session2 = JSON.parse(decrypted);
154381
154381
  if (new Date(session2.expires_at).getTime() < Date.now()) {
154382
154382
  try {
154383
- unlinkSync(ACTIVE_SESSION_PATH);
154383
+ unlinkSync3(ACTIVE_SESSION_PATH);
154384
154384
  } catch {
154385
154385
  }
154386
154386
  return null;
@@ -154392,7 +154392,7 @@ function loadActiveSession() {
154392
154392
  return null;
154393
154393
  if (new Date(session.expires_at).getTime() < Date.now()) {
154394
154394
  try {
154395
- unlinkSync(ACTIVE_SESSION_PATH);
154395
+ unlinkSync3(ACTIVE_SESSION_PATH);
154396
154396
  } catch {
154397
154397
  }
154398
154398
  return null;
@@ -154405,7 +154405,7 @@ function loadActiveSession() {
154405
154405
  function deleteActiveSession() {
154406
154406
  try {
154407
154407
  if (existsSync10(ACTIVE_SESSION_PATH)) {
154408
- unlinkSync(ACTIVE_SESSION_PATH);
154408
+ unlinkSync3(ACTIVE_SESSION_PATH);
154409
154409
  }
154410
154410
  } catch {
154411
154411
  }
@@ -154466,7 +154466,7 @@ function loadFingerprints() {
154466
154466
  const decrypted = decryptData(raw, FINGERPRINT_HKDF_SALT);
154467
154467
  if (!decrypted) {
154468
154468
  try {
154469
- unlinkSync2(filePath);
154469
+ unlinkSync4(filePath);
154470
154470
  } catch {
154471
154471
  }
154472
154472
  continue;
@@ -154776,7 +154776,7 @@ var sessionKeepaliveCommand = new Command18("session-keepalive").description("Un
154776
154776
 
154777
154777
  // dist/commands/session-cleanup.js
154778
154778
  import { Command as Command19 } from "commander";
154779
- import { readFileSync as readFileSync14, writeFileSync as writeFileSync9, existsSync as existsSync13, readdirSync as readdirSync4, unlinkSync as unlinkSync3 } from "node:fs";
154779
+ import { readFileSync as readFileSync14, writeFileSync as writeFileSync9, existsSync as existsSync13, readdirSync as readdirSync4, unlinkSync as unlinkSync5 } from "node:fs";
154780
154780
  import { join as join13 } from "node:path";
154781
154781
  import { homedir as homedir8 } from "node:os";
154782
154782
  var SETTINGS_PATH2 = join13(homedir8(), ".claude", "settings.json");
@@ -154830,7 +154830,7 @@ function clearFingerprintCache() {
154830
154830
  const files = readdirSync4(FINGERPRINT_DIR3);
154831
154831
  for (const file of files) {
154832
154832
  try {
154833
- unlinkSync3(join13(FINGERPRINT_DIR3, file));
154833
+ unlinkSync5(join13(FINGERPRINT_DIR3, file));
154834
154834
  } catch {
154835
154835
  }
154836
154836
  }
@@ -156161,7 +156161,7 @@ workspacesCommand.command("find").description("Print the absolute path of a work
156161
156161
  });
156162
156162
 
156163
156163
  // dist/index.js
156164
- var __dirname2 = dirname4(fileURLToPath(import.meta.url));
156164
+ var __dirname2 = dirname5(fileURLToPath(import.meta.url));
156165
156165
  var pkg = JSON.parse(readFileSync18(join17(__dirname2, "..", "package.json"), "utf8"));
156166
156166
  var program = new Command35();
156167
156167
  program.name("skillvault-publisher").description("SkillVault publisher CLI \u2014 publish, manage, and distribute encrypted skills").version(pkg.version).addHelpText("after", `
@@ -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-publisher",
3
- "version": "0.11.1",
3
+ "version": "0.11.2",
4
4
  "description": "SkillVault publisher CLI — publish, manage, and distribute encrypted skills",
5
5
  "type": "module",
6
6
  "bin": {