agentlinkops 0.6.8 → 0.6.10

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 (54) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +20 -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 +37 -6
  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/ledger.js +4 -7
  15. package/cli/lifecycle.js +5 -2
  16. package/cli/local-result.js +4 -4
  17. package/cli/main.js +43 -28
  18. package/cli/mcp-client.js +198 -0
  19. package/cli/native-lock.js +22 -0
  20. package/cli/oauth-credentials.js +196 -0
  21. package/cli/oauth-flow.js +259 -0
  22. package/cli/setup-plan.js +5 -5
  23. package/cli/status.js +14 -2
  24. package/cli/sync-lock.js +105 -0
  25. package/cli/sync.js +14 -6
  26. package/package.json +6 -3
  27. package/references/cli.md +22 -0
  28. package/references/local-crm-draft-handoff.md +76 -0
  29. package/references/portable-ledger-handoff.md +50 -0
  30. package/references/recipes/catalog.json +9 -9
  31. package/references/recipes/command-notes.md +17 -1
  32. package/references/recipes/placement-reconciliation.md +19 -5
  33. package/references/recipes/qualified-campaign-handoff.md +16 -4
  34. package/references/recipes/site-context-brief.md +2 -2
  35. package/references/recipes/sourced-linkable-asset.md +8 -8
  36. package/scripts/agentlinkops.py +30 -5
  37. package/scripts/outreach-crm-draft.py +121 -0
  38. package/scripts/portable-handoff.py +184 -0
  39. package/shared/exec-grammar.js +342 -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 +62 -26
  46. package/skills/agentlinkops-crm/SKILL.md +17 -3
  47. package/src/citations/adapter.js +3 -2
  48. package/src/citations/browser/chatgpt-citations.js +60 -6
  49. package/src/citations/browser/engine.js +106 -18
  50. package/src/citations/browser/shims/utils.js +9 -2
  51. package/src/citations/login.js +42 -5
  52. package/src/discovery/contract.js +1 -1
  53. package/src/response-shaping.js +58 -0
  54. package/src/tool-views.js +31 -4
@@ -1,8 +1,9 @@
1
+ import { acquireSyncLock } from './sync-lock.js';
1
2
  // Portable, accountless first result and explicit repository adoption. No hosted client.
2
3
  import { createHash, randomUUID } from 'node:crypto';
3
4
  import { z } from 'zod';
4
5
  import { readFile, writeFile, mkdir, open, rename, unlink } from 'node:fs/promises';
5
- import { dirname, join } from 'node:path';
6
+ import { basename, dirname, join } from 'node:path';
6
7
  import { matchesTarget } from '../src/verifier/url.js';
7
8
  import { validatePublicUrl } from '../src/verifier/index.js';
8
9
  import { runCheck } from './check.js';
