agentlinkops 0.6.9 → 0.6.11

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 (52) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +18 -0
  4. package/README.md +44 -12
  5. package/cli/admission.js +9 -1
  6. package/cli/agent-setup.js +189 -20
  7. package/cli/catalog.js +7938 -1357
  8. package/cli/client.js +89 -23
  9. package/cli/cloud-error.js +21 -0
  10. package/cli/commands.js +15 -3
  11. package/cli/connection.js +74 -12
  12. package/cli/doctor.js +24 -9
  13. package/cli/gsc-browser.js +19 -6
  14. package/cli/import.js +4 -1
  15. package/cli/ledger.js +4 -7
  16. package/cli/lifecycle.js +5 -2
  17. package/cli/local-result.js +4 -4
  18. package/cli/main.js +50 -34
  19. package/cli/mcp-client.js +198 -0
  20. package/cli/native-lock.js +22 -0
  21. package/cli/oauth-credentials.js +196 -0
  22. package/cli/oauth-flow.js +259 -0
  23. package/cli/setup-plan.js +5 -5
  24. package/cli/status.js +14 -2
  25. package/cli/sync-lock.js +105 -0
  26. package/cli/sync.js +14 -6
  27. package/package.json +6 -3
  28. package/references/cli.md +22 -0
  29. package/references/local-crm-draft-handoff.md +76 -0
  30. package/references/portable-ledger-handoff.md +50 -0
  31. package/references/recipes/catalog.json +9 -9
  32. package/references/recipes/command-notes.md +17 -1
  33. package/references/recipes/placement-reconciliation.md +19 -5
  34. package/references/recipes/qualified-campaign-handoff.md +16 -4
  35. package/references/recipes/site-context-brief.md +2 -2
  36. package/references/recipes/sourced-linkable-asset.md +8 -8
  37. package/scripts/agentlinkops.py +30 -5
  38. package/scripts/outreach-crm-draft.py +121 -0
  39. package/scripts/portable-handoff.py +184 -0
  40. package/shared/lifecycle-contract.js +2 -2
  41. package/shared/response-contract.js +7 -1
  42. package/skills/agentlinkops-assets/SKILL.md +2 -2
  43. package/skills/agentlinkops-campaigns/SKILL.md +3 -3
  44. package/skills/agentlinkops-connect/SKILL.md +29 -6
  45. package/skills/agentlinkops-connect/references/agentlinkops.md +34 -13
  46. package/skills/agentlinkops-crm/SKILL.md +18 -4
  47. package/src/citations/adapter.js +3 -2
  48. package/src/citations/browser/engine.js +23 -7
  49. package/src/citations/login.js +42 -5
  50. package/src/discovery/contract.js +1 -1
  51. package/src/response-shaping.js +58 -0
  52. package/src/tool-views.js +3 -1
package/cli/main.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { acquireSyncLock } from './sync-lock.js';
1
2
  import {lifecycleMain} from './lifecycle.js';
2
3
  import {admissionMain} from './admission.js';
3
4
  import { commandsMain } from './commands.js';
@@ -77,20 +78,20 @@ const USAGE = `agentlinkops — a backlink ledger that lives in your repository
77
78
  .agentlinkops/citations/; live engines need
78
79
  engine-specific setup; the mock engine makes no paid calls
79
80
  agentlinkops receive --body FILE --headers FILE signed notification; authenticated pull
80
- agentlinkops connect --workspace ID --project-id ID [--origin URL] [--selection FILE]
81
+ agentlinkops connect --workspace ID --project-id ID [--origin URL] [--selection FILE] [--oauth --issuer URL --client-id ID [--credential-ref REF]]
81
82
  agentlinkops tools [TOOLSET] [--json] [--refresh] cloud commands by toolset (offline snapshot;
82
83
  --refresh reads the live catalog)
83
84
  agentlinkops describe NAME [--examples] [--output-schema] [--json] one command's exact schema
84
85
  agentlinkops call NAME [--args JSON | --file FILE | --set PATH=VALUE ...] [--dry-run] [-y]
85
86
  run a cloud command (retired names still resolve)
86
87
  agentlinkops agent setup [--client ID ...] [--scope project|user] [--origin URL] [--dry-run]
87
- install the skill pack and the right MCP view for each
88
- detected agent client (Claude Code, Codex, Cursor,
89
- Gemini CLI, Hermes); stores no credential
88
+ core connection/reference and the right MCP view;
89
+ optional --skill ID ... / --recipe ID ... / --all-skills;
90
+ stores no credential; preserves customer instructions
90
91
  agentlinkops agent status what is installed where
91
- agentlinkops agent remove [--scope project|user] [--apply] [--json]
92
+ agentlinkops agent remove [--skill ID ...] [--recipe ID ...] [--scope project|user] [--apply] [--json]
92
93
  agentlinkops agent recover [--scope project|user] [--apply] [--json]
93
- agentlinkops sync [--push-only] [--pull-only] [--dry-run] [--include-wanted] [--lifecycle]
94
+ agentlinkops sync [--push-only] [--pull-only] [--dry-run] [--include-wanted] [--lifecycle] [--recover-cursors]
94
95
  AGENTLINKOPS_TOKEN or AGENTLINKOPS_API_KEY
95
96
  agentlinkops check [--filter TEXT] [--all] [--json] [--fail-on-unknown]
96
97
  agentlinkops doctor verify ledger, verifier, cloud reachability, token
@@ -114,8 +115,9 @@ const USAGE = `agentlinkops — a backlink ledger that lives in your repository
114
115
  Suppliers: ${SUPPLIER_NAMES.join(', ')}
115
116
  A preset is a convenience over --map, never a requirement.
116
117
 
117
- Exit codes: 0 every expected link present · 1 an expected link observed absent with complete
118
- evidence · 2 usage, configuration or ledger error. An unknown never fails.
118
+ Exit codes: 0 no expected link conclusively absent · 1 an expected link observed absent with complete
119
+ evidence · 2 usage, configuration or ledger error. Unknown is nonfailing by default;
120
+ --fail-on-unknown returns 1 when any observation is unknown.
119
121
 
120
122
  Names: \`linktrail\` still runs this CLI, LINKTRAIL_* variables are still read, and an existing
121
123
  .linktrail/ directory is still used, each with a one-line notice, until the DP-0029 cutover.`;
