@vibecompass/vibecompass 0.14.0 → 0.16.0

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.
@@ -0,0 +1,856 @@
1
+ import { execFile as nodeExecFile } from 'node:child_process';
2
+ import { createHash, randomBytes } from 'node:crypto';
3
+ import { chmod, link, mkdir, open, readFile, rename, stat, unlink } from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+
7
+ /**
8
+ * Layered local credential store for hosted sync tokens (D-355).
9
+ *
10
+ * Resolution order for every hosted command is fixed:
11
+ * 1. the environment variable named by the binding's `credential_env_var`
12
+ * (explicit per-shell override for CI, automation, and agents);
13
+ * 2. the local credential store entry keyed by the binding's normalized
14
+ * `api_url` + `project_id`, whose secret lives in the OS keychain when a
15
+ * keychain backend is available and otherwise in a per-user file.
16
+ *
17
+ * The store lives outside every project root and Git checkout. The index file
18
+ * (`credentials.json`) holds non-secret metadata per entry — backend, token
19
+ * prefix, label, stored-at time, capture source — and, for the file backend
20
+ * only, the secret itself. The directory is created 0700 and the file 0600;
21
+ * a file store with broader permissions is refused rather than read.
22
+ *
23
+ * Nothing here ever writes a token into project.yaml, state/, manifests, or
24
+ * output: callers receive the resolved secret and a source label only.
25
+ */
26
+
27
+ export const CREDENTIAL_STORE_VERSION = 1;
28
+ export const CREDENTIAL_STORE_FILE_NAME = 'credentials.json';
29
+ export const KEYCHAIN_SERVICE = 'vibecompass-sync';
30
+ export const CONFIG_DIR_ENV_VAR = 'VIBECOMPASS_CONFIG_DIR';
31
+ export const STORE_BACKEND_ENV_VAR = 'VIBECOMPASS_CREDENTIAL_STORE';
32
+ export const STORE_BACKENDS = new Set(['auto', 'keychain', 'file']);
33
+ export const TOKEN_PREFIX_LENGTH = 14;
34
+
35
+ const SAFE_ACCOUNT_PATTERN = /^[A-Za-z0-9._:/@|+-]+$/;
36
+ const LOCK_FILE_SUFFIX = '.lock';
37
+ const RECLAIM_LOCK_SUFFIX = '.reclaim';
38
+ const DEFAULT_LOCK_TIMEOUT_MS = 10_000;
39
+ const KEYCHAIN_LABEL_MAX = 120;
40
+
41
+ /**
42
+ * Resolves the per-user config directory that owns the credential store.
43
+ * `VIBECOMPASS_CONFIG_DIR` wins, then `XDG_CONFIG_HOME/vibecompass`, then the
44
+ * platform default (`~/.config/vibecompass`; `%APPDATA%\vibecompass` on
45
+ * Windows). Tests isolate the store by pointing the env var at a temp dir.
46
+ */
47
+ export function resolveCredentialStoreDir(options = {}) {
48
+ const env = options.env ?? process.env;
49
+ const platform = options.platform ?? process.platform;
50
+ const homeDir = options.homeDir ?? os.homedir();
51
+
52
+ const explicit = normalizeOptionalString(env[CONFIG_DIR_ENV_VAR]);
53
+ if (explicit) {
54
+ return path.resolve(explicit);
55
+ }
56
+
57
+ const xdg = normalizeOptionalString(env.XDG_CONFIG_HOME);
58
+ if (xdg) {
59
+ return path.join(path.resolve(xdg), 'vibecompass');
60
+ }
61
+
62
+ if (platform === 'win32') {
63
+ const appData = normalizeOptionalString(env.APPDATA);
64
+ return appData
65
+ ? path.join(path.resolve(appData), 'vibecompass')
66
+ : path.join(homeDir, 'AppData', 'Roaming', 'vibecompass');
67
+ }
68
+
69
+ return path.join(homeDir, '.config', 'vibecompass');
70
+ }
71
+
72
+ export function resolveCredentialStorePath(options = {}) {
73
+ return path.join(resolveCredentialStoreDir(options), CREDENTIAL_STORE_FILE_NAME);
74
+ }
75
+
76
+ /**
77
+ * Canonical target identity: scheme and host are case-insensitive and are
78
+ * lowercased; the path is case-sensitive and is preserved exactly (trailing
79
+ * slashes trimmed) so `/TeamA` and `/teama` stay distinct targets.
80
+ */
81
+ export function normalizeApiUrl(apiUrl) {
82
+ const raw = normalizeOptionalString(apiUrl);
83
+ if (!raw) {
84
+ throw new Error('A hosted api_url is required to address the credential store.');
85
+ }
86
+ let parsed;
87
+ try {
88
+ parsed = new URL(raw);
89
+ } catch {
90
+ throw new Error(`Hosted api_url "${raw}" is not a valid URL.`);
91
+ }
92
+ const pathname = parsed.pathname.replace(/\/+$/, '');
93
+ return `${parsed.protocol.toLowerCase()}//${parsed.host.toLowerCase()}${pathname}`;
94
+ }
95
+
96
+ export function credentialStoreKey(apiUrl, projectId) {
97
+ const project = normalizeOptionalString(projectId);
98
+ if (!project) {
99
+ throw new Error('A hosted project_id is required to address the credential store.');
100
+ }
101
+ return `${normalizeApiUrl(apiUrl)}|${project}`;
102
+ }
103
+
104
+ export function tokenPrefixOf(token) {
105
+ return String(token).slice(0, TOKEN_PREFIX_LENGTH);
106
+ }
107
+
108
+ /**
109
+ * Returns the requested backend policy: `auto` (keychain when available,
110
+ * else file), `keychain` (required), or `file`. The CLI flag wins over the
111
+ * `VIBECOMPASS_CREDENTIAL_STORE` environment default.
112
+ */
113
+ export function resolveStoreBackendPolicy(options = {}) {
114
+ const env = options.env ?? process.env;
115
+ const requested = normalizeOptionalString(options.backend)
116
+ ?? normalizeOptionalString(env[STORE_BACKEND_ENV_VAR])
117
+ ?? 'auto';
118
+ if (!STORE_BACKENDS.has(requested)) {
119
+ throw new Error(
120
+ `Unknown credential store backend "${requested}". Use auto, keychain, or file.`,
121
+ );
122
+ }
123
+ return requested;
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Keychain backends
128
+ // ---------------------------------------------------------------------------
129
+
130
+ /**
131
+ * Keychain item account for a store key. Keys are usually safe as-is; any key
132
+ * outside the conservative charset is mapped to a stable digest so the
133
+ * keychain never sees characters the backends cannot quote.
134
+ */
135
+ export function keychainAccountFor(key) {
136
+ return SAFE_ACCOUNT_PATTERN.test(key) ? key : `k:${createHash('sha256').update(key).digest('hex')}`;
137
+ }
138
+
139
+ /**
140
+ * Display label for a keychain item. Project names may contain quotes,
141
+ * backslashes, or control characters; those are replaced so the label can
142
+ * never break the backend command line (the token itself is untouched).
143
+ */
144
+ export function keychainLabelFor(label) {
145
+ const cleaned = String(label ?? '')
146
+ .replace(/[\u0000-\u001f\u007f"\\]/g, ' ')
147
+ .replace(/\s+/g, ' ')
148
+ .trim()
149
+ .slice(0, KEYCHAIN_LABEL_MAX);
150
+ return cleaned || 'VibeCompass sync token';
151
+ }
152
+
153
+ function promisifiedExecFile(execFileImpl) {
154
+ return (file, args, input) =>
155
+ new Promise((resolve, reject) => {
156
+ let child;
157
+ try {
158
+ child = execFileImpl(
159
+ file,
160
+ args,
161
+ { encoding: 'utf8', maxBuffer: 1024 * 1024, windowsHide: true },
162
+ (error, stdout, stderr) => {
163
+ if (error) {
164
+ const wrapped = new Error(error.message);
165
+ wrapped.code = error.code;
166
+ wrapped.exitCode = typeof error.code === 'number' ? error.code : null;
167
+ wrapped.stderr = typeof stderr === 'string' ? stderr : '';
168
+ wrapped.stdout = typeof stdout === 'string' ? stdout : '';
169
+ reject(wrapped);
170
+ return;
171
+ }
172
+ resolve({ stdout: typeof stdout === 'string' ? stdout : '', stderr: typeof stderr === 'string' ? stderr : '' });
173
+ },
174
+ );
175
+ } catch (error) {
176
+ reject(error);
177
+ return;
178
+ }
179
+ if (child?.stdin) {
180
+ if (input !== undefined) {
181
+ child.stdin.on('error', () => {});
182
+ child.stdin.end(input);
183
+ } else {
184
+ child.stdin.end();
185
+ }
186
+ }
187
+ });
188
+ }
189
+
190
+ function assertSafeAccount(account) {
191
+ if (!SAFE_ACCOUNT_PATTERN.test(account)) {
192
+ throw new Error(`Credential store key "${account}" contains characters the keychain backend does not accept.`);
193
+ }
194
+ }
195
+
196
+ function quoteForSecurityInteractive(value) {
197
+ // `security -i` tokenizes its command line with double quotes; the values we
198
+ // pass (store keys, hex tokens, short labels) never contain quotes or
199
+ // backslashes, so a plain wrap is exact. Anything else is refused upstream.
200
+ if (/["\\\r\n]/.test(value)) {
201
+ throw new Error('Keychain values may not contain quotes, backslashes, or line breaks.');
202
+ }
203
+ return `"${value}"`;
204
+ }
205
+
206
+ function createMacOsSecurityBackend(execFileImpl) {
207
+ const run = promisifiedExecFile(execFileImpl);
208
+ return {
209
+ name: 'keychain',
210
+ description: 'macOS Keychain',
211
+ async available() {
212
+ return true;
213
+ },
214
+ async get(account) {
215
+ assertSafeAccount(account);
216
+ try {
217
+ const { stdout } = await run('security', [
218
+ 'find-generic-password',
219
+ '-a', account,
220
+ '-s', KEYCHAIN_SERVICE,
221
+ '-w',
222
+ ]);
223
+ const value = stdout.replace(/\r?\n$/, '');
224
+ return value === '' ? null : value;
225
+ } catch (error) {
226
+ if (error.exitCode === 44) {
227
+ return null; // errSecItemNotFound
228
+ }
229
+ throw new Error(`macOS Keychain read failed (${describeExecError(error)}). Set ${STORE_BACKEND_ENV_VAR}=file to use the file store instead.`);
230
+ }
231
+ },
232
+ async set(account, secret, label) {
233
+ assertSafeAccount(account);
234
+ // The token is handed to `security -i` on stdin so it never appears in a
235
+ // process argument list.
236
+ const command = [
237
+ 'add-generic-password',
238
+ '-a', quoteForSecurityInteractive(account),
239
+ '-s', quoteForSecurityInteractive(KEYCHAIN_SERVICE),
240
+ '-l', quoteForSecurityInteractive(label),
241
+ '-j', quoteForSecurityInteractive('VibeCompass hosted sync token'),
242
+ '-U',
243
+ '-w', quoteForSecurityInteractive(secret),
244
+ ].join(' ');
245
+ try {
246
+ await run('security', ['-i'], `${command}\n`);
247
+ } catch (error) {
248
+ throw new Error(`macOS Keychain write failed (${describeExecError(error)}). Retry with --credential-store file to use the file store instead.`);
249
+ }
250
+ },
251
+ async remove(account) {
252
+ assertSafeAccount(account);
253
+ try {
254
+ await run('security', ['delete-generic-password', '-a', account, '-s', KEYCHAIN_SERVICE]);
255
+ return true;
256
+ } catch (error) {
257
+ if (error.exitCode === 44) {
258
+ return false;
259
+ }
260
+ throw new Error(`macOS Keychain delete failed (${describeExecError(error)}).`);
261
+ }
262
+ },
263
+ };
264
+ }
265
+
266
+ function createSecretToolBackend(execFileImpl) {
267
+ const run = promisifiedExecFile(execFileImpl);
268
+ return {
269
+ name: 'keychain',
270
+ description: 'system keyring (secret-tool)',
271
+ async available() {
272
+ try {
273
+ await run('secret-tool', ['lookup', 'service', `${KEYCHAIN_SERVICE}.probe`, 'account', 'probe']);
274
+ return true;
275
+ } catch (error) {
276
+ if (error.code === 'ENOENT') {
277
+ return false;
278
+ }
279
+ // A missing item exits 1; that still proves the tool exists.
280
+ return error.exitCode === 1;
281
+ }
282
+ },
283
+ async get(account) {
284
+ assertSafeAccount(account);
285
+ try {
286
+ const { stdout } = await run('secret-tool', ['lookup', 'service', KEYCHAIN_SERVICE, 'account', account]);
287
+ const value = stdout.replace(/\r?\n$/, '');
288
+ return value === '' ? null : value;
289
+ } catch (error) {
290
+ if (error.exitCode === 1) {
291
+ return null;
292
+ }
293
+ throw new Error(`Keyring read failed (${describeExecError(error)}). Set ${STORE_BACKEND_ENV_VAR}=file to use the file store instead.`);
294
+ }
295
+ },
296
+ async set(account, secret, label) {
297
+ assertSafeAccount(account);
298
+ try {
299
+ await run(
300
+ 'secret-tool',
301
+ ['store', `--label=${label}`, 'service', KEYCHAIN_SERVICE, 'account', account],
302
+ secret,
303
+ );
304
+ } catch (error) {
305
+ throw new Error(`Keyring write failed (${describeExecError(error)}). Retry with --credential-store file to use the file store instead.`);
306
+ }
307
+ },
308
+ async remove(account) {
309
+ assertSafeAccount(account);
310
+ try {
311
+ await run('secret-tool', ['clear', 'service', KEYCHAIN_SERVICE, 'account', account]);
312
+ return true;
313
+ } catch (error) {
314
+ if (error.exitCode === 1) {
315
+ return false;
316
+ }
317
+ throw new Error(`Keyring delete failed (${describeExecError(error)}).`);
318
+ }
319
+ },
320
+ };
321
+ }
322
+
323
+ function describeExecError(error) {
324
+ if (error?.code === 'ENOENT') {
325
+ return 'tool not found';
326
+ }
327
+ const stderr = normalizeOptionalString(error?.stderr);
328
+ if (stderr) {
329
+ return stderr.split(/\r?\n/)[0];
330
+ }
331
+ return error?.message ?? 'unknown error';
332
+ }
333
+
334
+ /**
335
+ * Picks the platform keychain backend, or null when none exists. An injected
336
+ * `keychainBackend` (tests) short-circuits detection; `false` disables it.
337
+ */
338
+ export async function detectKeychainBackend(options = {}) {
339
+ if (options.keychainBackend === false) {
340
+ return null;
341
+ }
342
+ if (options.keychainBackend && typeof options.keychainBackend === 'object') {
343
+ return options.keychainBackend;
344
+ }
345
+
346
+ const platform = options.platform ?? process.platform;
347
+ const execFileImpl = options.execFile ?? nodeExecFile;
348
+
349
+ if (platform === 'darwin') {
350
+ return createMacOsSecurityBackend(execFileImpl);
351
+ }
352
+ if (platform === 'linux') {
353
+ const backend = createSecretToolBackend(execFileImpl);
354
+ return (await backend.available()) ? backend : null;
355
+ }
356
+ return null;
357
+ }
358
+
359
+ // ---------------------------------------------------------------------------
360
+ // Index file
361
+ // ---------------------------------------------------------------------------
362
+
363
+ function emptyIndex() {
364
+ return { version: CREDENTIAL_STORE_VERSION, entries: {} };
365
+ }
366
+
367
+ async function assertPrivateFile(filePath, platform) {
368
+ if (platform === 'win32') {
369
+ return;
370
+ }
371
+ const info = await stat(filePath);
372
+ if ((info.mode & 0o077) !== 0) {
373
+ throw new Error(
374
+ `Refusing to read ${filePath}: it is readable by other users. Run: chmod 600 ${filePath}`,
375
+ );
376
+ }
377
+ }
378
+
379
+ export async function readCredentialIndex(options = {}) {
380
+ const platform = options.platform ?? process.platform;
381
+ const filePath = resolveCredentialStorePath(options);
382
+ let raw;
383
+ try {
384
+ await assertPrivateFile(filePath, platform);
385
+ raw = await readFile(filePath, 'utf8');
386
+ } catch (error) {
387
+ if (error && typeof error === 'object' && error.code === 'ENOENT') {
388
+ return { filePath, exists: false, index: emptyIndex() };
389
+ }
390
+ throw error;
391
+ }
392
+
393
+ let parsed;
394
+ try {
395
+ parsed = JSON.parse(raw);
396
+ } catch {
397
+ throw new Error(`Credential store ${filePath} is not valid JSON. Fix or remove the file and store the token again.`);
398
+ }
399
+ if (!parsed || typeof parsed !== 'object' || parsed.version !== CREDENTIAL_STORE_VERSION || !parsed.entries || typeof parsed.entries !== 'object') {
400
+ throw new Error(`Credential store ${filePath} has an unsupported shape (expected version ${CREDENTIAL_STORE_VERSION}).`);
401
+ }
402
+ return { filePath, exists: true, index: parsed };
403
+ }
404
+
405
+ export async function writeCredentialIndex(index, options = {}, hooks = {}) {
406
+ const platform = options.platform ?? process.platform;
407
+ const dir = resolveCredentialStoreDir(options);
408
+ const filePath = path.join(dir, CREDENTIAL_STORE_FILE_NAME);
409
+ await mkdir(dir, { recursive: true, mode: 0o700 });
410
+ if (platform !== 'win32') {
411
+ await chmod(dir, 0o700);
412
+ }
413
+ // Exclusive creation with a random suffix: concurrent writers in one
414
+ // process or across processes can never share a temp file.
415
+ const tempPath = `${filePath}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
416
+ const handle = await open(tempPath, 'wx', 0o600);
417
+ try {
418
+ await handle.writeFile(`${JSON.stringify(index, null, 2)}\n`, 'utf8');
419
+ } finally {
420
+ await handle.close();
421
+ }
422
+ if (platform !== 'win32') {
423
+ await chmod(tempPath, 0o600);
424
+ }
425
+ if (typeof hooks.beforeCommit === 'function') {
426
+ // Defensive tripwire, not the exclusion mechanism: the lock below is what
427
+ // keeps writers apart. This only refuses to publish if the lock file was
428
+ // removed by hand while this writer was running.
429
+ try {
430
+ await hooks.beforeCommit();
431
+ } catch (error) {
432
+ await unlink(tempPath).catch(() => {});
433
+ throw error;
434
+ }
435
+ }
436
+ if (typeof hooks.beforeRename === 'function') {
437
+ await hooks.beforeRename(); // test seam only
438
+ }
439
+ await rename(tempPath, filePath);
440
+ return filePath;
441
+ }
442
+
443
+ function isProcessAlive(pid) {
444
+ if (!Number.isInteger(pid) || pid <= 0) {
445
+ return false;
446
+ }
447
+ try {
448
+ process.kill(pid, 0);
449
+ return true;
450
+ } catch (error) {
451
+ // EPERM: the process exists but belongs to another user — still alive.
452
+ return error?.code === 'EPERM';
453
+ }
454
+ }
455
+
456
+ async function readLockOwner(lockPath) {
457
+ try {
458
+ const parsed = JSON.parse(await readFile(lockPath, 'utf8'));
459
+ return parsed && typeof parsed === 'object' && typeof parsed.id === 'string' ? parsed : null;
460
+ } catch {
461
+ return null;
462
+ }
463
+ }
464
+
465
+ /**
466
+ * Creates a lock file atomically: the owner record is written to a private
467
+ * temp file and hard-linked into place, so the lock either appears fully
468
+ * formed or not at all and a contender never reads a half-written record.
469
+ * Returns false when the path is already taken.
470
+ */
471
+ async function createLockAtomically(lockPath, owner) {
472
+ const tempPath = `${lockPath}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
473
+ const handle = await open(tempPath, 'wx', 0o600);
474
+ try {
475
+ await handle.writeFile(JSON.stringify(owner), 'utf8');
476
+ } finally {
477
+ await handle.close();
478
+ }
479
+ try {
480
+ await link(tempPath, lockPath);
481
+ return true;
482
+ } catch (error) {
483
+ if (error?.code === 'EEXIST') {
484
+ return false;
485
+ }
486
+ throw error;
487
+ } finally {
488
+ await unlink(tempPath).catch(() => {});
489
+ }
490
+ }
491
+
492
+ /**
493
+ * A contended lock may be reclaimed only when it was taken on this host by
494
+ * a process that no longer exists. A live process keeps its lock however
495
+ * long it waits (a keychain prompt, a paused terminal); a lock from another
496
+ * host or an unreadable lock is never removed automatically.
497
+ */
498
+ function isAbandoned(owner, host) {
499
+ return Boolean(owner) && owner.host === host && !isProcessAlive(owner.pid);
500
+ }
501
+
502
+ function describeLockOwner(owner) {
503
+ return owner ? `pid ${owner.pid}${owner.host ? ` on ${owner.host}` : ''}` : 'an unreadable lock file';
504
+ }
505
+
506
+ /**
507
+ * Serialized dead-owner recovery. Exactly one process may unlink an
508
+ * abandoned lock: the holder of the reclaim lock, which re-reads the owner
509
+ * record under that lock and removes it only if it is still the same dead
510
+ * owner. A lock re-acquired by a live process in the meantime has a
511
+ * different id and is left alone. A reclaim lock left behind by a reclaimer
512
+ * that itself died is never cleaned automatically (it blocks recovery and is
513
+ * named in the timeout error) so that no old observation can ever unlink a
514
+ * successor's lock.
515
+ */
516
+ async function reclaimAbandonedLock(lockPath, reclaimPath, seenOwner, host) {
517
+ const reclaimer = { id: randomBytes(12).toString('hex'), pid: process.pid, host, acquired_at: new Date().toISOString() };
518
+ if (!(await createLockAtomically(reclaimPath, reclaimer))) {
519
+ return false; // another reclaimer is active; the caller retries after it finishes
520
+ }
521
+ try {
522
+ const current = await readLockOwner(lockPath);
523
+ if (current && current.id === seenOwner.id && isAbandoned(current, host)) {
524
+ await unlink(lockPath).catch(() => {});
525
+ }
526
+ return true;
527
+ } finally {
528
+ const mine = await readLockOwner(reclaimPath);
529
+ if (mine && mine.id === reclaimer.id) {
530
+ await unlink(reclaimPath).catch(() => {});
531
+ }
532
+ }
533
+ }
534
+
535
+ /**
536
+ * Cross-process mutual exclusion for read-modify-write of the index and the
537
+ * keychain operations that go with it. The lock is a file next to the index
538
+ * that appears atomically (see createLockAtomically) and names its owner
539
+ * (random id, pid, host). It is never taken from a live process on this
540
+ * host; writers wait up to lockTimeoutMs, then fail naming the owner. When
541
+ * the owner process no longer exists, recovery goes through a second lock so
542
+ * that exactly one reclaimer removes it after re-reading the owner under that
543
+ * lock. Readers do not take the lock: the atomic rename in
544
+ * writeCredentialIndex guarantees a complete snapshot.
545
+ */
546
+ export async function withCredentialStoreLock(options, work) {
547
+ const platform = options.platform ?? process.platform;
548
+ const dir = resolveCredentialStoreDir(options);
549
+ await mkdir(dir, { recursive: true, mode: 0o700 });
550
+ if (platform !== 'win32') {
551
+ await chmod(dir, 0o700);
552
+ }
553
+ const lockPath = path.join(dir, `${CREDENTIAL_STORE_FILE_NAME}${LOCK_FILE_SUFFIX}`);
554
+ const reclaimPath = `${lockPath}${RECLAIM_LOCK_SUFFIX}`;
555
+ const host = options.hostname ?? os.hostname();
556
+ const timeoutMs = options.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
557
+ const deadline = Date.now() + timeoutMs;
558
+ const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
559
+ const owner = { id: randomBytes(12).toString('hex'), pid: process.pid, host, acquired_at: new Date().toISOString() };
560
+
561
+ for (;;) {
562
+ if (await createLockAtomically(lockPath, owner)) {
563
+ break;
564
+ }
565
+ const current = await readLockOwner(lockPath);
566
+ if (current === null) {
567
+ try {
568
+ await stat(lockPath);
569
+ } catch (error) {
570
+ if (error?.code === 'ENOENT') {
571
+ continue; // released between our attempt and the read
572
+ }
573
+ throw error;
574
+ }
575
+ } else if (isAbandoned(current, host)) {
576
+ if (await reclaimAbandonedLock(lockPath, reclaimPath, current, host)) {
577
+ continue; // removed (or confirmed already gone); try to acquire again
578
+ }
579
+ // Another reclaimer holds the reclaim lock (or a dead one blocks it):
580
+ // fall through to the bounded wait so this can never spin forever.
581
+ }
582
+ if (Date.now() > deadline) {
583
+ let reclaimNote = '';
584
+ const reclaimHolder = await readLockOwner(reclaimPath);
585
+ if (reclaimHolder) {
586
+ reclaimNote = ` A reclaim lock (${reclaimPath}, ${describeLockOwner(reclaimHolder)}) is also present.`;
587
+ }
588
+ throw new Error(
589
+ `The credential store is locked by another vibecompass process (${describeLockOwner(current)}; ${lockPath}). `
590
+ + 'It is released automatically when that process exits. If no such process exists (for example the lock was left by another machine), delete the lock file and retry.'
591
+ + reclaimNote,
592
+ );
593
+ }
594
+ await sleep(20 + Math.floor(Math.random() * 40));
595
+ }
596
+
597
+ const assertOwned = async () => {
598
+ const current = await readLockOwner(lockPath);
599
+ if (!current || current.id !== owner.id) {
600
+ throw new Error(
601
+ 'The credential store lock file was removed while this command was running; nothing was written. Retry the command.',
602
+ );
603
+ }
604
+ };
605
+
606
+ try {
607
+ return await work({ assertOwned });
608
+ } finally {
609
+ const current = await readLockOwner(lockPath);
610
+ if (current && current.id === owner.id) {
611
+ await unlink(lockPath).catch(() => {});
612
+ }
613
+ }
614
+ }
615
+
616
+ function publicEntry(key, entry) {
617
+ return {
618
+ key,
619
+ apiUrl: entry.api_url,
620
+ projectId: entry.project_id,
621
+ backend: entry.backend,
622
+ tokenPrefix: entry.token_prefix ?? null,
623
+ label: entry.label ?? null,
624
+ storedAt: entry.stored_at ?? null,
625
+ source: entry.source ?? null,
626
+ };
627
+ }
628
+
629
+ // ---------------------------------------------------------------------------
630
+ // Public API
631
+ // ---------------------------------------------------------------------------
632
+
633
+ /** Builds resolver options from a command environment (`{ env, runtime }`). */
634
+ export function credentialStoreOptionsFrom(environment = {}) {
635
+ const overrides = environment.runtime?.credentialStore
636
+ ?? environment.credentialStore
637
+ ?? {};
638
+ return {
639
+ env: environment.env ?? process.env,
640
+ ...overrides,
641
+ };
642
+ }
643
+
644
+ /**
645
+ * Resolves the credential for a binding through the D-355 order. Never
646
+ * throws for a merely missing credential; callers format the guidance.
647
+ */
648
+ export async function resolveSyncCredential(binding, options = {}) {
649
+ const env = options.env ?? process.env;
650
+ const envVar = binding?.credentialEnvVar;
651
+ const fromEnv = envVar ? normalizeOptionalString(env[envVar]) : null;
652
+ if (fromEnv) {
653
+ return { credential: fromEnv, source: 'env', envVar };
654
+ }
655
+
656
+ let key;
657
+ try {
658
+ key = credentialStoreKey(binding.apiUrl, binding.projectId);
659
+ } catch {
660
+ return { credential: null, source: 'missing', envVar };
661
+ }
662
+
663
+ const { index, filePath } = await readCredentialIndex(options);
664
+ const entry = index.entries[key];
665
+ if (!entry) {
666
+ return { credential: null, source: 'missing', envVar, key };
667
+ }
668
+
669
+ if (entry.backend === 'file') {
670
+ const token = normalizeOptionalString(entry.token);
671
+ return token
672
+ ? { credential: token, source: 'file', envVar, key, entry: publicEntry(key, entry), storePath: filePath }
673
+ : { credential: null, source: 'missing', envVar, key, detail: 'file entry has no token' };
674
+ }
675
+
676
+ if (entry.backend === 'keychain') {
677
+ const backend = await detectKeychainBackend(options);
678
+ if (!backend) {
679
+ return {
680
+ credential: null,
681
+ source: 'missing',
682
+ envVar,
683
+ key,
684
+ detail: 'the index says this token is in the OS keychain, but no keychain backend is available here',
685
+ };
686
+ }
687
+ const token = await backend.get(entry.keychain_account ?? keychainAccountFor(key));
688
+ return token
689
+ ? { credential: token, source: 'keychain', envVar, key, entry: publicEntry(key, entry) }
690
+ : { credential: null, source: 'missing', envVar, key, detail: 'the index names a keychain item that no longer exists' };
691
+ }
692
+
693
+ return { credential: null, source: 'missing', envVar, key, detail: `unknown backend "${entry.backend}"` };
694
+ }
695
+
696
+ /**
697
+ * Stores a token for a binding. `auto` prefers the keychain when available;
698
+ * the chosen backend is recorded in the index so later lookups never probe.
699
+ */
700
+ export async function storeSyncCredential(input, options = {}) {
701
+ const token = normalizeOptionalString(input?.token);
702
+ if (!token) {
703
+ throw new Error('A sync token is required.');
704
+ }
705
+ if (/\s/.test(token)) {
706
+ throw new Error('A sync token cannot contain whitespace. Paste it exactly as the dashboard showed it.');
707
+ }
708
+ const key = credentialStoreKey(input.apiUrl, input.projectId);
709
+ const policy = resolveStoreBackendPolicy({ ...options, backend: input.backend });
710
+ const label = normalizeOptionalString(input.label) ?? `VibeCompass sync token (${normalizeOptionalString(input.projectId)})`;
711
+ const warnings = [];
712
+
713
+ return withCredentialStoreLock(options, async (lock) => {
714
+ let backend = null;
715
+ if (policy !== 'file') {
716
+ backend = await detectKeychainBackend(options);
717
+ if (!backend && policy === 'keychain') {
718
+ throw new Error('No OS keychain backend is available on this machine. Use --credential-store file instead.');
719
+ }
720
+ }
721
+
722
+ const { index } = await readCredentialIndex(options);
723
+ const previous = index.entries[key] ?? null;
724
+ const account = keychainAccountFor(key);
725
+
726
+ const entry = {
727
+ api_url: normalizeApiUrl(input.apiUrl),
728
+ project_id: normalizeOptionalString(input.projectId),
729
+ backend: 'file',
730
+ token_prefix: tokenPrefixOf(token),
731
+ label,
732
+ stored_at: (options.now ?? new Date()).toISOString(),
733
+ source: normalizeOptionalString(input.source) ?? 'sync-credential',
734
+ };
735
+
736
+ if (backend) {
737
+ try {
738
+ await backend.set(account, token, keychainLabelFor(label));
739
+ entry.backend = 'keychain';
740
+ entry.keychain_account = account;
741
+ } catch (error) {
742
+ if (policy !== 'auto') {
743
+ throw error;
744
+ }
745
+ // Headless session, locked keychain, missing Secret Service: keep the
746
+ // token rather than losing a one-time claim, and say where it went.
747
+ warnings.push(`OS keychain write failed (${error.message}); the token was stored in the credential store file instead.`);
748
+ backend = null;
749
+ }
750
+ }
751
+ if (!backend) {
752
+ entry.token = token;
753
+ }
754
+
755
+ // A previous keychain item is retired when the new entry moves to the
756
+ // file backend so a stale secret never lingers in the keychain.
757
+ if (previous?.backend === 'keychain' && !backend) {
758
+ const previousBackend = await detectKeychainBackend(options);
759
+ if (previousBackend) {
760
+ await previousBackend.remove(previous.keychain_account ?? keychainAccountFor(key)).catch(() => false);
761
+ }
762
+ }
763
+
764
+ index.entries[key] = entry;
765
+ const filePath = await writeCredentialIndex(index, options, {
766
+ beforeCommit: lock.assertOwned,
767
+ beforeRename: options.testHooks?.beforeIndexRename,
768
+ });
769
+
770
+ return {
771
+ key,
772
+ backend: entry.backend,
773
+ backendDescription: backend ? backend.description : filePath,
774
+ storePath: filePath,
775
+ tokenPrefix: entry.token_prefix,
776
+ replaced: Boolean(previous),
777
+ warnings,
778
+ };
779
+ });
780
+ }
781
+
782
+ export async function removeSyncCredential(input, options = {}) {
783
+ const key = credentialStoreKey(input.apiUrl, input.projectId);
784
+ return withCredentialStoreLock(options, async (lock) => {
785
+ const { index, exists } = await readCredentialIndex(options);
786
+ const entry = index.entries[key];
787
+ if (!entry) {
788
+ return { key, removed: false, backend: null };
789
+ }
790
+
791
+ let keychainRemoved = null;
792
+ if (entry.backend === 'keychain') {
793
+ const backend = await detectKeychainBackend(options);
794
+ keychainRemoved = backend ? await backend.remove(entry.keychain_account ?? keychainAccountFor(key)) : false;
795
+ }
796
+
797
+ delete index.entries[key];
798
+ const filePath = exists
799
+ ? await writeCredentialIndex(index, options, { beforeCommit: lock.assertOwned })
800
+ : resolveCredentialStorePath(options);
801
+ return { key, removed: true, backend: entry.backend, keychainRemoved, storePath: filePath };
802
+ });
803
+ }
804
+
805
+ export async function listSyncCredentials(options = {}) {
806
+ const { index, filePath, exists } = await readCredentialIndex(options);
807
+ return {
808
+ storePath: filePath,
809
+ exists,
810
+ entries: Object.entries(index.entries)
811
+ .map(([key, entry]) => publicEntry(key, entry))
812
+ .sort((a, b) => a.key.localeCompare(b.key)),
813
+ };
814
+ }
815
+
816
+ /** Human description of where a resolved credential came from (never the value). */
817
+ export function describeCredentialSource(resolution) {
818
+ switch (resolution?.source) {
819
+ case 'env':
820
+ return `environment variable ${resolution.envVar}`;
821
+ case 'keychain':
822
+ return 'OS keychain';
823
+ case 'file':
824
+ return `credential store file${resolution.storePath ? ` (${resolution.storePath})` : ''}`;
825
+ default:
826
+ return 'missing';
827
+ }
828
+ }
829
+
830
+ /**
831
+ * The single missing-credential message every hosted command prints (D-355):
832
+ * login first, then the manual store, then the per-shell override.
833
+ */
834
+ export function formatMissingCredentialError(binding, context = {}) {
835
+ const target = binding?.target ? ` --sync-target ${binding.target}` : '';
836
+ const where = binding?.apiUrl && binding?.projectId
837
+ ? ` for ${binding.apiUrl} (project ${binding.projectId})`
838
+ : '';
839
+ const action = context.action ? `${context.action} needs a hosted sync token` : 'No hosted sync token found';
840
+ const detail = context.detail ? ` (${context.detail})` : '';
841
+ return (
842
+ `${action}${where}${detail}. `
843
+ + `Sign in from this terminal with "vibecompass login${target}" (opens the dashboard, no copy-paste), `
844
+ + `store a dashboard token with "vibecompass sync-credential set${target} --token-stdin", `
845
+ + `or export ${binding?.credentialEnvVar ?? 'VIBECOMPASS_SYNC_TOKEN'} for this shell only. `
846
+ + 'Lost the token? Create a new one on the hosted dashboard under Setup -> Hosted sync, or run login again.'
847
+ );
848
+ }
849
+
850
+ function normalizeOptionalString(value) {
851
+ if (typeof value !== 'string') {
852
+ return null;
853
+ }
854
+ const trimmed = value.trim();
855
+ return trimmed === '' ? null : trimmed;
856
+ }