@@ -124,8 +125,7 @@ export async function adoptLocalResult(config, supplied, { intent = 'wanted', af
124
125
  if (!['wanted', 'expected'].includes(intent)) throw new Error('adoption intent must be wanted or expected');
125
126
  const result = validateLocalResult(supplied);
126
127
  if (new Set([config.paths.ledger, config.paths.observations, config.paths.state]).size !== 3) throw new Error('adoption destinations must be distinct');
127
- const lockPath = `${config.paths.ledger}.lock`;
128
- const lock = await open(lockPath, 'wx');
128
+ const lock = await acquireSyncLock(dirname(config.paths.ledger), 'adopt-result', {markerName: `${basename(config.paths.ledger)}.lock`});
129
129
  const journalPath = join(config.dir, 'adopt-result-transaction.json');
130
130
  try {
131
131
  const pending = await textAt(journalPath);
@@ -157,5 +157,5 @@ export async function adoptLocalResult(config, supplied, { intent = 'wanted', af
157
157
  await writeFile(journalPath, JSON.stringify({ version: 1, parts }), { flag: 'wx' });
158
158
  await finishAdoption(journalPath, { version: 1, parts }, config, afterWrite);
159
159
  return { result_id: result.result_id, ledger_id: entry.id, created: !matches[0], observation_added: true };
160
- } finally { await lock.close(); await unlink(lockPath); }
160
+ } finally { await lock.release(); }
161
161
  }
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
@@ -133,7 +134,7 @@ function reportProblems(problems, what, out) {
133
134
  if (problems.length > 20) out(` … and ${problems.length - 20} more`);
134
135
  }
135
136
 
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 } = {}) {
137
+ 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
138
  if (argv[0] === 'gsc-links') return await gscLinksMain(argv, { cwd, out });
138
139
  // Citation watches keep their own files under .agentlinkops/citations/ and never
139
140
  // touch the link ledger, so they dispatch before its requirement like context does.
@@ -147,17 +148,19 @@ export async function main(argv = process.argv.slice(2), { cwd = process.cwd(),
147
148
 
148
149
  let syncLock = null;
149
150
  try {
150
- if (command === 'lifecycle') return await lifecycleMain(args,{cwd,env,fetchImpl,out});
151
+ if (args['recover-cursors'] !== undefined && (command !== 'sync' || args['recover-cursors'] !== true || args['pull-only'] !== true || args['push-only']))
152
+ throw new ConfigError('Cursor recovery requires agentlinkops sync --pull-only --recover-cursors; it performs no remote writes.');
153
+ if (command === 'lifecycle') return await lifecycleMain(args,{cwd,env,fetchImpl,out,err});
151
154
  if (command === 'admission') { const config=await loadConfig({cwd}); return await admissionMain(argv.slice(1),{client:createClient({...cloudConnection(config,env),fetchImpl}),out,err}); }
152
155
  if (command === 'disavow') return await disavowMain(args,{cwd,env,fetchImpl,out});
153
156
  if (command === 'index') return await indexMain(args,{cwd,env,fetchImpl,out});
154
157
  if (command === 'setup') return await setupPlanMain(argv, { cwd, out });
155
- if (command === 'tools' || command === 'describe' || command === 'call') return await commandsMain(argv, { cwd, out, err, env, fetchImpl });
158
+ if (command === 'tools' || command === 'describe' || command === 'schema' || command === 'call') return await commandsMain(argv, { cwd, out, err, env, fetchImpl });
156
159
  if (command === 'skill') return await skillMain(argv, { out });
157
160
  if (command === 'agent') return await agentMain(argv, { cwd, out, err });
158
161
  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 });
162
+ 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]]');
163
+ return await connectMain(args, { cwd, out, env, fetchImpl });
161
164
  }
