@synkro-sh/cli 1.6.5 → 1.6.7

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
@@ -5768,6 +5858,7 @@ __export(dockerInstall_exports, {
5768
5858
  dockerStop: () => dockerStop,
5769
5859
  dockerUpdate: () => dockerUpdate,
5770
5860
  imageTag: () => imageTag,
5861
+ readContainerConfig: () => readContainerConfig,
5771
5862
  resolveWorkerConfig: () => resolveWorkerConfig,
5772
5863
  splitWorkers: () => splitWorkers,
5773
5864
  waitForContainerReady: () => waitForContainerReady
@@ -6001,6 +6092,34 @@ function dockerStatus() {
6001
6092
  healthz: `http://127.0.0.1:${HOST_MCP_PORT}/`
6002
6093
  };
6003
6094
  }
6095
+ function readContainerConfig() {
6096
+ const r = spawnSync2("docker", ["inspect", "--format", "{{json .Config.Env}}", CONTAINER_NAME], {
6097
+ encoding: "utf-8",
6098
+ timeout: 5e3
6099
+ });
6100
+ if (r.status !== 0 || !r.stdout) return null;
6101
+ let env;
6102
+ try {
6103
+ env = JSON.parse(r.stdout.trim());
6104
+ } catch {
6105
+ return null;
6106
+ }
6107
+ if (!Array.isArray(env)) return null;
6108
+ const get = (k) => {
6109
+ const hit = env.find((e) => typeof e === "string" && e.startsWith(k + "="));
6110
+ return hit ? hit.slice(k.length + 1) : void 0;
6111
+ };
6112
+ const num = (s) => {
6113
+ if (s === void 0) return void 0;
6114
+ const n = parseInt(s, 10);
6115
+ return Number.isFinite(n) ? n : void 0;
6116
+ };
6117
+ return {
6118
+ claudeWorkers: num(get("CLAUDE_WORKERS")),
6119
+ cursorWorkers: num(get("CURSOR_WORKERS")),
6120
+ connectedRepo: get("SYNKRO_CONNECTED_REPO") || void 0
6121
+ };
6122
+ }
6004
6123
  async function dockerSafeStop() {
6005
6124
  const status = dockerStatus();
6006
6125
  if (!status.running) {
@@ -6327,7 +6446,7 @@ function writeConfigEnv(opts) {
6327
6446
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
6328
6447
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
6329
6448
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
6330
- `SYNKRO_VERSION=${shellQuoteSingle("1.6.5")}`
6449
+ `SYNKRO_VERSION=${shellQuoteSingle("1.6.7")}`
6331
6450
  ];
6332
6451
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
6333
6452
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
@@ -7450,22 +7569,22 @@ import { existsSync as existsSync11, rmSync, readdirSync as readdirSync3 } from
7450
7569
  import { homedir as homedir10 } from "os";
7451
7570
  import { join as join10 } from "path";
7452
7571
  import { spawnSync as spawnSync4 } from "child_process";
7453
- async function tearDownLocalCC(purge) {
7572
+ import { createInterface as createInterface4 } from "readline";
7573
+ async function tearDownLocalCC() {
7454
7574
  const docker = dockerStatus();
7455
7575
  if (docker.running) {
7456
7576
  await dockerSafeStop();
7457
- console.log("\u2713 stopped synkro-server container (data snapshot saved)");
7577
+ console.log("\u2713 stopped synkro-server container");
7458
7578
  } else {
7459
7579
  console.log("\xB7 no synkro-server container running");
7460
7580
  }
7461
7581
  dockerRemove();
7462
- if (purge) {
7463
- try {
7464
- const image = imageTag();
7465
- spawnSync4("docker", ["rmi", image], { encoding: "utf-8", timeout: 3e4 });
7466
- console.log(`\u2713 removed Docker image ${image}`);
7467
- } catch {
7468
- }
7582
+ console.log("\u2713 removed synkro-server container");
7583
+ try {
7584
+ const image = imageTag();
7585
+ const r = spawnSync4("docker", ["rmi", "-f", image], { encoding: "utf-8", timeout: 3e4 });
7586
+ console.log(r.status === 0 ? `\u2713 removed Docker image ${image}` : "\xB7 no Docker image to remove");
7587
+ } catch {
7469
7588
  }
7470
7589
  if (needsKeychainBridge()) {
7471
7590
  try {
@@ -7477,10 +7596,31 @@ async function tearDownLocalCC(purge) {
7477
7596
  uninstallLocalCC();
7478
7597
  console.log("\u2713 cleaned ~/.claude.json entries");
7479
7598
  }
7599
+ function confirmPurge() {
7600
+ console.log("\u26A0 WARNING \u2014 synkro uninstall --purge");
7601
+ console.log(" This permanently deletes ALL Synkro data on this machine,");
7602
+ console.log(" including every scan finding, telemetry record, and backup in");
7603
+ console.log(" ~/.synkro/pgdata and ~/.synkro/pgdata-backups. It cannot be undone.\n");
7604
+ if (!process.stdin.isTTY) {
7605
+ console.log(" Non-interactive shell \u2014 re-run in a terminal to confirm.");
7606
+ return Promise.resolve(false);
7607
+ }
7608
+ const rl = createInterface4({ input: process.stdin, output: process.stdout });
7609
+ return new Promise((resolve3) => {
7610
+ rl.question(" Type 'yes' to wipe everything (anything else cancels): ", (answer) => {
7611
+ rl.close();
7612
+ resolve3(answer.trim().toLowerCase() === "yes");
7613
+ });
7614
+ });
7615
+ }
7480
7616
  async function disconnectCommand(args2 = []) {
7481
7617
  const purge = args2.includes("--purge");
7482
- console.log("Synkro disconnect starting...\n");
7483
- await tearDownLocalCC(purge);
7618
+ if (purge && !await confirmPurge()) {
7619
+ console.log("\nAborted \u2014 nothing was removed.");
7620
+ return;
7621
+ }
7622
+ console.log("\nSynkro uninstall starting...\n");
7623
+ await tearDownLocalCC();
7484
7624
  const agents = detectAgents();
7485
7625
  let sawClaudeCode = false;
7486
7626
  for (const agent of agents) {
@@ -7501,31 +7641,32 @@ async function disconnectCommand(args2 = []) {
7501
7641
  const cursorMcpRemoved = uninstallCursorMcpConfig();
7502
7642
  console.log(`${cursorMcpRemoved ? "\u2713" : "\xB7"} MCP guardrails (Cursor): ${cursorMcpRemoved ? "removed from ~/.cursor/mcp.json" : "no entry found"}`);
7503
7643
  }
7504
- if (purge) {
7505
- if (existsSync11(SYNKRO_DIR5)) {
7506
- const pgdataPath = join10(SYNKRO_DIR5, "pgdata");
7507
- const backupsPath = join10(SYNKRO_DIR5, "pgdata-backups");
7644
+ if (existsSync11(SYNKRO_DIR5)) {
7645
+ if (purge) {
7646
+ rmSync(SYNKRO_DIR5, { recursive: true, force: true });
7647
+ console.log(`\u2713 wiped ${SYNKRO_DIR5} entirely \u2014 including all scan data and backups`);
7648
+ } else {
7649
+ const keep = /* @__PURE__ */ new Set([join10(SYNKRO_DIR5, "pgdata"), join10(SYNKRO_DIR5, "pgdata-backups")]);
7508
7650
  const preserved = [];
7509
7651
  for (const entry of readdirSync3(SYNKRO_DIR5)) {
7510
7652
  const full = join10(SYNKRO_DIR5, entry);
7511
- if (full === pgdataPath || full === backupsPath) {
7653
+ if (keep.has(full)) {
7512
7654
  preserved.push(entry);
7513
7655
  continue;
7514
7656
  }
7515
7657
  rmSync(full, { recursive: true, force: true });
7516
7658
  }
7517
7659
  if (preserved.length > 0) {
7518
- console.log(`\u2713 Removed ${SYNKRO_DIR5} config (preserved: ${preserved.join(", ")} \u2014 your data is safe)`);
7660
+ console.log(`\u2713 removed Synkro config from ${SYNKRO_DIR5} (kept your scan data: ${preserved.join(", ")})`);
7661
+ console.log(" run `synkro uninstall --purge` to delete that too");
7519
7662
  } else {
7520
- console.log(`\u2713 Removed ${SYNKRO_DIR5}`);
7663
+ console.log(`\u2713 removed ${SYNKRO_DIR5}`);
7521
7664
  }
7522
- } else {
7523
- console.log(`\xB7 ${SYNKRO_DIR5} already gone, nothing to remove`);
7524
7665
  }
7525
- } else if (existsSync11(SYNKRO_DIR5)) {
7526
- console.log(`Config preserved at ${SYNKRO_DIR5}. Run with --purge to remove.`);
7666
+ } else {
7667
+ console.log(`\xB7 ${SYNKRO_DIR5} already gone`);
7527
7668
  }
7528
- console.log("\nSynkro disconnected.");
7669
+ console.log(purge ? "\nSynkro fully removed \u2014 this machine is clean, as if it was never installed." : "\nSynkro uninstalled. Your scan data is preserved.");
7529
7670
  }
7530
7671
  var SYNKRO_DIR5;
7531
7672
  var init_disconnect = __esm({
@@ -7722,7 +7863,8 @@ var lifecycle_exports = {};
7722
7863
  __export(lifecycle_exports, {
7723
7864
  restartCommand: () => restartCommand,
7724
7865
  startCommand: () => startCommand,
7725
- stopCommand: () => stopCommand
7866
+ stopCommand: () => stopCommand,
7867
+ updateCommand: () => updateCommand
7726
7868
  });
7727
7869
  async function stopCommand() {
7728
7870
  assertDockerAvailable();
@@ -7758,6 +7900,26 @@ Start failed: ${result.error}`);
7758
7900
  }
7759
7901
  console.log("\nServer is running.");
7760
7902
  }
7903
+ async function updateCommand() {
7904
+ assertDockerAvailable();
7905
+ const cfg = readContainerConfig();
7906
+ if (!cfg) {
7907
+ console.error("No synkro-server container found. Run `synkro install` first.");
7908
+ process.exit(1);
7909
+ }
7910
+ const claudeWorkers = cfg.claudeWorkers ?? 8;
7911
+ const cursorWorkers = cfg.cursorWorkers ?? 0;
7912
+ console.log("Synkro: updating to the latest container image");
7913
+ console.log(` preserving pool: ${claudeWorkers} claude + ${cursorWorkers} cursor worker(s)
7914
+ `);
7915
+ await dockerUpdate({ claudeWorkers, cursorWorkers, connectedRepo: cfg.connectedRepo });
7916
+ const ready = await waitForContainerReady(9e4);
7917
+ if (!ready) {
7918
+ console.error("\n\u26A0 container did not pass its health check within 90s \u2014 check: docker logs synkro-server");
7919
+ process.exit(1);
7920
+ }
7921
+ console.log("\nSynkro updated \u2014 now running the latest version.");
7922
+ }
7761
7923
  async function restartCommand(rest = []) {
7762
7924
  assertDockerAvailable();
7763
7925
  const cfg = resolveWorkerConfig(rest);
@@ -7814,7 +7976,7 @@ var args = process.argv.slice(2);
7814
7976
  var cmd = args[0] || "";
7815
7977
  var subArgs = args.slice(1);
7816
7978
  function printVersion() {
7817
- console.log("1.6.5");
7979
+ console.log("1.6.7");
7818
7980
  }
7819
7981
  function printHelp() {
7820
7982
  console.log(`Synkro CLI \u2014 runtime safety for AI coding agents
@@ -7824,10 +7986,11 @@ Usage:
7824
7986
 
7825
7987
  Commands:
7826
7988
  install [--force] Install or update Synkro
7827
- uninstall [--purge] Remove Synkro hooks (--purge also removes ~/.synkro)
7989
+ uninstall [--purge] Remove Synkro (keeps scan data; --purge wipes that too)
7828
7990
  stop Gracefully stop the server (snapshot + checkpoint)
7829
7991
  start [opts] Start the server (with pgdata integrity check)
7830
7992
  restart [opts] Safe restart (stop \u2192 start, data preserved)
7993
+ update Pull the latest container image and safely restart
7831
7994
  version Show version
7832
7995
 
7833
7996
  start/restart opts (recreate the worker pool):
@@ -7886,6 +8049,11 @@ async function main() {
7886
8049
  await restartCommand2(args.slice(1));
7887
8050
  break;
7888
8051
  }
8052
+ case "update": {
8053
+ const { updateCommand: updateCommand2 } = await Promise.resolve().then(() => (init_lifecycle(), lifecycle_exports));
8054
+ await updateCommand2();
8055
+ break;
8056
+ }
7889
8057
  default: {
7890
8058
  console.error(`Unknown command: ${cmd}`);
7891
8059
  printHelp();