@synkro-sh/cli 1.4.60 → 1.4.62

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
@@ -911,63 +911,93 @@ function decodeJwtExp(jwt: string): number {
911
911
  }
912
912
 
913
913
  export async function refreshJwt(jwt: string): Promise<string> {
914
- try {
915
- const creds = JSON.parse(readFileSync(CREDS_PATH, 'utf-8'));
916
- const rt = creds.refresh_token;
917
- if (!rt) return jwt;
914
+ const creds = JSON.parse(readFileSync(CREDS_PATH, 'utf-8'));
915
+ const rt = creds.refresh_token;
916
+ if (!rt) return jwt;
918
917
 
919
- const resp = await fetch(GATEWAY_URL + '/api/auth/refresh', {
920
- method: 'POST',
921
- headers: { 'Content-Type': 'application/json' },
922
- body: JSON.stringify({ refresh_token: rt }),
923
- signal: AbortSignal.timeout(4000),
924
- });
925
- const data = await resp.json() as any;
926
- const newAt = data.access_token;
927
- if (!newAt) return jwt;
928
-
929
- const newRt = data.refresh_token || rt;
930
- const existing = (() => {
931
- try { return JSON.parse(readFileSync(CREDS_PATH, 'utf-8')); } catch { return {}; }
932
- })();
933
- const updated = { ...existing, access_token: newAt, refresh_token: newRt };
934
- const tmp = CREDS_PATH + '.synkro.tmp';
935
- writeFileSync(tmp, JSON.stringify(updated, null, 2));
936
- renameSync(tmp, CREDS_PATH);
937
- return newAt;
938
- } catch {
918
+ const resp = await fetch(GATEWAY_URL + '/api/auth/refresh', {
919
+ method: 'POST',
920
+ headers: { 'Content-Type': 'application/json' },
921
+ body: JSON.stringify({ refresh_token: rt }),
922
+ signal: AbortSignal.timeout(4000),
923
+ });
924
+
925
+ if (!resp.ok) {
926
+ log('refresh failed: HTTP ' + resp.status);
939
927
  return jwt;
940
928
  }
929
+
930
+ const data = await resp.json() as any;
931
+ const newAt = data.access_token;
932
+ if (!newAt) return jwt;
933
+
934
+ const newRt = data.refresh_token || rt;
935
+ const existing = (() => {
936
+ try { return JSON.parse(readFileSync(CREDS_PATH, 'utf-8')); } catch { return {}; }
937
+ })();
938
+ const updated = { ...existing, access_token: newAt, refresh_token: newRt };
939
+ const tmp = CREDS_PATH + '.synkro.tmp';
940
+ writeFileSync(tmp, JSON.stringify(updated, null, 2));
941
+ renameSync(tmp, CREDS_PATH);
942
+ return newAt;
943
+ }
944
+
945
+ function jwtIsExpired(jwt: string): boolean {
946
+ const exp = decodeJwtExp(jwt);
947
+ return exp - Math.floor(Date.now() / 1000) < 60;
948
+ }
949
+
950
+ function lockIsStale(lockfile: string, maxAgeMs = 8000): boolean {
951
+ try {
952
+ const stat = require('node:fs').statSync(lockfile);
953
+ return Date.now() - stat.mtimeMs > maxAgeMs;
954
+ } catch {
955
+ return false;
956
+ }
941
957
  }
942
958
 
943
959
  export async function ensureFreshJwt(jwt: string): Promise<string> {
944
960
  if (!jwt) return jwt;
945
- const exp = decodeJwtExp(jwt);
946
- const now = Math.floor(Date.now() / 1000);
947
- if (exp - now >= 60) return jwt;
961
+ if (!jwtIsExpired(jwt)) return jwt;
948
962
 
949
963
  const lockfile = CREDS_PATH + '.lock';
964
+
965
+ // Clean stale lock left by a killed hook
966
+ if (existsSync(lockfile) && lockIsStale(lockfile)) {
967
+ try { unlinkSync(lockfile); } catch {}
968
+ }
969
+
950
970
  let fd = -1;
951
971
  try {
952
972
  fd = openSync(lockfile, FS_CONSTANTS.O_WRONLY | FS_CONSTANTS.O_CREAT | FS_CONSTANTS.O_EXCL, 0o644);
953
973
  } catch {
954
- // Another process holds the lock \u2014 wait for it to finish and re-read
955
- for (let i = 0; i < 10; i++) {
956
- await new Promise(r => setTimeout(r, 300));
974
+ // Another process is refreshing \u2014 wait for it
975
+ for (let i = 0; i < 8; i++) {
976
+ await new Promise(r => setTimeout(r, 500));
957
977
  if (!existsSync(lockfile)) break;
958
978
  }
979
+ // Stale lock after full wait \u2014 force-remove
980
+ if (existsSync(lockfile) && lockIsStale(lockfile)) {
981
+ try { unlinkSync(lockfile); } catch {}
982
+ }
983
+ // Re-read \u2014 the winner should have written a fresh JWT
959
984
  const fresh = loadJwt();
960
- return fresh || jwt;
985
+ if (fresh && !jwtIsExpired(fresh)) return fresh;
986
+ // Winner's refresh failed \u2014 try to acquire lock ourselves
987
+ try {
988
+ fd = openSync(lockfile, FS_CONSTANTS.O_WRONLY | FS_CONSTANTS.O_CREAT | FS_CONSTANTS.O_EXCL, 0o644);
989
+ } catch {
990
+ return fresh || jwt;
991
+ }
961
992
  }
962
993
 
963
994
  try {
964
- // Re-check \u2014 another hook may have just refreshed while we waited
995
+ // Re-check \u2014 another hook may have written fresh creds while we waited for lock
965
996
  const freshJwt = loadJwt();
966
- if (freshJwt) {
967
- const freshExp = decodeJwtExp(freshJwt);
968
- if (freshExp - Math.floor(Date.now() / 1000) >= 60) return freshJwt;
969
- }
997
+ if (freshJwt && !jwtIsExpired(freshJwt)) return freshJwt;
970
998
  return await refreshJwt(jwt);
999
+ } catch {
1000
+ return jwt;
971
1001
  } finally {
972
1002
  try { closeSync(fd); } catch {}
973
1003
  try { unlinkSync(lockfile); } catch {}
@@ -1255,6 +1285,20 @@ export function dispatchCapture(
1255
1285
  if (repo) body.repo = repo;
1256
1286
  if (sessionId) body.session_id = sessionId;
1257
1287
 
1288
+ // Local telemetry always gets full content \u2014 data never leaves the machine
1289
+ const localBody = { ...body };
1290
+ if (opts) {
1291
+ if (opts.command) localBody.command = opts.command;
1292
+ if (opts.reasoning) localBody.reasoning = opts.reasoning;
1293
+ if (opts.rulesChecked) localBody.rules_checked = opts.rulesChecked;
1294
+ if (opts.violatedRules) localBody.violated_rules = opts.violatedRules;
1295
+ if (opts.recentUserMessages) localBody.recent_user_messages = opts.recentUserMessages;
1296
+ }
1297
+ appendLocalTelemetry(localBody);
1298
+
1299
+ // local_only: no data leaves the machine
1300
+ if (captureDepth === 'local_only') return;
1301
+
1258
1302
  if (sendFull && opts) {
1259
1303
  body.capture_depth = captureDepth;
1260
1304
  if (opts.command) body.command = opts.command;
@@ -1264,8 +1308,6 @@ export function dispatchCapture(
1264
1308
  if (opts.recentUserMessages) body.recent_user_messages = opts.recentUserMessages;
1265
1309
  }
1266
1310
 
1267
- appendLocalTelemetry(body);
1268
-
1269
1311
  fetch(GATEWAY_URL + '/api/v1/hook/capture', {
1270
1312
  method: 'POST',
1271
1313
  headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
@@ -1848,6 +1890,25 @@ async function main() {
1848
1890
  const proposed = reconstructContent(toolName, toolInput, filePath, cwd);
1849
1891
  if (!proposed) { outputEmpty(); return; }
1850
1892
 
1893
+ // Change-anchored window: for Edit/MultiEdit send context around the diff,
1894
+ // for Write send first 4000 chars (new files have patterns at the top).
1895
+ let cweContent: string;
1896
+ if (toolName === 'Edit' || toolName === 'MultiEdit') {
1897
+ const newStr = toolName === 'Edit'
1898
+ ? (toolInput.new_string || '')
1899
+ : (Array.isArray(toolInput.edits) ? toolInput.edits.map((e: any) => e?.new_string || '').join('\\n') : '');
1900
+ const changeIdx = proposed.indexOf(newStr);
1901
+ if (changeIdx >= 0 && proposed.length > 6000) {
1902
+ const start = Math.max(0, changeIdx - 2000);
1903
+ const end = Math.min(proposed.length, changeIdx + newStr.length + 2000);
1904
+ cweContent = proposed.slice(start, end);
1905
+ } else {
1906
+ cweContent = proposed.slice(0, 6000);
1907
+ }
1908
+ } else {
1909
+ cweContent = proposed.slice(0, 4000);
1910
+ }
1911
+
1851
1912
  const config = await loadConfig(jwt);
1852
1913
  const rt = await cweRoute(config);
1853
1914
 
@@ -1885,7 +1946,7 @@ async function main() {
1885
1946
  const graderPrompt = [
1886
1947
  'File: ' + filePath,
1887
1948
  'Content:',
1888
- proposed,
1949
+ cweContent,
1889
1950
  '',
1890
1951
  'CWE rules to check against:',
1891
1952
  JSON.stringify(cweRules),
@@ -2590,7 +2651,7 @@ main();
2590
2651
  `;
2591
2652
  BASH_FOLLOWUP_TS = `#!/usr/bin/env bun
2592
2653
  import {
2593
- loadJwt, readStdin, hashCommand, consentGrant, consentHasActive, consentConsume,
2654
+ loadJwt, loadConfig, readStdin, hashCommand, consentGrant, consentHasActive, consentConsume,
2594
2655
  outputEmpty, appendLocalTelemetry, GATEWAY_URL,
2595
2656
  } from './_synkro-common.ts';
2596
2657
 
@@ -2634,12 +2695,15 @@ async function main() {
2634
2695
 
2635
2696
  appendLocalTelemetry(body);
2636
2697
 
2637
- fetch(GATEWAY_URL + '/api/v1/hook/capture', {
2638
- method: 'POST',
2639
- headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
2640
- body: JSON.stringify(body),
2641
- signal: AbortSignal.timeout(3000),
2642
- }).catch(() => {});
2698
+ const config = await loadConfig(jwt);
2699
+ if (config.captureDepth !== 'local_only') {
2700
+ fetch(GATEWAY_URL + '/api/v1/hook/capture', {
2701
+ method: 'POST',
2702
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt },
2703
+ body: JSON.stringify(body),
2704
+ signal: AbortSignal.timeout(3000),
2705
+ }).catch(() => {});
2706
+ }
2643
2707
 
2644
2708
  outputEmpty();
2645
2709
  } catch {
@@ -5137,7 +5201,7 @@ function writeConfigEnv(opts) {
5137
5201
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
5138
5202
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
5139
5203
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
5140
- `SYNKRO_VERSION=${shellQuoteSingle("1.4.60")}`
5204
+ `SYNKRO_VERSION=${shellQuoteSingle("1.4.62")}`
5141
5205
  ];
5142
5206
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
5143
5207
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);