162
165
  if (command === 'check' && (args.source !== undefined || args.target !== undefined)) {
163
166
  if (typeof args.source !== 'string' || typeof args.target !== 'string') throw new ConfigError('check requires both --source URL and --target URL');
@@ -329,9 +332,7 @@ export async function main(argv = process.argv.slice(2), { cwd = process.cwd(),
329
332
 
330
333
  const config = await loadConfig({ cwd });
331
334
  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; }
335
+ syncLock = await acquireSyncLock(config.dir, command);
335
336
  }
336
337
  if (command === 'adopt-result') {
337
338
  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 +507,7 @@ export async function main(argv = process.argv.slice(2), { cwd = process.cwd(),
506
507
  for (const change of found.slice(0, 100)) out(` ${change.at.slice(0, 10)} ${change.from} -> ${change.to} ${change.id}`);
507
508
  return 0;
508
509
  }
509
- const groups = { appeared: 'appeared', lost: 'LOST', cannot_say: 'cannot say', never_checked: 'never checked' };
510
+ const groups = { appeared: 'appeared', lost: 'LOST', suspected_missing: 'suspected missing', cannot_say: 'cannot say', never_checked: 'never checked' };
510
511
  for (const [kind, label] of Object.entries(groups)) {
511
512
  const rows = found.filter(item => item.kind === kind);
512
513
  if (!rows.length) continue;
@@ -524,7 +525,7 @@ export async function main(argv = process.argv.slice(2), { cwd = process.cwd(),
524
525
  if (command === 'receive') {
525
526
  if (!args.body || !args.headers) throw new ConfigError('receive --body RAW_BODY_FILE --headers HEADERS_JSON_FILE');
526
527
  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 });
528
+ secret: readEnv(env, 'WEBHOOK_SECRET').value, workspaceId: config.cloud?.workspaceId });
528
529
  args['pull-only'] = true;
529
530
  args['push-only'] = false;
530
531
  }
@@ -533,11 +534,11 @@ export async function main(argv = process.argv.slice(2), { cwd = process.cwd(),
533
534
  if(args.lifecycle&&args['push-only'])throw new ConfigError('--lifecycle requires a pull; omit --push-only.');
534
535
  const selection = { includeWanted: args['include-wanted'] === true, ledgerIds: config.cloud?.ledgerIds ?? null };
535
536
  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);
537
+ const connection = cloudConnection(config, env);
537
538
  if (!connection.origin) { err('no cloud origin — run agentlinkops connect'); return 2; }
538
- if (!connection.token) { err(keySetup(connection.origin)); return 2; }
539
+ if (!connection.token && !connection.auth) { err(keySetup(connection.origin)); return 2; }
539
540
  if (!connection.projectId) { err('no project id — run agentlinkops connect'); return 2; }
540
- const client = createClient(connection);
541
+ const client = createClient({ ...connection, fetchImpl });
541
542
  let next = { ...state };
542
543
  let pushFailed = false;
543
544
  if (!args['pull-only']) {
@@ -549,28 +550,38 @@ export async function main(argv = process.argv.slice(2), { cwd = process.cwd(),
549
550
  pushFailed = pushed.failed.length > 0;
550
551
  }
551
552
  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');
553
+ const snapshots = [];
554
+ const recovery = {
555
+ recoverInvalidCursor: args['recover-cursors'] === true,
556
+ onSnapshot: async (snapshot, feed) => {
557
+ const path = join(config.dir, `snapshot-${feed}-${Date.now()}-${crypto.randomUUID()}.json`);
558
+ await writeFile(path, `${JSON.stringify(snapshot, null, 1)}\n`, {encoding: 'utf8', flag: 'wx', mode: 0o600});
559
+ snapshots.push({feed, path});
557
560
  },
561
+ };
562
+ const pulled = await pullEvents(client, next, {
563
+ ...recovery,
558
564
  });
559
565
  // Two feeds, two cursors. They are pulled separately and written separately, so an
560
566
  // expiry on one cannot move the other.
561
- const targets = await pullTargetEvents(client, next);
567
+ const targets = await pullTargetEvents(client, next, recovery);
562
568
  const index = await watchIndex(client, next);
563
569
  if(args.lifecycle)next.lifecycle=await pullLifecycleMirrors(client,next,{projectId:connection.projectId,index,ledgerIds:selection.ledgerIds??ledger.entries.map(entry=>entry.id)});
564
570
  const { fresh, duplicates } = await persistPulledHistory(config, pulled.events, targets.events, index);
571
+ await afterHistoryPersisted?.();
565
572
  next.cursors = { ...(next.cursors ?? {}), events: pulled.cursor, target_events: targets.cursor };
566
573
  next.gaps = [...(next.gaps ?? []), ...pulled.gaps, ...targets.gaps];
567
574
  out(`pulled ${pulled.events.length} event(s) over ${pulled.pages} page(s), ${fresh.length} observation(s)${duplicates.length ? `, ${duplicates.length} already held` : ''}`);
568
575
  if (targets.events.length) out(`pulled ${targets.events.length} target event(s) over ${targets.pages} page(s)`);
569
576
  // A gap is stated, never inferred. An empty feed means nothing happened; an expired
570
577
  // 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}`);
578
+ for (const gap of [...pulled.gaps, ...targets.gaps]) {
579
+ const snapshot = snapshots.find(item => item.feed === gap.feed);
580
+ out(` RESYNC ${gap.at}: ${gap.reason}; ${snapshot ? `snapshot saved to ${snapshot.path}` : 'current-state snapshot applied'}`);
581
+ }
572
582
  }
573
583
  await writeState(config.paths.state, next);
584
+ await afterStatePersisted?.();
574
585
  return pushFailed ? 2 : 0;
575
586
  }
576
587
 
@@ -595,7 +606,7 @@ export async function main(argv = process.argv.slice(2), { cwd = process.cwd(),
595
606
  if (args.json) { out(rows.map(row => JSON.stringify(row)).join('\n')); return summary.exitCode; }
596
607
  reportProblems(ledger.problems, 'ledger line(s)', out);
597
608
  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}]`);
609
+ for (const { entry, row } of summary.failures) out(`ABSENT ${entry.id} ${entry.source} [${row.reason}]`);
599
610
  out(`\n${JSON.stringify(summary.counts)}`);
600
611
  out(`${plural(changed.length, 'observation')} recorded, ${repeated.length} repeated an answer already on file`);
601
612
  if (summary.counts.unknown) {
@@ -609,9 +620,13 @@ export async function main(argv = process.argv.slice(2), { cwd = process.cwd(),
609
620
  return 2;
610
621
  } catch (error) {
611
622
  if (error instanceof ConfigError) { err(error.message); return 2; }
623
+ if (command === 'sync' && args['recover-cursors'] === true && error instanceof CloudError) {
624
+ 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.'}}));
625
+ return 2;
626
+ }
612
627
  err(`agentlinkops: ${error?.message ?? error}`);
613
628
  return 2;
614
629
  } finally {
615
- if (syncLock) { await syncLock.handle.close(); await unlink(syncLock.path); }
630
+ if (syncLock) await syncLock.release();
616
631
  }
617
632
  }
@@ -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.10'});
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.10');
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
+ }