@@ -133,7 +135,7 @@ function reportProblems(problems, what, out) {
133
135
  if (problems.length > 20) out(` … and ${problems.length - 20} more`);
134
136
  }
135
137
 
136
- export async function main(argv = process.argv.slice(2), { cwd = process.cwd(), out = console.log, err = console.error, env = process.env, fetchImpl = globalThis.fetch } = {}) {
138
+ export async function main(argv = process.argv.slice(2), { cwd = process.cwd(), out = console.log, err = console.error, env = process.env, fetchImpl = globalThis.fetch, afterHistoryPersisted, afterStatePersisted } = {}) {
137
139
  if (argv[0] === 'gsc-links') return await gscLinksMain(argv, { cwd, out });
138
140
  // Citation watches keep their own files under .agentlinkops/citations/ and never
139
141
  // touch the link ledger, so they dispatch before its requirement like context does.
@@ -147,7 +149,9 @@ export async function main(argv = process.argv.slice(2), { cwd = process.cwd(),
147
149
 
148
150
  let syncLock = null;
149
151
  try {
150
- if (command === 'lifecycle') return await lifecycleMain(args,{cwd,env,fetchImpl,out});
152
+ if (args['recover-cursors'] !== undefined && (command !== 'sync' || args['recover-cursors'] !== true || args['pull-only'] !== true || args['push-only']))
153
+ throw new ConfigError('Cursor recovery requires agentlinkops sync --pull-only --recover-cursors; it performs no remote writes.');
154
+ if (command === 'lifecycle') return await lifecycleMain(args,{cwd,env,fetchImpl,out,err});
151
155
  if (command === 'admission') { const config=await loadConfig({cwd}); return await admissionMain(argv.slice(1),{client:createClient({...cloudConnection(config,env),fetchImpl}),out,err}); }
152
156
  if (command === 'disavow') return await disavowMain(args,{cwd,env,fetchImpl,out});
153
157
  if (command === 'index') return await indexMain(args,{cwd,env,fetchImpl,out});
@@ -156,8 +160,8 @@ export async function main(argv = process.argv.slice(2), { cwd = process.cwd(),
156
160
  if (command === 'skill') return await skillMain(argv, { out });
157
161
  if (command === 'agent') return await agentMain(argv, { cwd, out, err });
158
162
  if (command === 'connect') {
159
- if (args._.length !== 1 || args.tag.length || Object.keys(args).some(key => !['_', 'tag', 'origin', 'workspace', 'project-id', 'selection'].includes(key))) throw new ConfigError('connect --workspace ID --project-id ID [--origin URL] [--selection FILE]');
160
- return await connectMain(args, { cwd, out });
163
+ if (args._.length !== 1 || args.tag.length || Object.keys(args).some(key => !['_', 'tag', 'origin', 'workspace', 'project-id', 'selection', 'oauth', 'issuer', 'client-id', 'credential-ref'].includes(key))) throw new ConfigError('connect --workspace ID --project-id ID [--origin URL] [--selection FILE] [--oauth --issuer URL --client-id ID [--credential-ref REF]]');
164
+ return await connectMain(args, { cwd, out, env, fetchImpl });
161
165
  }
162
166
  if (command === 'check' && (args.source !== undefined || args.target !== undefined)) {
163
167
  if (typeof args.source !== 'string' || typeof args.target !== 'string') throw new ConfigError('check requires both --source URL and --target URL');
@@ -329,9 +333,7 @@ export async function main(argv = process.argv.slice(2), { cwd = process.cwd(),
329
333
 
330
334
  const config = await loadConfig({ cwd });
331
335
  if (['sync','receive','check','compact','adopt-result'].includes(command) && !(command === 'sync' && args['dry-run'])) {
332
- const lockPath = join(config.dir, 'sync.lock');
333
- try { syncLock = { handle: await open(lockPath, 'wx'), path: lockPath }; }
334
- catch (error) { if (error.code === 'ENOENT') throw new ConfigError('no ledger here — run `agentlinkops init` first'); if (error.code === 'EEXIST') throw new ConfigError('sync writer already locked; retry delivery after it finishes'); throw error; }
336
+ syncLock = await acquireSyncLock(config.dir, command);
335
337
  }
336
338
  if (command === 'adopt-result') {
337
339
  if (args._.length !== 2 || Object.keys(args).some(key => !['_', 'intent', 'tag'].includes(key)) || args.tag.length) throw new ConfigError('adopt-result FILE [--intent wanted|expected]');
@@ -506,7 +508,7 @@ export async function main(argv = process.argv.slice(2), { cwd = process.cwd(),
506
508
  for (const change of found.slice(0, 100)) out(` ${change.at.slice(0, 10)} ${change.from} -> ${change.to} ${change.id}`);
507
509
  return 0;
508
510
  }
509
- const groups = { appeared: 'appeared', lost: 'LOST', cannot_say: 'cannot say', never_checked: 'never checked' };
511
+ const groups = { appeared: 'appeared', lost: 'LOST', suspected_missing: 'suspected missing', cannot_say: 'cannot say', never_checked: 'never checked' };
510
512
  for (const [kind, label] of Object.entries(groups)) {
511
513
  const rows = found.filter(item => item.kind === kind);
512
514
  if (!rows.length) continue;
@@ -524,7 +526,7 @@ export async function main(argv = process.argv.slice(2), { cwd = process.cwd(),
524
526
  if (command === 'receive') {
525
527
  if (!args.body || !args.headers) throw new ConfigError('receive --body RAW_BODY_FILE --headers HEADERS_JSON_FILE');
526
528
  verifyDelivery({ body: await readFile(String(args.body), 'utf8'), headers: JSON.parse(await readFile(String(args.headers), 'utf8')),
527
- secret: readEnv(process.env, 'WEBHOOK_SECRET').value, workspaceId: config.cloud?.workspaceId });
529
+ secret: readEnv(env, 'WEBHOOK_SECRET').value, workspaceId: config.cloud?.workspaceId });
528
530
  args['pull-only'] = true;
529
531
  args['push-only'] = false;
530
532
  }
@@ -533,11 +535,11 @@ export async function main(argv = process.argv.slice(2), { cwd = process.cwd(),
533
535
  if(args.lifecycle&&args['push-only'])throw new ConfigError('--lifecycle requires a pull; omit --push-only.');
534
536
  const selection = { includeWanted: args['include-wanted'] === true, ledgerIds: config.cloud?.ledgerIds ?? null };
535
537
  if (args['dry-run']) { out(JSON.stringify({ network: false, rows: args['pull-only'] ? [] : syncPlan(ledger.entries, state, selection) }, null, 2)); return 0; }
536
- const connection = cloudConnection(config);
538
+ const connection = cloudConnection(config, env);
537
539
  if (!connection.origin) { err('no cloud origin — run agentlinkops connect'); return 2; }
538
- if (!connection.token) { err(keySetup(connection.origin)); return 2; }
540
+ if (!connection.token && !connection.auth) { err(keySetup(connection.origin)); return 2; }
539
541
  if (!connection.projectId) { err('no project id — run agentlinkops connect'); return 2; }
540
- const client = createClient(connection);
542
+ const client = createClient({ ...connection, fetchImpl });
541
543
  let next = { ...state };
542
544
  let pushFailed = false;
543
545
  if (!args['pull-only']) {
@@ -549,36 +551,47 @@ export async function main(argv = process.argv.slice(2), { cwd = process.cwd(),
549
551
  pushFailed = pushed.failed.length > 0;
550
552
  }
551
553
  if (!args['push-only']) {
552
- const pulled = await pullEvents(client, next, {
553
- onSnapshot: async snapshot => {
554
- // A resync writes the snapshot beside the mirror rather than into the ledger: the
555
- // cloud is a mirror of the customer's file and never the other way round.
556
- await writeFile(join(config.dir, `snapshot-${Date.now()}.json`), `${JSON.stringify(snapshot, null, 1)}\n`, 'utf8');
554
+ const snapshots = [];
555
+ const recovery = {
556
+ recoverInvalidCursor: args['recover-cursors'] === true,
557
+ onSnapshot: async (snapshot, feed) => {
558
+ const path = join(config.dir, `snapshot-${feed}-${Date.now()}-${crypto.randomUUID()}.json`);
559
+ await writeFile(path, `${JSON.stringify(snapshot, null, 1)}\n`, {encoding: 'utf8', flag: 'wx', mode: 0o600});
560
+ snapshots.push({feed, path});
557
561
  },
562
+ };
563
+ const pulled = await pullEvents(client, next, {
564
+ ...recovery,
558
565
  });
559
566
  // Two feeds, two cursors. They are pulled separately and written separately, so an
560
567
  // expiry on one cannot move the other.
561
- const targets = await pullTargetEvents(client, next);
568
+ const targets = await pullTargetEvents(client, next, recovery);
562
569
  const index = await watchIndex(client, next);
563
570
  if(args.lifecycle)next.lifecycle=await pullLifecycleMirrors(client,next,{projectId:connection.projectId,index,ledgerIds:selection.ledgerIds??ledger.entries.map(entry=>entry.id)});
564
571
  const { fresh, duplicates } = await persistPulledHistory(config, pulled.events, targets.events, index);
572
+ await afterHistoryPersisted?.();
565
573
  next.cursors = { ...(next.cursors ?? {}), events: pulled.cursor, target_events: targets.cursor };
566
574
  next.gaps = [...(next.gaps ?? []), ...pulled.gaps, ...targets.gaps];
567
575
  out(`pulled ${pulled.events.length} event(s) over ${pulled.pages} page(s), ${fresh.length} observation(s)${duplicates.length ? `, ${duplicates.length} already held` : ''}`);
568
576
  if (targets.events.length) out(`pulled ${targets.events.length} target event(s) over ${targets.pages} page(s)`);
569
577
  // A gap is stated, never inferred. An empty feed means nothing happened; an expired
570
578
  // cursor means something happened and is gone.
571
- for (const gap of [...pulled.gaps, ...targets.gaps]) out(` RESYNC ${gap.at}: ${gap.reason}; a snapshot was written to ${config.dir}`);
579
+ for (const gap of [...pulled.gaps, ...targets.gaps]) {
580
+ const snapshot = snapshots.find(item => item.feed === gap.feed);
581
+ out(` RESYNC ${gap.at}: ${gap.reason}; ${snapshot ? `snapshot saved to ${snapshot.path}` : 'current-state snapshot applied'}`);
582
+ }
572
583
  }
573
584
  await writeState(config.paths.state, next);
585
+ await afterStatePersisted?.();
574
586
  return pushFailed ? 2 : 0;
575
587
  }
576
588
 
577
589
  if (command === 'check') {
590
+ if (ledger.problems.length) { reportProblems(ledger.problems, 'ledger line(s)', err); return 2; }
578
591
  const state = await readState(config.paths.state);
579
592
  const selected = selectEntries(ledger.entries, { filter: args.filter === true ? null : args.filter });
580
593
  const entries = args.all ? selected : dueEntries(selected, lastCheckedMap(state));
581
- if (!entries.length) { out(`nothing due (${plural(selected.length, 'entry', 'entries')} in scope; pass --all to recheck)`); return 0; }
594
+ if (!entries.length) { (args.json ? err : out)(`nothing due (${plural(selected.length, 'entry', 'entries')} in scope; pass --all to recheck)`); return 0; }
582
595
  if (!args.json) err(`checking ${plural(entries.length, 'entry', 'entries')}…`);
583
596
  const rows = await runCheck(entries, {
584
597
  concurrency: Number(args.concurrency ?? config.defaults.concurrency ?? DEFAULTS.concurrency),
@@ -592,26 +605,29 @@ export async function main(argv = process.argv.slice(2), { cwd = process.cwd(),
592
605
  await appendObservations(config.paths.observations, changed);
593
606
  await writeState(config.paths.state, applyRun(state, rows));
594
607
  const summary = summarize(entries, rows);
595
- if (args.json) { out(rows.map(row => JSON.stringify(row)).join('\n')); return summary.exitCode; }
596
- reportProblems(ledger.problems, 'ledger line(s)', out);
608
+ const exitCode = args['fail-on-unknown'] && summary.counts.unknown ? 1 : summary.exitCode;
609
+ if (args.json) { out(rows.map(row => JSON.stringify(row)).join('\n')); return exitCode; }
597
610
  for (const { entry, row } of summary.appeared) out(`APPEARED ${entry.id} ${entry.source} (${row.occurrences} occurrence(s))`);
598
- for (const { entry, row } of summary.failures) out(`LOST ${entry.id} ${entry.source} [${row.reason}]`);
611
+ for (const { entry, row } of summary.failures) out(`ABSENT ${entry.id} ${entry.source} [${row.reason}]`);
599
612
  out(`\n${JSON.stringify(summary.counts)}`);
600
613
  out(`${plural(changed.length, 'observation')} recorded, ${repeated.length} repeated an answer already on file`);
601
614
  if (summary.counts.unknown) {
602
615
  out(`${plural(summary.counts.unknown, 'page')} could not be concluded. An unknown is not a lost link; run \`agentlinkops status\` for the reasons.`);
603
616
  }
604
- if (args['fail-on-unknown'] && summary.counts.unknown) return 1;
605
- return summary.exitCode;
617
+ return exitCode;
606
618
  }
607
619
 
608
620
  err(`unknown command: ${command}\n\n${USAGE}`);
609
621
  return 2;
610
622
  } catch (error) {
611
623
  if (error instanceof ConfigError) { err(error.message); return 2; }
624
+ if (command === 'sync' && args['recover-cursors'] === true && error instanceof CloudError) {
625
+ err(JSON.stringify({error: {code: error.code, status: error.status, next: 'Keep this project ledger and its history. Resolve authorized read access for the configured project, then retry agentlinkops sync --pull-only --recover-cursors. Do not broaden grants or replay remote writes.'}}));
626
+ return 2;
627
+ }
612
628
  err(`agentlinkops: ${error?.message ?? error}`);
613
629
  return 2;
614
630
  } finally {
615
- if (syncLock) { await syncLock.handle.close(); await unlink(syncLock.path); }
631
+ if (syncLock) await syncLock.release();
616
632
  }
617
633
  }
@@ -0,0 +1,198 @@
1
+ import {CloudError} from './cloud-error.js';
2
+ import {createOAuthCredentialStore} from './oauth-credentials.js';
3
+ import {discoverOAuth, refreshOAuthTokens} from './oauth-flow.js';
4
+ import {restResponseContract} from '../shared/response-contract.js';
5
+ import {SHAPES} from '../src/response-shaping.js';
6
+
7
+ const sleepDefault = ms => new Promise(done => setTimeout(done, ms));
8
+ const fail = (code, status = 400) => { throw new CloudError(code, status); };
9
+ const ERROR_BYTES = 65536;
10
+ const sensitive = (text, token) => typeof text === 'string' && (token && text.includes(token)
11
+ || /\blt_[a-f0-9]{64}\b|\b[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\b/.test(text));
12
+ function publicError(value, token, depth = 0) {
13
+ if (depth > 8) return '[redacted]';
14
+ if (typeof value === 'string') return sensitive(value, token) ? '[redacted]' : value;
15
+ if (Array.isArray(value)) return value.slice(0, 100).map(item => publicError(item, token, depth + 1));
16
+ if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).slice(0, 100)
17
+ .filter(([key]) => !/token|authorization|password|secret|cookie|session/i.test(key))
18
+ .map(([key, item]) => [key, publicError(item, token, depth + 1)]));
19
+ return value;
20
+ }
21
+ async function boundedError(response) {
22
+ if (!response.body?.getReader) return null;
23
+ const reader = response.body.getReader(), chunks = []; let size = 0;
24
+ try {
25
+ while (true) {
26
+ const {value, done} = await reader.read(); if (done) break;
27
+ size += value.byteLength;
28
+ if (size > ERROR_BYTES) { await reader.cancel().catch(() => {}); return null; }
29
+ chunks.push(Buffer.from(value));
30
+ }
31
+ return JSON.parse(new TextDecoder('utf-8', {fatal: true}).decode(Buffer.concat(chunks, size)));
32
+ } catch { return null; } finally { reader.releaseLock(); }
33
+ }
34
+ const statusOf = error => (Number.isInteger(error.status) && error.status >= 400 && error.status <= 599 ? error.status : null) ?? ({INSUFFICIENT_SCOPE: 403, AGENT_APPROVAL_REQUIRED: 403,
35
+ GRANT_REVOKED: 401, UNAUTHORIZED: 401, CURSOR_EXPIRED: 410, RATE_LIMITED: 429,
36
+ QUOTA_EXCEEDED: 429, SERVICE_UNAVAILABLE: 503}[error.code]) ?? 400;
37
+ function payloadOf(result) {
38
+ if (result?.structuredContent && typeof result.structuredContent === 'object') return result.structuredContent;
39
+ try { return JSON.parse(result?.content?.find(item => item.type === 'text')?.text); }
40
+ catch { fail('INVALID_RESPONSE', 502); }
41
+ }
42
+ function normalized(result, token) {
43
+ const data = payloadOf(result);
44
+ if (result?.isError) {
45
+ const error = publicError(data?.error, token);
46
+ if (!error || typeof error.code !== 'string' || !/^[A-Z][A-Z0-9_]{1,100}$/.test(error.code)) fail('INVALID_RESPONSE', 502);
47
+ throw new CloudError(error.code, statusOf(error), error.details ?? null, error.message ?? null,
48
+ error, {retryAfter: error.retryAfter ?? publicError(result.retryAfter, token) ?? null,
49
+ requestId: error.requestId ?? publicError(result.requestId, token) ?? null});
50
+ }
51
+ if (!data || typeof data !== 'object' || Array.isArray(data)) fail('INVALID_RESPONSE', 502);
52
+ return data;
53
+ }
54
+ function retryDelay(error, attempt, now) {
55
+ if (!(error instanceof CloudError) || ![0, 429, 503].includes(error.status)) return null;
56
+ if (error.status === 0 && !['CLOUD_NETWORK_ERROR', 'CLOUD_TIMEOUT'].includes(error.code)) return null;
57
+ const value = error.retryAfter;
58
+ if (value == null || value === '') return Math.min(5000, 250 * 2 ** (attempt - 1));
59
+ const delay = /^\d+$/.test(String(value).trim()) ? Number(value) * 1000 : Date.parse(value) - now();
60
+ return Number.isFinite(delay) && delay >= 0 && delay <= 5000 ? delay : null;
61
+ }
62
+
63
+ async function sdkClient({url, token, workspaceId, fetchImpl}) {
64
+ const {Client, StreamableHTTPClientTransport} = await import('@modelcontextprotocol/client');
65
+ const client = new Client({name: 'agentlinkops-cli', version: '0.6.11'});
66
+ const destination = new URL(url);
67
+ const transport = new StreamableHTTPClientTransport(destination, {
68
+ authProvider: {token: async () => token},
69
+ fetch: async (input, init = {}) => {
70
+ const target = new URL(typeof input === 'string' || input instanceof URL ? input : input.url);
71
+ if (target.href !== destination.href) fail('OAUTH_TOKEN_DESTINATION_MISMATCH');
72
+ const headers = new Headers(init.headers ?? (input instanceof Request ? input.headers : undefined));
73
+ if (workspaceId) headers.set('X-Workspace-ID', workspaceId);
74
+ headers.set('User-Agent', 'agentlinkops-cli/0.6.11');
75
+ const response = await fetchImpl(input, {...init, headers, redirect: 'error', signal: AbortSignal.timeout(30000)});
76
+ if (response.redirected || response.status >= 300 && response.status < 400
77
+ || response.url && response.url !== destination.href) fail('OAUTH_TOKEN_DESTINATION_MISMATCH');
78
+ if (!response.ok) {
79
+ const data = await boundedError(response), error = publicError(data?.error, token);
80
+ const code = typeof error?.code === 'string' && /^[A-Z][A-Z0-9_]{1,100}$/.test(error.code)
81
+ ? error.code : `HTTP_${response.status}`;
82
+ throw new CloudError(code, response.status, error?.details ?? null,
83
+ error?.message ?? null, error ?? null,
84
+ {retryAfter: publicError(response.headers.get('Retry-After'), token),
85
+ requestId: error?.requestId ?? publicError(response.headers.get('X-Request-ID'), token)});
86
+ }
87
+ return response;
88
+ },
89
+ });
90
+ try { await client.connect(transport, {timeout: 30000}); return client; }
91
+ catch (error) { await client.close().catch(() => {}); throw error; }
92
+ }
93
+
94
+ export function createMcpClient({origin, auth, workspaceId, projectId, credentialStore,
95
+ createSdkClient = sdkClient, fetchImpl = globalThis.fetch, sleepImpl = sleepDefault, now = Date.now, resolveHost} = {}) {
96
+ let base, issuer;
97
+ try { base = new URL(origin); issuer = new URL(auth?.issuer); } catch { fail('OAUTH_CONFIG_INVALID'); }
98
+ if (base.protocol !== 'https:' || base.origin !== origin || issuer.protocol !== 'https:' || issuer.origin !== auth?.issuer
99
+ || auth?.kind !== 'oauth' || auth.resource !== `${origin}/mcp` || typeof auth.clientId !== 'string'
100
+ || !auth.clientId || auth.clientId.length > 2048 || /[\s\x00-\x1f\x7f]/.test(auth.clientId)
101
+ || !/^oauth_[a-f0-9]{32}$/.test(auth.credentialRef ?? '')) fail('OAUTH_CONFIG_INVALID');
102
+ const binding = {issuer: auth.issuer, resource: auth.resource, clientId: auth.clientId};
103
+ const store = credentialStore ?? createOAuthCredentialStore();
104
+ async function currentToken() {
105
+ try {
106
+ await store.read(auth.credentialRef, binding);
107
+ return await store.withCredential(auth.credentialRef, binding, async record => {
108
+ if (record.tokens.expiresAt > now() + 30000) return {value: record.tokens.accessToken};
109
+ const plan = await discoverOAuth({origin, ...binding, scopes: record.tokens.scope.split(' '), fetchImpl, resolveHost});
110
+ const tokens = await refreshOAuthTokens({plan, tokens: record.tokens, fetchImpl, now});
111
+ return {tokens, value: tokens.accessToken};
112
+ });
113
+ } catch (error) {
114
+ if (error instanceof CloudError) throw error;
115
+ const code = typeof error?.code === 'string' && /^[A-Z][A-Z0-9_]{1,100}$/.test(error.code) ? error.code : 'OAUTH_CREDENTIAL_UNAVAILABLE';
116
+ throw new CloudError(code, 401, null, 'OAuth credentials could not be used. Reconnect through the configured issuer; do not repeat an unresolved write.');
117
+ }
118
+ }
119
+ async function perform(operation, {safeRead = false, all = false} = {}) {
120
+ for (let attempt = 1; attempt <= (safeRead ? 3 : 1); attempt++) {
121
+ const token = await currentToken();
122
+ let client;
123
+ try {
124
+ client = await createSdkClient({url: all ? `${origin}/mcp/all` : auth.resource, token, workspaceId, fetchImpl});
125
+ return await operation(client, token);
126
+ } catch (error) {
127
+ const status = Number.isInteger(error?.status) && error.status >= 400 && error.status <= 599 ? error.status : null;
128
+ const failure = error instanceof CloudError ? error : status !== null ? new CloudError(`HTTP_${status}`, status,
129
+ null, 'The cloud request was refused.', null, {retryAfter: error.retryAfter ?? null, requestId: error.requestId ?? null}) : new CloudError(
130
+ ['AbortError', 'TimeoutError'].includes(error?.name) ? 'CLOUD_TIMEOUT' : 'CLOUD_NETWORK_ERROR', 0,
131
+ {attempts: attempt}, safeRead ? 'The cloud read failed. Check the connection before retrying.'
132
+ : 'The cloud request did not return a conclusive result. Check its outcome before sending it again.');
133
+ const delay = safeRead && attempt < 3 ? retryDelay(failure, attempt, now) : null;
134
+ if (delay === null) throw failure;
135
+ await sleepImpl(delay);
136
+ } finally { await client?.close().catch(() => {}); }
137
+ }
138
+ }
139
+ const detailed = (name, args) => Object.hasOwn(SHAPES, name) ? {format: 'detailed', ...args} : args;
140
+ const scoped = (name, args) => detailed(name, {...args, ...(projectId ? {projectId} : {})});
141
+ const tool = (name, args, safeRead = false, path = null, method = 'GET') => perform(async (client, token) => {
142
+ const data = normalized(await client.callTool({name, arguments: args}, {timeout: 30000}), token);
143
+ if (!path) return data;
144
+ const parsed = restResponseContract(path, method).safeParse(data);
145
+ if (!parsed.success) fail('INVALID_RESPONSE', 502);
146
+ return parsed.data;
147
+ }, {safeRead});
148
+ return {
149
+ listCommands: () => perform(async (client, token) => {
150
+ const items = []; let cursor = '';
151
+ const visited = new Set([cursor]);
152
+ do {
153
+ // An explicit cursor selects the SDK's per-page path. A missing cursor
154
+ // auto-aggregates and can hide a repeated-page stop from our own guards.
155
+ const page = await client.listTools({cursor});
156
+ if (!Array.isArray(page?.tools)) fail('INVALID_RESPONSE', 502);
157
+ items.push(...page.tools);
158
+ if (items.length > 1000) fail('INVALID_RESPONSE', 502);
159
+ if (page.nextCursor === undefined) break;
160
+ if (typeof page.nextCursor !== 'string' || !page.nextCursor
161
+ || visited.has(page.nextCursor) || visited.size >= 100) fail('INVALID_RESPONSE', 502);
162
+ cursor = page.nextCursor; visited.add(cursor);
163
+ } while (true);
164
+ // Meta tools are registered only on the default view, not the flat listing.
165
+ const meta = await createSdkClient({url: auth.resource, token, workspaceId, fetchImpl});
166
+ try {
167
+ const described = new Map();
168
+ for (let start = 0; start < items.length; start += 10) {
169
+ const names = items.slice(start, start + 10).map(item => item.name);
170
+ if (names.some(name => typeof name !== 'string' || !name || name.length > 64)) fail('INVALID_RESPONSE', 502);
171
+ const data = normalized(await meta.callTool({name: 'describe_tools',
172
+ arguments: {names, include: 'input+output'}}, {timeout: 30000}), token);
173
+ if (!Array.isArray(data.tools) || data.tools.length !== names.length || data.unknown?.length) fail('INVALID_RESPONSE', 502);
174
+ for (const item of data.tools) {
175
+ if (!names.includes(item.name) || described.has(item.name)) fail('INVALID_RESPONSE', 502);
176
+ described.set(item.name, item);
177
+ }
178
+ }
179
+ return {items: items.map(item => ({...item, ...described.get(item.name), annotations: item.annotations}))};
180
+ } finally { await meta.close().catch(() => {}); }
181
+ }, {safeRead: true, all: true}),
182
+ describeCommand: name => perform(async (client, token) => {
183
+ const data = normalized(await client.callTool({name: 'describe_tools', arguments: {names: [name], include: 'input+output'}}, {timeout: 30000}), token);
184
+ if (!Array.isArray(data?.tools) || data.tools.length !== 1) fail('INVALID_RESPONSE', 502);
185
+ return data.tools[0];
186
+ }, {safeRead: true}),
187
+ callCommand: (name, args = {}) => tool(name, detailed(name, args)),
188
+ getWorkspace: () => tool('get_workspace', {}, true, '/v1/workspace'),
189
+ getProject: id => tool('get_project', {projectId: id}, true, '/v1/projects/' + encodeURIComponent(id)),
190
+ importWatches: (id, watches) => tool('import_link_watches', {projectId: id, watches}, false, '/v1/watches/import', 'POST'),
191
+ updateWatch: (watchId, patch) => tool('update_link_watch', {...patch, watchId}, false, '/v1/watches/' + encodeURIComponent(watchId), 'PATCH'),
192
+ listWatches: query => tool('list_link_watches', scoped('list_link_watches', query), true, '/v1/watches'),
193
+ listEvents: query => tool('list_events', {...scoped('list_events', query), feed: 'links'}, true, '/v1/events'),
194
+ listTargets: query => tool('list_targets', scoped('list_targets', query), true, '/v1/targets'),
195
+ listTargetEvents: query => tool('list_events', {...scoped('list_events', query), feed: 'targets'}, true, '/v1/target-events'),
196
+ exportWatches: query => tool('export_link_watches', scoped('export_link_watches', query), true, '/v1/exports/watches'),
197
+ };
198
+ }
@@ -0,0 +1,22 @@
1
+ // Only the OS advisory lock coordinates recovery. Path age is never ownership.
2
+ import { ConfigError } from './config.js';
3
+ let binding;
4
+ export async function nativeLock(platform = process.platform) {
5
+ if (!['darwin', 'linux'].includes(platform)) throw new ConfigError('Sync lock recovery is unsupported on this platform; no writer started.');
6
+ if (!binding) {
7
+ try {
8
+ const { default: koffi } = await import('koffi');
9
+ const lib = koffi.load(platform === 'darwin' ? '/usr/lib/libSystem.B.dylib' : 'libc.so.6');
10
+ const flock = lib.func('int flock(int fd, int operation)');
11
+ binding = { acquire(fd) {
12
+ if (flock(fd, 2 | 4) === 0) return true;
13
+ const code = koffi.errno();
14
+ if (code === koffi.os.errno.EWOULDBLOCK || code === koffi.os.errno.EAGAIN) return false;
15
+ throw new ConfigError('Kernel sync lock failed; no writer started.');
16
+ } };
17
+ } catch (error) {
18
+ throw new ConfigError('Native sync lock is unavailable; reinstall the CLI with its platform dependencies. No writer started.');
19
+ }
20
+ }
21
+ return binding;
22
+ }
@@ -0,0 +1,196 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import {constants} from 'node:fs';
3
+ import {homedir} from 'node:os';
4
+ import {isAbsolute, join, parse, resolve} from 'node:path';
5
+ import {randomBytes} from 'node:crypto';
6
+
7
+ const MAX_BYTES = 300000;
8
+ const fail = code => { throw Object.assign(new Error(code), {code}); };
9
+ const exact = (value, keys) => value && typeof value === 'object' && !Array.isArray(value)
10
+ && Object.keys(value).sort().join(',') === [...keys].sort().join(',');
11
+ function bindingOf(value) {
12
+ if (!exact(value, ['issuer', 'resource', 'clientId'])) fail('OAUTH_CREDENTIAL_BINDING_INVALID');
13
+ for (const key of ['issuer', 'resource']) {
14
+ let url; try { url = new URL(value[key]); } catch { fail('OAUTH_CREDENTIAL_BINDING_INVALID'); }
15
+ if (typeof value[key] !== 'string' || value[key].length > 4096 || url.protocol !== 'https:'
16
+ || url.username || url.password || url.search || url.hash
17
+ || (key === 'issuer' ? url.origin !== value[key] : url.href !== value[key] || url.pathname !== '/mcp'))
18
+ fail('OAUTH_CREDENTIAL_BINDING_INVALID');
19
+ }
20
+ if (typeof value.clientId !== 'string' || !value.clientId || value.clientId.length > 2048
21
+ || /[\s\x00-\x1f\x7f]/.test(value.clientId)) fail('OAUTH_CREDENTIAL_BINDING_INVALID');
22
+ return {...value};
23
+ }
24
+ function tokensOf(value) {
25
+ if (!exact(value, ['accessToken', 'refreshToken', 'expiresAt', 'scope'])) fail('OAUTH_CREDENTIAL_TOKENS_INVALID');
26
+ for (const key of ['accessToken', 'refreshToken']) if (typeof value[key] !== 'string' || !value[key]
27
+ || Buffer.byteLength(value[key]) > 65536 || /[\x00-\x20\x7f]/.test(value[key])) fail('OAUTH_CREDENTIAL_TOKENS_INVALID');
28
+ if (!Number.isSafeInteger(value.expiresAt) || value.expiresAt <= 0 || typeof value.scope !== 'string'
29
+ || !value.scope || Buffer.byteLength(value.scope) > 4096 || !/^[A-Za-z0-9:._/-]+(?: [A-Za-z0-9:._/-]+)*$/.test(value.scope))
30
+ fail('OAUTH_CREDENTIAL_TOKENS_INVALID');
31
+ return {...value};
32
+ }
33
+ const reference = ref => { if (typeof ref !== 'string' || !/^oauth_[a-f0-9]{32}$/.test(ref)) fail('OAUTH_CREDENTIAL_REF_INVALID'); return ref; };
34
+ const sameIdentity = (a, b) => a.dev === b.dev && a.ino === b.ino;
35
+ const sameBinding = (a, b) => ['issuer', 'resource', 'clientId'].every(key => a[key] === b[key]);
36
+ const sleep = ms => new Promise(done => setTimeout(done, ms));
37
+
38
+ export function createOAuthCredentialStore({home = homedir(), platform = process.platform, now = Date.now, lockTimeoutMs = 2000} = {}) {
39
+ if (typeof home !== 'string' || !isAbsolute(home) || home.includes('\0')) fail('OAUTH_CREDENTIAL_HOME_INVALID');
40
+ if (!Number.isInteger(lockTimeoutMs) || lockTimeoutMs < 0 || lockTimeoutMs > 10000) fail('OAUTH_CREDENTIAL_LOCK_BOUND_INVALID');
41
+ const root = resolve(home), directory = join(root, '.agentlinkops', 'oauth');
42
+ const uid = process.getuid?.();
43
+ const privateStat = (info, mode) => info.uid === uid && (info.mode & 0o7777) === mode;
44
+ async function directories() {
45
+ // Darwin ACLs can grant access independently of these POSIX mode checks.
46
+ // Other platforms require separately verified native credential custody.
47
+ if (platform !== 'linux' || uid === undefined) fail('OAUTH_CREDENTIAL_STORAGE_UNSUPPORTED');
48
+ let path = parse(root).root;
49
+ for (const part of root.slice(path.length).split('/').filter(Boolean)) {
50
+ path = join(path, part);
51
+ const info = await fs.lstat(path);
52
+ if (!info.isDirectory()) fail('OAUTH_CREDENTIAL_PRIVATE_STORAGE_REQUIRED');
53
+ }
54
+ const homeInfo = await fs.lstat(root);
55
+ if (homeInfo.uid !== uid) fail('OAUTH_CREDENTIAL_PRIVATE_STORAGE_REQUIRED');
56
+ for (const path of [join(root, '.agentlinkops'), directory]) {
57
+ try { await fs.mkdir(path, {mode: 0o700}); } catch (error) { if (error.code !== 'EEXIST') throw error; }
58
+ const info = await fs.lstat(path);
59
+ if (!info.isDirectory() || !privateStat(info, 0o700)) fail('OAUTH_CREDENTIAL_PRIVATE_STORAGE_REQUIRED');
60
+ }
61
+ return fs.lstat(directory);
62
+ }
63
+ async function unchangedDirectory(original) {
64
+ if (!sameIdentity(original, await directories())) fail('OAUTH_CREDENTIAL_STORAGE_CHANGED');
65
+ }
66
+ async function syncDirectory() {
67
+ const parent = await fs.open(directory, constants.O_RDONLY | constants.O_NOFOLLOW);
68
+ try { await parent.sync(); } finally { await parent.close(); }
69
+ }
70
+ async function bytes(path, missing = false) {
71
+ let handle;
72
+ try {
73
+ const info = await fs.lstat(path);
74
+ if (!info.isFile() || info.nlink !== 1 || !privateStat(info, 0o600)) fail('OAUTH_CREDENTIAL_PRIVATE_STORAGE_REQUIRED');
75
+ handle = await fs.open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
76
+ const actual = await handle.stat();
77
+ if (!sameIdentity(info, actual) || !actual.isFile() || actual.nlink !== 1 || !privateStat(actual, 0o600)) fail('OAUTH_CREDENTIAL_STORAGE_CHANGED');
78
+ if (actual.size > MAX_BYTES) fail('OAUTH_CREDENTIAL_RECORD_INVALID');
79
+ const buffer = Buffer.alloc(MAX_BYTES + 1);
80
+ let offset = 0;
81
+ while (offset < buffer.length) {
82
+ const read = await handle.read(buffer, offset, buffer.length - offset, offset);
83
+ if (!read.bytesRead) break;
84
+ offset += read.bytesRead;
85
+ }
86
+ if (offset > MAX_BYTES) fail('OAUTH_CREDENTIAL_RECORD_INVALID');
87
+ return buffer.subarray(0, offset).toString('utf8');
88
+ } catch (error) { if (missing && error.code === 'ENOENT') return null; throw error; }
89
+ finally { await handle?.close(); }
90
+ }
91
+ function recordOf(text, binding) {
92
+ let record; try { record = JSON.parse(text); } catch { fail('OAUTH_CREDENTIAL_RECORD_INVALID'); }
93
+ if (!exact(record, ['version', 'revision', 'binding', 'tokens']) || record.version !== 1
94
+ || !Number.isSafeInteger(record.revision) || record.revision < 1) fail('OAUTH_CREDENTIAL_RECORD_INVALID');
95
+ const stored = bindingOf(record.binding), tokens = tokensOf(record.tokens);
96
+ if (!sameBinding(stored, binding)) fail('OAUTH_CREDENTIAL_BINDING_MISMATCH');
97
+ return {version: 1, revision: record.revision, binding: stored, tokens};
98
+ }
99
+ async function atomic(ref, record, before, original) {
100
+ const path = join(directory, ref + '.json'), temporary = join(directory, '.' + ref + '.' + randomBytes(16).toString('hex') + '.tmp');
101
+ let handle, owned = false;
102
+ try {
103
+ const text = JSON.stringify(record);
104
+ if (Buffer.byteLength(text) > MAX_BYTES) fail('OAUTH_CREDENTIAL_RECORD_INVALID');
105
+ handle = await fs.open(temporary, 'wx', 0o600); owned = true;
106
+ if (!privateStat(await handle.stat(), 0o600)) fail('OAUTH_CREDENTIAL_PRIVATE_STORAGE_REQUIRED');
107
+ await handle.writeFile(text); await handle.sync(); await handle.close(); handle = undefined;
108
+ await unchangedDirectory(original);
109
+ if (await bytes(path, true) !== before) fail('OAUTH_CREDENTIAL_STORAGE_CHANGED');
110
+ if (before === null) { await fs.link(temporary, path); await fs.unlink(temporary); }
111
+ else await fs.rename(temporary, path);
112
+ owned = false;
113
+ await syncDirectory();
114
+ } finally { await handle?.close(); if (owned) await fs.unlink(temporary).catch(() => {}); }
115
+ }
116
+ async function locked(ref, work) {
117
+ const original = await directories(), path = join(directory, ref + '.lock'), nonce = randomBytes(16).toString('hex');
118
+ const deadline = Date.now() + lockTimeoutMs;
119
+ let lock;
120
+ while (!lock) {
121
+ try { lock = await fs.open(path, 'wx', 0o600); }
122
+ catch (error) {
123
+ if (error.code !== 'EEXIST') throw error;
124
+ if (Date.now() >= deadline) fail('OAUTH_CREDENTIAL_LOCKED');
125
+ await sleep(20);
126
+ }
127
+ }
128
+ let identity, initialized = false;
129
+ try {
130
+ identity = await lock.stat();
131
+ if (!privateStat(identity, 0o600)) fail('OAUTH_CREDENTIAL_PRIVATE_STORAGE_REQUIRED');
132
+ await lock.writeFile(nonce); await lock.sync();
133
+ initialized = true;
134
+ return await work(original);
135
+ } finally {
136
+ try {
137
+ // Before initialization, the open inode proves ownership even if the nonce write failed.
138
+ identity ??= await lock.stat().catch(() => null);
139
+ const current = await fs.lstat(path).catch(() => null);
140
+ if (identity && current && sameIdentity(identity, current)
141
+ && (!initialized || await bytes(path) === nonce)) await fs.unlink(path);
142
+ } finally { await lock.close(); }
143
+ }
144
+ }
145
+ async function safe(work) {
146
+ try { return await work(); }
147
+ catch (error) { if (typeof error.code === 'string' && error.code.startsWith('OAUTH_CREDENTIAL_')) throw error; fail('OAUTH_CREDENTIAL_STORAGE_FAILED'); }
148
+ }
149
+ return {
150
+ async checkAvailability() { return safe(async () => { await directories(); }); },
151
+ async create(binding, tokens) {
152
+ binding = bindingOf(binding); tokens = tokensOf(tokens);
153
+ return safe(async () => {
154
+ const ref = 'oauth_' + randomBytes(16).toString('hex');
155
+ await locked(ref, original => atomic(ref, {version: 1, revision: 1, binding, tokens}, null, original));
156
+ return ref;
157
+ });
158
+ },
159
+ async read(ref, binding) {
160
+ reference(ref); binding = bindingOf(binding);
161
+ return safe(async () => { const original = await directories(); const text = await bytes(join(directory, ref + '.json'), true);
162
+ if (text === null) fail('OAUTH_CREDENTIAL_NOT_FOUND');
163
+ const record = recordOf(text, binding); await unchangedDirectory(original); return record; });
164
+ },
165
+ async withCredential(ref, binding, callback) {
166
+ reference(ref); binding = bindingOf(binding);
167
+ if (typeof callback !== 'function') fail('OAUTH_CREDENTIAL_CALLBACK_INVALID');
168
+ let callbackError, callbackFailed = false;
169
+ try { return await safe(() => locked(ref, async original => {
170
+ const path = join(directory, ref + '.json'), before = await bytes(path, true);
171
+ if (before === null) fail('OAUTH_CREDENTIAL_NOT_FOUND');
172
+ const record = recordOf(before, binding);
173
+ let result; try { result = await callback(structuredClone(record)); } catch (error) { callbackFailed = true; callbackError = error; throw error; }
174
+ if (!result || typeof result !== 'object' || !Object.hasOwn(result, 'value')
175
+ || Object.keys(result).some(key => !['tokens', 'value'].includes(key))) fail('OAUTH_CREDENTIAL_CALLBACK_INVALID');
176
+ await unchangedDirectory(original);
177
+ if (await bytes(path) !== before) fail('OAUTH_CREDENTIAL_STORAGE_CHANGED');
178
+ if (result.tokens !== undefined) {
179
+ if (record.revision === Number.MAX_SAFE_INTEGER) fail('OAUTH_CREDENTIAL_RECORD_INVALID');
180
+ await atomic(ref, {...record, revision: record.revision + 1, tokens: tokensOf(result.tokens)}, before, original);
181
+ }
182
+ return result.value;
183
+ })); } catch (error) { if (callbackFailed) throw callbackError; throw error; }
184
+ },
185
+ async remove(ref, binding) {
186
+ reference(ref); binding = bindingOf(binding);
187
+ return safe(() => locked(ref, async original => {
188
+ const path = join(directory, ref + '.json'), before = await bytes(path, true);
189
+ if (before === null) return false;
190
+ recordOf(before, binding); await unchangedDirectory(original);
191
+ if (await bytes(path) !== before) fail('OAUTH_CREDENTIAL_STORAGE_CHANGED');
192
+ await fs.unlink(path); await syncDirectory(); return true;
193
+ }));
194
+ },
195
+ };
196
+ }