@synkro-sh/cli 1.6.5 → 1.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bootstrap.js CHANGED
@@ -733,7 +733,7 @@ var init_hookScriptsTs = __esm({
733
733
  "use strict";
734
734
  SYNKRO_COMMON_TS = `
735
735
  // Shared Synkro hook utilities \u2014 imported by all hook scripts.
736
- import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, renameSync, openSync, closeSync, unlinkSync } from 'node:fs';
736
+ import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, renameSync, openSync, closeSync, unlinkSync, readdirSync, statSync } from 'node:fs';
737
737
  import { join, dirname, basename, extname, resolve as resolvePath } from 'node:path';
738
738
  import { homedir } from 'node:os';
739
739
  import { execSync } from 'node:child_process';
@@ -1002,6 +1002,10 @@ export async function loadConfig(jwt: string, query?: string): Promise<HookConfi
1002
1002
  scanExemptions: [],
1003
1003
  };
1004
1004
 
1005
+ // Kick the telemetry spool drainer. Fire-and-forget: it runs concurrently
1006
+ // with the grade that follows this call, so it adds no latency to the hook.
1007
+ drainSpool().catch(() => {});
1008
+
1005
1009
  // Local-first: fetch from the local MCP server (PGLite-backed) \u2014 zero network egress.
1006
1010
  const mcpPort = process.env.SYNKRO_MCP_PORT || '18931';
1007
1011
  try {
@@ -1596,18 +1600,104 @@ export function dispatchCapture(
1596
1600
  }).catch(() => {});
1597
1601
  }
1598
1602
 
1603
+ // \u2500\u2500\u2500 Durable Telemetry Spool \u2500\u2500\u2500
1604
+ // Telemetry must survive process death, container restarts, and ingest-server
1605
+ // backpressure. Instead of a fire-and-forget POST (which silently dropped
1606
+ // captures under parallel load), every event is appended synchronously to a
1607
+ // local JSONL spool \u2014 a write that completes before the function returns and
1608
+ // outlives the hook process. A background drainer (kicked from loadConfig, so
1609
+ // it overlaps the multi-second grade) batch-ships the spool to the ingest
1610
+ // server and only deletes events after a confirmed write. Ingest is idempotent
1611
+ // (ON CONFLICT DO NOTHING on event_id), so a retried or double-drained event
1612
+ // is harmless.
1613
+
1614
+ const TELEMETRY_SPOOL = join(HOME, '.synkro', 'telemetry-spool.jsonl');
1615
+ const SPOOL_DRAIN_PREFIX = 'telemetry-spool.jsonl.draining.';
1616
+
1617
+ // appendLocalTelemetry \u2014 durably records one telemetry event. The synchronous
1618
+ // append IS the durability guarantee; nothing here can drop the event.
1599
1619
  export function appendLocalTelemetry(body: Record<string, any>): void {
1600
1620
  const event = { ...body, _ts: new Date().toISOString() };
1621
+ try {
1622
+ appendFileSync(TELEMETRY_SPOOL, JSON.stringify(event) + '\\n');
1623
+ } catch {}
1624
+ }
1625
+
1626
+ // drainSpool \u2014 claims the spool via atomic rename, batch-ships it to the
1627
+ // ingest server, deletes on success, re-spools on failure. Fire-and-forget:
1628
+ // callers kick it and let it run concurrently with the grade.
1629
+ export async function drainSpool(): Promise<void> {
1630
+ const dir = join(HOME, '.synkro');
1631
+ const claimed: string[] = [];
1632
+
1633
+ // 1. Claim the live spool by atomic rename \u2014 a fresh spool takes new writes.
1634
+ try {
1635
+ if (existsSync(TELEMETRY_SPOOL) && statSync(TELEMETRY_SPOOL).size > 0) {
1636
+ const claim = join(dir, SPOOL_DRAIN_PREFIX + process.pid + '.' + Date.now());
1637
+ renameSync(TELEMETRY_SPOOL, claim);
1638
+ claimed.push(claim);
1639
+ }
1640
+ } catch {}
1641
+
1642
+ // 2. Recover orphaned claim files \u2014 a previous hook died mid-drain. Only
1643
+ // adopt claims older than 30s so we never steal another hook's in-flight drain.
1644
+ try {
1645
+ for (const f of readdirSync(dir)) {
1646
+ if (!f.startsWith(SPOOL_DRAIN_PREFIX)) continue;
1647
+ const full = join(dir, f);
1648
+ if (claimed.indexOf(full) !== -1) continue;
1649
+ try {
1650
+ if (Date.now() - statSync(full).mtimeMs > 30000) claimed.push(full);
1651
+ } catch {}
1652
+ }
1653
+ } catch {}
1654
+ if (claimed.length === 0) return;
1655
+
1656
+ // 3. Read every event out of the claimed files.
1657
+ const events: any[] = [];
1658
+ for (const f of claimed) {
1659
+ try {
1660
+ for (const line of readFileSync(f, 'utf-8').split('\\n')) {
1661
+ const t = line.trim();
1662
+ if (!t) continue;
1663
+ try { events.push(JSON.parse(t)); } catch {}
1664
+ }
1665
+ } catch {}
1666
+ }
1667
+ if (events.length === 0) {
1668
+ for (const f of claimed) { try { unlinkSync(f); } catch {} }
1669
+ return;
1670
+ }
1671
+
1672
+ // 4. Ship to /api/ingest/batch in chunks. A token is required by the server.
1601
1673
  const mcpPort = process.env.SYNKRO_MCP_PORT || '18931';
1602
1674
  let mcpToken = '';
1603
1675
  try { mcpToken = readFileSync(join(HOME, '.synkro', '.mcp-jwt'), 'utf-8').trim(); } catch {}
1604
- if (!mcpToken) return;
1605
- fetch(\`http://127.0.0.1:\${mcpPort}/api/ingest\`, {
1606
- method: 'POST',
1607
- headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + mcpToken },
1608
- body: JSON.stringify({ data: event }),
1609
- signal: AbortSignal.timeout(2000),
1610
- }).catch(() => {});
1676
+ if (!mcpToken) return; // leave claim files; a later drain retries them
1677
+
1678
+ let allOk = true;
1679
+ for (let i = 0; i < events.length; i += 200) {
1680
+ try {
1681
+ const resp = await fetch('http://127.0.0.1:' + mcpPort + '/api/ingest/batch', {
1682
+ method: 'POST',
1683
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + mcpToken },
1684
+ body: JSON.stringify({ events: events.slice(i, i + 200) }),
1685
+ signal: AbortSignal.timeout(8000),
1686
+ });
1687
+ if (!resp.ok) { allOk = false; break; }
1688
+ } catch { allOk = false; break; }
1689
+ }
1690
+
1691
+ // 5. Success \u2192 drop the claim files. Failure \u2192 re-spool for the next drain.
1692
+ if (allOk) {
1693
+ for (const f of claimed) { try { unlinkSync(f); } catch {} }
1694
+ } else {
1695
+ try {
1696
+ appendFileSync(TELEMETRY_SPOOL, events.map(e => JSON.stringify(e)).join('\\n') + '\\n');
1697
+ for (const f of claimed) { try { unlinkSync(f); } catch {} }
1698
+ } catch {}
1699
+ // if re-spool failed, claim files remain and are recovered as orphans later
1700
+ }
1611
1701
  }
1612
1702
 
1613
1703
  // \u2500\u2500\u2500 Rule Mode Lookup \u2500\u2500\u2500
@@ -6327,7 +6417,7 @@ function writeConfigEnv(opts) {
6327
6417
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
6328
6418
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
6329
6419
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
6330
- `SYNKRO_VERSION=${shellQuoteSingle("1.6.5")}`
6420
+ `SYNKRO_VERSION=${shellQuoteSingle("1.6.6")}`
6331
6421
  ];
6332
6422
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
6333
6423
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
@@ -7814,7 +7904,7 @@ var args = process.argv.slice(2);
7814
7904
  var cmd = args[0] || "";
7815
7905
  var subArgs = args.slice(1);
7816
7906
  function printVersion() {
7817
- console.log("1.6.5");
7907
+ console.log("1.6.6");
7818
7908
  }
7819
7909
  function printHelp() {
7820
7910
  console.log(`Synkro CLI \u2014 runtime safety for AI coding agents