@synkro-sh/cli 1.6.39 → 1.6.41

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
@@ -41,26 +41,26 @@ function detectAgents() {
41
41
  const home = homedir();
42
42
  const claudeBinary = which("claude");
43
43
  const claudeConfigDir = join(home, ".claude");
44
- if (claudeBinary || existsSync(claudeConfigDir)) {
44
+ if (claudeBinary) {
45
45
  agents.push({
46
46
  kind: "claude_code",
47
47
  name: "Claude Code",
48
48
  binaryPath: claudeBinary,
49
49
  configDir: claudeConfigDir,
50
50
  settingsPath: join(claudeConfigDir, "settings.json"),
51
- version: claudeBinary ? getVersion("claude") : void 0
51
+ version: getVersion("claude")
52
52
  });
53
53
  }
54
54
  const cursorBinary = which("cursor");
55
55
  const cursorConfigDir = join(home, ".cursor");
56
- if (cursorBinary || existsSync(cursorConfigDir)) {
56
+ if (cursorBinary) {
57
57
  agents.push({
58
58
  kind: "cursor",
59
59
  name: "Cursor",
60
60
  binaryPath: cursorBinary,
61
61
  configDir: cursorConfigDir,
62
62
  settingsPath: join(cursorConfigDir, "hooks.json"),
63
- version: cursorBinary ? getVersion("cursor") : void 0
63
+ version: getVersion("cursor")
64
64
  });
65
65
  }
66
66
  return agents;
@@ -8018,7 +8018,7 @@ __export(install_exports, {
8018
8018
  import { existsSync as existsSync10, mkdirSync as mkdirSync8, writeFileSync as writeFileSync7, chmodSync as chmodSync2, readFileSync as readFileSync8, readdirSync as readdirSync3 } from "fs";
8019
8019
  import { homedir as homedir8 } from "os";
8020
8020
  import { join as join8 } from "path";
8021
- import { execSync as execSync6, spawnSync as spawnSync3 } from "child_process";
8021
+ import { execSync as execSync6 } from "child_process";
8022
8022
  import { createInterface as createInterface3 } from "readline";
8023
8023
  function sanitizeGatewayCandidate(raw) {
8024
8024
  if (!raw) return void 0;
@@ -8184,17 +8184,6 @@ function writeHookScripts() {
8184
8184
  writeFileSync7(cursorAgentCapturePath, CURSOR_AGENT_CAPTURE_TS, "utf-8");
8185
8185
  writeFileSync7(mcpStdioProxyPath, MCP_STDIO_PROXY_SRC, "utf-8");
8186
8186
  writeFileSync7(installExtractCorePath, "/**\n * Deterministic install-command extraction \u2014 no LLM, no network.\n * Shared by the API pkg-scan route and the hook scripts (copied to ~/.synkro/hooks/).\n */\nimport { parse as shellParse } from 'shell-quote';\n\nexport interface DeterministicPkgRequest {\n name: string;\n version: string;\n ecosystem: string;\n}\n\ninterface RawInstall {\n ecosystem: string;\n name: string;\n versionSpec: string | null;\n source: 'registry' | 'git' | 'local' | 'url' | 'unknown';\n}\n\nconst SEPARATOR_OPS = new Set(['&&', '||', ';', '|', '&', '\\n']);\n\n/** Split a shell command into command segments (each an argv string array). */\nexport function segmentCommand(command: string): string[][] {\n let tokens: unknown[];\n try {\n tokens = shellParse(command) as unknown[];\n } catch {\n return [command.split(/\\s+/).filter(Boolean)];\n }\n const segments: string[][] = [];\n let current: string[] = [];\n for (const tok of tokens) {\n if (typeof tok === 'string') {\n current.push(tok);\n continue;\n }\n if (tok && typeof tok === 'object') {\n const op = (tok as { op?: string }).op;\n const pattern = (tok as { pattern?: string }).pattern;\n if (op && SEPARATOR_OPS.has(op)) {\n if (current.length) segments.push(current);\n current = [];\n } else if (typeof pattern === 'string') {\n current.push(pattern);\n }\n }\n }\n if (current.length) segments.push(current);\n return segments;\n}\n\nconst PM_TABLE: Record<string, { subs: Set<string>; ecosystem: string }> = {\n npm: { subs: new Set(['install', 'i', 'add', 'ci']), ecosystem: 'npm' },\n pnpm: { subs: new Set(['add', 'install', 'i', 'dlx']), ecosystem: 'npm' },\n yarn: { subs: new Set(['add', 'install']), ecosystem: 'npm' },\n bun: { subs: new Set(['add', 'install', 'i']), ecosystem: 'npm' },\n pip: { subs: new Set(['install']), ecosystem: 'PyPI' },\n pip3: { subs: new Set(['install']), ecosystem: 'PyPI' },\n cargo: { subs: new Set(['add', 'install']), ecosystem: 'crates.io' },\n go: { subs: new Set(['get', 'install']), ecosystem: 'Go' },\n gem: { subs: new Set(['install']), ecosystem: 'RubyGems' },\n composer: { subs: new Set(['require']), ecosystem: 'Packagist' },\n};\nconst SYSTEM_PMS = new Set(['apt', 'apt-get', 'apk', 'brew', 'dnf', 'yum', 'pacman']);\nconst SYSTEM_SUBS = new Set(['install', 'add']);\n\nconst WRAPPERS = new Set(['sudo', 'doas', 'command', 'env', 'xargs', 'nice', 'time']);\nconst VALUE_FLAGS = new Set([\n '--filter', '-F', '-C', '--dir', '--prefix', '--registry', '--tag', '--features',\n '-v', '--version', '--index-url', '--extra-index-url', '--target', '-t',\n]);\n\nfunction basename(p: string): string {\n const i = p.lastIndexOf('/');\n return i >= 0 ? p.slice(i + 1) : p;\n}\n\nfunction stripPrefixes(argv: string[]): string[] {\n let i = 0;\n while (i < argv.length) {\n const t = argv[i];\n if (WRAPPERS.has(basename(t).toLowerCase())) { i++; continue; }\n if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(t)) { i++; continue; }\n break;\n }\n return argv.slice(i);\n}\n\nfunction looksLikePath(tok: string): boolean {\n return tok === '.' || tok === '..' || /^\\.{0,2}\\//.test(tok) || tok.startsWith('~/') || tok.startsWith('file:');\n}\n\n/** Shell redirect fragments (e.g. `2>&1` \u2192 argv `2`, `1`) \u2014 not package names. */\nfunction isRedirectFragment(tok: string): boolean {\n if (/^\\d+$/.test(tok)) return true;\n if (/^[<>]|[<>]$/.test(tok)) return true;\n if (tok === '&' || tok === '|') return true;\n if (/^\\d*[<>]/.test(tok)) return true;\n return false;\n}\n\nfunction parsePackageToken(tok: string, ecosystem: string): RawInstall | null {\n if (/^(https?:)?\\/\\//.test(tok) || tok.startsWith('git+') || tok.startsWith('git:')) {\n return { ecosystem, name: tok, versionSpec: null, source: tok.includes('git') ? 'git' : 'url' };\n }\n if (looksLikePath(tok)) {\n return { ecosystem, name: basename(tok.replace(/\\/+$/, '')) || tok, versionSpec: null, source: 'local' };\n }\n if (ecosystem === 'PyPI') {\n const noExtras = tok.replace(/\\[[^\\]]*\\]/g, '');\n const m = noExtras.match(/^([A-Za-z0-9_.-]+)\\s*([=~!<>].*)?$/);\n if (!m) return null;\n return { ecosystem, name: m[1], versionSpec: m[2] ? m[2].trim() : null, source: 'registry' };\n }\n const at = tok.lastIndexOf('@');\n if (at > 0) {\n return { ecosystem, name: tok.slice(0, at), versionSpec: tok.slice(at + 1) || null, source: 'registry' };\n }\n return { ecosystem, name: tok, versionSpec: null, source: 'registry' };\n}\n\n/** Deterministic extraction for a single command segment. */\nexport function extractSegment(rawArgv: string[]): RawInstall[] {\n let argv = stripPrefixes(rawArgv);\n if (argv.length < 2) return [];\n let bin = basename(argv[0]).toLowerCase();\n\n if (bin === 'uv' && argv[1] === 'pip') { argv = argv.slice(1); bin = 'pip'; }\n if ((bin === 'python' || bin === 'python3') && argv.includes('-m')) {\n const mi = argv.indexOf('-m');\n if (argv[mi + 1] === 'pip') { argv = ['pip', ...argv.slice(mi + 2)]; bin = 'pip'; }\n }\n\n const isSystem = SYSTEM_PMS.has(bin);\n const entry = PM_TABLE[bin];\n if (!entry && !isSystem) return [];\n const ecosystem = entry ? entry.ecosystem : 'system';\n const subs = entry ? entry.subs : SYSTEM_SUBS;\n\n let subIdx = -1;\n for (let i = 1; i < argv.length; i++) {\n if (subs.has(argv[i].toLowerCase())) { subIdx = i; break; }\n }\n if (subIdx === -1) return [];\n\n const installs: RawInstall[] = [];\n for (let i = subIdx + 1; i < argv.length; i++) {\n const tok = argv[i];\n if (isRedirectFragment(tok)) break;\n if (tok.startsWith('-')) {\n if (VALUE_FLAGS.has(tok)) i++;\n continue;\n }\n const parsed = parsePackageToken(tok, ecosystem);\n if (parsed) installs.push(parsed);\n }\n return installs;\n}\n\nconst ECO_TO_OSV: Record<string, string | null> = {\n npm: 'npm',\n pypi: 'PyPI', PyPI: 'PyPI',\n cargo: 'crates.io', 'crates.io': 'crates.io',\n go: 'Go', Go: 'Go',\n rubygems: 'RubyGems', RubyGems: 'RubyGems',\n packagist: 'Packagist', Packagist: 'Packagist',\n maven: 'Maven', Maven: 'Maven',\n nuget: 'NuGet', NuGet: 'NuGet',\n apt: null, brew: null, system: null, other: null,\n};\n\nfunction normalizeName(name: string, osvEco: string): string {\n const n = name.trim();\n if (osvEco === 'npm') return n.toLowerCase();\n if (osvEco === 'PyPI') return n.toLowerCase().replace(/[-_.]+/g, '-');\n return n;\n}\n\nfunction concretePin(spec: string | null): string | null {\n if (!spec) return null;\n const c = spec.trim().replace(/^[v=\\s]+/, '');\n if (c.toLowerCase() === 'latest' || c === '') return null;\n if (/[\\^~><|*\\sx]/i.test(c)) return null;\n return /^\\d[\\w.\\-+]*$/.test(c) ? c : null;\n}\n\nconst PKG_JSON_DEP_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];\n\nfunction safeParseObject(text: string): Record<string, any> | null {\n try {\n const v = JSON.parse(text);\n return v && typeof v === 'object' && !Array.isArray(v) ? v : null;\n } catch {\n return null;\n }\n}\n\n/**\n * Diff two package.json contents and return the registry packages that are\n * newly added or whose version spec changed in the new content. The caller\n * scans these against the vuln DB before letting the edit land \u2014 so a bare\n * `pnpm install` afterwards has nothing left to vet. Non-registry sources\n * (file:, link:, workspace:, git, http, relative paths) are skipped.\n */\nexport function extractPackageJsonDelta(oldText: string, newText: string): DeterministicPkgRequest[] {\n const newJson = safeParseObject(newText);\n if (!newJson) return [];\n const oldJson = safeParseObject(oldText) || {};\n\n const out = new Map<string, DeterministicPkgRequest>();\n for (const field of PKG_JSON_DEP_FIELDS) {\n const oldDeps = (oldJson[field] && typeof oldJson[field] === 'object') ? oldJson[field] : {};\n const newDeps = (newJson[field] && typeof newJson[field] === 'object') ? newJson[field] : {};\n for (const [rawName, version] of Object.entries(newDeps)) {\n if (typeof version !== 'string') continue;\n if (oldDeps[rawName] === version) continue;\n const spec = version.trim();\n if (\n spec.startsWith('file:') || spec.startsWith('link:') ||\n spec.startsWith('http') || spec.startsWith('git') ||\n spec.startsWith('workspace:') || spec.startsWith('catalog:') ||\n spec.startsWith('npm:') ||\n spec.startsWith('./') || spec.startsWith('../') ||\n spec === '' || spec === '*'\n ) continue;\n const name = rawName.toLowerCase();\n out.set(name, {\n name,\n version: concretePin(spec) ?? '*',\n ecosystem: 'npm',\n });\n }\n }\n return [...out.values()];\n}\n\n/**\n * Parse registry installs from a shell command without LLM/network.\n * Unpinned versions use '*' so OSV scans the full advisory history.\n */\nexport function extractDeterministicPkgRequests(command: string): DeterministicPkgRequest[] {\n const merged = new Map<string, DeterministicPkgRequest>();\n for (const r of segmentCommand(command).flatMap(extractSegment)) {\n if (r.source !== 'registry') continue;\n const osvEco = ECO_TO_OSV[r.ecosystem] ?? ECO_TO_OSV[r.ecosystem.toLowerCase()] ?? null;\n if (!osvEco) continue;\n const name = normalizeName(r.name, osvEco);\n if (!name) continue;\n const key = osvEco + '|' + name.toLowerCase();\n const version = concretePin(r.versionSpec) ?? '*';\n const prev = merged.get(key);\n if (!prev || (prev.version === '*' && version !== '*')) {\n merged.set(key, { name, version, ecosystem: osvEco });\n }\n }\n return [...merged.values()];\n}\n", "utf-8");
8187
- const hooksPkgPath = join8(HOOKS_DIR, "package.json");
8188
- writeFileSync7(hooksPkgPath, JSON.stringify({
8189
- name: "synkro-hooks",
8190
- private: true,
8191
- type: "module",
8192
- dependencies: { "shell-quote": "^1.8.1" }
8193
- }, null, 2) + "\n");
8194
- const bunInstall = spawnSync3("bun", ["install"], { cwd: HOOKS_DIR, encoding: "utf-8" });
8195
- if (bunInstall.status !== 0) {
8196
- console.warn(" \u26A0 Could not install hook dependencies (shell-quote): " + (bunInstall.stderr || bunInstall.stdout || "").slice(0, 200));
8197
- }
8198
8187
  chmodSync2(bashScriptPath, 493);
8199
8188
  chmodSync2(bashFollowupScriptPath, 493);
8200
8189
  chmodSync2(editPrecheckScriptPath, 493);
@@ -8261,7 +8250,7 @@ function writeConfigEnv(opts) {
8261
8250
  `SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
8262
8251
  `SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
8263
8252
  `SYNKRO_INFERENCE=${shellQuoteSingle(safeInference)}`,
8264
- `SYNKRO_VERSION=${shellQuoteSingle("1.6.39")}`
8253
+ `SYNKRO_VERSION=${shellQuoteSingle("1.6.41")}`
8265
8254
  ];
8266
8255
  if (safeSynkroBin) lines.push(`SYNKRO_CLI_BIN=${shellQuoteSingle(safeSynkroBin)}`);
8267
8256
  if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
@@ -11117,27 +11106,31 @@ async function updateCommand() {
11117
11106
  async function restartCommand(rest = []) {
11118
11107
  assertDockerAvailable();
11119
11108
  const cfg = resolveWorkerConfig(rest);
11120
- if (cfg.explicit) {
11121
- console.log(`Synkro: restarting server (${cfg.claudeWorkers} claude + ${cfg.cursorWorkers} cursor)
11122
- `);
11123
- await dockerUpdate({ claudeWorkers: cfg.claudeWorkers, cursorWorkers: cfg.cursorWorkers, connectedRepo: resolveConnectedRepo() });
11124
- const ready = await waitForContainerReady(6e4);
11125
- if (!ready) {
11126
- console.error("\n\u26A0 container did not pass /healthz within 60s");
11127
- process.exit(1);
11109
+ let claudeWorkers = cfg.claudeWorkers;
11110
+ let cursorWorkers = cfg.cursorWorkers;
11111
+ if (!cfg.explicit) {
11112
+ const reconciled = reconcileHarness();
11113
+ if (reconciled) {
11114
+ claudeWorkers = reconciled.claudeWorkers;
11115
+ cursorWorkers = reconciled.cursorWorkers;
11128
11116
  }
11129
- console.log("\nServer restarted successfully.");
11130
- return;
11131
11117
  }
11132
- console.log("Synkro: restarting server\n");
11133
- const result = await dockerSafeRestart();
11134
- if (!result.ok) {
11135
- if (!result.stop.ok) console.error("\nStop phase failed.");
11136
- if (!result.start.ok) console.error(`
11137
- Start phase failed: ${result.start.error}`);
11118
+ console.log(`Synkro: restarting server (${claudeWorkers} claude + ${cursorWorkers} cursor)
11119
+ `);
11120
+ await dockerUpdate({ claudeWorkers, cursorWorkers, connectedRepo: resolveConnectedRepo() });
11121
+ const ready = await waitForContainerReady(6e4);
11122
+ if (!ready) {
11123
+ console.error("\n\u26A0 container did not pass /healthz within 60s");
11138
11124
  process.exit(1);
11139
11125
  }
11140
11126
  console.log("\nServer restarted successfully.");
11127
+ const workersUp = await waitForWorkersReady(3e4);
11128
+ if (workersUp) {
11129
+ console.log("\u2713 workers ready");
11130
+ await syncSkillFiles();
11131
+ } else {
11132
+ console.warn("\u26A0 workers did not register within 30s \u2014 skill sync skipped");
11133
+ }
11141
11134
  }
11142
11135
  var init_lifecycle = __esm({
11143
11136
  "cli/commands/lifecycle.ts"() {
@@ -11320,7 +11313,7 @@ var args = process.argv.slice(2);
11320
11313
  var cmd = args[0] || "";
11321
11314
  var subArgs = args.slice(1);
11322
11315
  function printVersion() {
11323
- console.log("1.6.39");
11316
+ console.log("1.6.41");
11324
11317
  }
11325
11318
  function printHelp2() {
11326
11319
  console.log(`Synkro CLI \u2014 runtime safety for AI coding agents