@synkro-sh/cli 1.2.6 → 1.3.2
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 +1030 -386
- package/dist/bootstrap.js.map +1 -1
- package/package.json +1 -1
package/dist/bootstrap.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
var __defProp = Object.defineProperty;
|
|
3
3
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
5
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
6
|
+
}) : x)(function(x) {
|
|
7
|
+
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
8
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
9
|
+
});
|
|
4
10
|
var __esm = (fn, res) => function __init() {
|
|
5
11
|
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
6
12
|
};
|
|
@@ -96,7 +102,7 @@ function removeSynkroEntries(events, eventName) {
|
|
|
96
102
|
if (!Array.isArray(arr)) return;
|
|
97
103
|
events[eventName] = arr.filter((entry) => !isSynkroEntry(entry));
|
|
98
104
|
}
|
|
99
|
-
function installCCHooks(settingsPath,
|
|
105
|
+
function installCCHooks(settingsPath, config2) {
|
|
100
106
|
const settings = readSettings(settingsPath);
|
|
101
107
|
settings.hooks = settings.hooks ?? {};
|
|
102
108
|
removeSynkroEntries(settings.hooks, "PreToolUse");
|
|
@@ -109,11 +115,11 @@ function installCCHooks(settingsPath, config) {
|
|
|
109
115
|
settings.hooks.SessionEnd = settings.hooks.SessionEnd ?? [];
|
|
110
116
|
settings.hooks.SessionStart = settings.hooks.SessionStart ?? [];
|
|
111
117
|
settings.hooks.PreToolUse.push({
|
|
112
|
-
matcher: "Bash",
|
|
118
|
+
matcher: "Bash|Read|Grep|Glob",
|
|
113
119
|
hooks: [
|
|
114
120
|
{
|
|
115
121
|
type: "command",
|
|
116
|
-
command:
|
|
122
|
+
command: config2.bashJudgeScriptPath,
|
|
117
123
|
timeout: 15
|
|
118
124
|
}
|
|
119
125
|
],
|
|
@@ -124,7 +130,7 @@ function installCCHooks(settingsPath, config) {
|
|
|
124
130
|
hooks: [
|
|
125
131
|
{
|
|
126
132
|
type: "command",
|
|
127
|
-
command:
|
|
133
|
+
command: config2.editPrecheckScriptPath,
|
|
128
134
|
timeout: 15
|
|
129
135
|
}
|
|
130
136
|
],
|
|
@@ -135,7 +141,7 @@ function installCCHooks(settingsPath, config) {
|
|
|
135
141
|
hooks: [
|
|
136
142
|
{
|
|
137
143
|
type: "command",
|
|
138
|
-
command:
|
|
144
|
+
command: config2.editCaptureScriptPath,
|
|
139
145
|
timeout: 20
|
|
140
146
|
}
|
|
141
147
|
],
|
|
@@ -146,7 +152,7 @@ function installCCHooks(settingsPath, config) {
|
|
|
146
152
|
hooks: [
|
|
147
153
|
{
|
|
148
154
|
type: "command",
|
|
149
|
-
command:
|
|
155
|
+
command: config2.bashFollowupScriptPath
|
|
150
156
|
}
|
|
151
157
|
],
|
|
152
158
|
[SYNKRO_MARKER]: true
|
|
@@ -155,7 +161,7 @@ function installCCHooks(settingsPath, config) {
|
|
|
155
161
|
hooks: [
|
|
156
162
|
{
|
|
157
163
|
type: "command",
|
|
158
|
-
command:
|
|
164
|
+
command: config2.stopSummaryScriptPath
|
|
159
165
|
}
|
|
160
166
|
],
|
|
161
167
|
[SYNKRO_MARKER]: true
|
|
@@ -164,7 +170,7 @@ function installCCHooks(settingsPath, config) {
|
|
|
164
170
|
hooks: [
|
|
165
171
|
{
|
|
166
172
|
type: "command",
|
|
167
|
-
command:
|
|
173
|
+
command: config2.sessionStartScriptPath
|
|
168
174
|
}
|
|
169
175
|
],
|
|
170
176
|
[SYNKRO_MARKER]: true
|
|
@@ -232,50 +238,50 @@ function readClaudeJson() {
|
|
|
232
238
|
throw new Error(`Failed to parse ${CC_CONFIG_PATH}: ${err.message}`);
|
|
233
239
|
}
|
|
234
240
|
}
|
|
235
|
-
function writeClaudeJsonAtomic(
|
|
241
|
+
function writeClaudeJsonAtomic(config2) {
|
|
236
242
|
mkdirSync2(dirname2(CC_CONFIG_PATH), { recursive: true });
|
|
237
243
|
const tmpPath = `${CC_CONFIG_PATH}.synkro.tmp`;
|
|
238
|
-
writeFileSync2(tmpPath, JSON.stringify(
|
|
244
|
+
writeFileSync2(tmpPath, JSON.stringify(config2, null, 2) + "\n", "utf-8");
|
|
239
245
|
renameSync2(tmpPath, CC_CONFIG_PATH);
|
|
240
246
|
}
|
|
241
247
|
function installMcpConfig(opts) {
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
for (const [name, entry] of Object.entries(
|
|
245
|
-
if (entry?.[SYNKRO_MARKER2] === true) delete
|
|
248
|
+
const config2 = readClaudeJson();
|
|
249
|
+
config2.mcpServers = config2.mcpServers ?? {};
|
|
250
|
+
for (const [name, entry] of Object.entries(config2.mcpServers)) {
|
|
251
|
+
if (entry?.[SYNKRO_MARKER2] === true) delete config2.mcpServers[name];
|
|
246
252
|
}
|
|
247
253
|
const url = `${opts.gatewayUrl.replace(/\/$/, "")}/api/v1/mcp/guardrails`;
|
|
248
|
-
|
|
254
|
+
config2.mcpServers[SYNKRO_SERVER_NAME] = {
|
|
249
255
|
type: "http",
|
|
250
256
|
url,
|
|
251
257
|
headers: { Authorization: `Bearer ${opts.bearerToken}` },
|
|
252
258
|
[SYNKRO_MARKER2]: true
|
|
253
259
|
};
|
|
254
|
-
writeClaudeJsonAtomic(
|
|
260
|
+
writeClaudeJsonAtomic(config2);
|
|
255
261
|
return { path: CC_CONFIG_PATH, url };
|
|
256
262
|
}
|
|
257
263
|
function uninstallMcpConfig() {
|
|
258
264
|
if (!existsSync3(CC_CONFIG_PATH)) return false;
|
|
259
|
-
const
|
|
260
|
-
if (!
|
|
265
|
+
const config2 = readClaudeJson();
|
|
266
|
+
if (!config2.mcpServers || Object.keys(config2.mcpServers).length === 0) return false;
|
|
261
267
|
let removed = false;
|
|
262
|
-
for (const [name, entry] of Object.entries(
|
|
268
|
+
for (const [name, entry] of Object.entries(config2.mcpServers)) {
|
|
263
269
|
if (entry?.[SYNKRO_MARKER2] === true) {
|
|
264
|
-
delete
|
|
270
|
+
delete config2.mcpServers[name];
|
|
265
271
|
removed = true;
|
|
266
272
|
}
|
|
267
273
|
}
|
|
268
274
|
if (!removed) return false;
|
|
269
|
-
if (Object.keys(
|
|
270
|
-
writeClaudeJsonAtomic(
|
|
275
|
+
if (Object.keys(config2.mcpServers).length === 0) delete config2.mcpServers;
|
|
276
|
+
writeClaudeJsonAtomic(config2);
|
|
271
277
|
return true;
|
|
272
278
|
}
|
|
273
279
|
function inspectMcpConfig() {
|
|
274
280
|
if (!existsSync3(CC_CONFIG_PATH)) {
|
|
275
281
|
return { installed: false, configPath: CC_CONFIG_PATH };
|
|
276
282
|
}
|
|
277
|
-
const
|
|
278
|
-
const entry =
|
|
283
|
+
const config2 = readClaudeJson();
|
|
284
|
+
const entry = config2.mcpServers?.[SYNKRO_SERVER_NAME];
|
|
279
285
|
if (!entry || entry[SYNKRO_MARKER2] !== true) {
|
|
280
286
|
return { installed: false, configPath: CC_CONFIG_PATH };
|
|
281
287
|
}
|
|
@@ -336,14 +342,30 @@ if [ -z "$PAYLOAD" ]; then
|
|
|
336
342
|
exit 0
|
|
337
343
|
fi
|
|
338
344
|
|
|
339
|
-
#
|
|
345
|
+
# Translate tool calls into a command string for the judge
|
|
340
346
|
TOOL_NAME=$(echo "$PAYLOAD" | jq -r '.tool_name // empty' 2>/dev/null)
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
+
case "$TOOL_NAME" in
|
|
348
|
+
Bash)
|
|
349
|
+
COMMAND=$(echo "$PAYLOAD" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
350
|
+
;;
|
|
351
|
+
Read)
|
|
352
|
+
FILE_PATH=$(echo "$PAYLOAD" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
|
|
353
|
+
COMMAND="cat \${FILE_PATH}"
|
|
354
|
+
;;
|
|
355
|
+
Grep)
|
|
356
|
+
PATTERN=$(echo "$PAYLOAD" | jq -r '.tool_input.pattern // empty' 2>/dev/null)
|
|
357
|
+
GREP_PATH=$(echo "$PAYLOAD" | jq -r '.tool_input.path // "."' 2>/dev/null)
|
|
358
|
+
COMMAND="grep -r '\${PATTERN}' \${GREP_PATH}"
|
|
359
|
+
;;
|
|
360
|
+
Glob)
|
|
361
|
+
PATTERN=$(echo "$PAYLOAD" | jq -r '.tool_input.pattern // empty' 2>/dev/null)
|
|
362
|
+
COMMAND="find . -name '\${PATTERN}'"
|
|
363
|
+
;;
|
|
364
|
+
*)
|
|
365
|
+
echo '{}'
|
|
366
|
+
exit 0
|
|
367
|
+
;;
|
|
368
|
+
esac
|
|
347
369
|
if [ -z "$COMMAND" ]; then
|
|
348
370
|
echo '{}'
|
|
349
371
|
exit 0
|
|
@@ -364,7 +386,7 @@ TRANSCRIPT_PATH=$(echo "$PAYLOAD" | jq -r '.transcript_path // empty' 2>/dev/nul
|
|
|
364
386
|
SESSION_ID=$(echo "$PAYLOAD" | jq -r '.session_id // empty' 2>/dev/null)
|
|
365
387
|
TOOL_USE_ID=$(echo "$PAYLOAD" | jq -r '.tool_use_id // empty' 2>/dev/null)
|
|
366
388
|
CWD=$(echo "$PAYLOAD" | jq -r '.cwd // empty' 2>/dev/null)
|
|
367
|
-
TOOL_INPUT=$(echo "$PAYLOAD" | jq -c '.tool_input // {}' 2>/dev/null)
|
|
389
|
+
TOOL_INPUT=$(echo "$PAYLOAD" | jq -c --arg cmd "$COMMAND" '.tool_input // {} | . + {command: $cmd}' 2>/dev/null)
|
|
368
390
|
# Detect git remote origin \u2192 repo identity (e.g. "owner/repo")
|
|
369
391
|
GIT_REPO=""
|
|
370
392
|
if command -v git >/dev/null 2>&1; then
|
|
@@ -384,11 +406,9 @@ if [ "\${SYNKRO_HEADLESS:-0}" = "1" ]; then IS_HEADLESS=1; fi
|
|
|
384
406
|
|
|
385
407
|
USER_INTENT=""
|
|
386
408
|
RECENT_USER_MESSAGES="[]"
|
|
409
|
+
RECENT_MESSAGES="[]"
|
|
387
410
|
RECENT_ACTIONS="[]"
|
|
388
411
|
if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
|
|
389
|
-
# Last 5 user-role messages, oldest first. Lets the grader see consent
|
|
390
|
-
# that carried over from a recent prior turn \u2014 saying "i consent" two
|
|
391
|
-
# turns ago should not require re-prompting on this turn's command.
|
|
392
412
|
RECENT_USER_MESSAGES=$(tail -400 "$TRANSCRIPT_PATH" | jq -c -s '
|
|
393
413
|
[.[]
|
|
394
414
|
| select(.type == "user")
|
|
@@ -399,16 +419,78 @@ if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
|
|
|
399
419
|
| select(. != null and . != "")
|
|
400
420
|
] | .[-5:]' 2>/dev/null || echo "[]")
|
|
401
421
|
USER_INTENT=$(echo "$RECENT_USER_MESSAGES" | jq -r '.[-1] // ""' 2>/dev/null || echo "")
|
|
402
|
-
#
|
|
403
|
-
|
|
422
|
+
# Interleaved assistant+user messages \u2014 lets the grader see what question
|
|
423
|
+
# each "yes" was answering (assistant text before user reply).
|
|
424
|
+
RECENT_MESSAGES=$(tail -400 "$TRANSCRIPT_PATH" | jq -c -s '
|
|
404
425
|
[.[]
|
|
426
|
+
| select(.type == "assistant" or .type == "user")
|
|
427
|
+
| {
|
|
428
|
+
role: .type,
|
|
429
|
+
text: (
|
|
430
|
+
if .type == "assistant" then
|
|
431
|
+
[.message.content[]? | select(type == "object" and .type == "text") | .text // ""] | join(" ") | .[0:500]
|
|
432
|
+
else
|
|
433
|
+
(.message.content
|
|
434
|
+
| if type == "string" then .[0:500]
|
|
435
|
+
else ([.[]? | if type == "string" then . elif (type == "object" and .type == "text") then (.text // "") else "" end] | join(" ") | .[0:500])
|
|
436
|
+
end)
|
|
437
|
+
end
|
|
438
|
+
)
|
|
439
|
+
}
|
|
440
|
+
| select(.text != "" and .text != null and (.text | length) > 0)
|
|
441
|
+
] | .[-10:]' 2>/dev/null || echo "[]")
|
|
442
|
+
# Recent agent actions (last 5 tool_use blocks paired with results)
|
|
443
|
+
RECENT_ACTIONS=$(tail -400 "$TRANSCRIPT_PATH" | jq -c -s '
|
|
444
|
+
# tool_result blocks live in USER messages (Anthropic API format)
|
|
445
|
+
([ .[]
|
|
446
|
+
| select(.type == "user")
|
|
447
|
+
| .message.content[]?
|
|
448
|
+
| select(type == "object" and .type == "tool_result")
|
|
449
|
+
| { (.tool_use_id): (.content // "" | tostring | .[0:300]) }
|
|
450
|
+
] | add // {}) as $results
|
|
451
|
+
|
|
|
452
|
+
[ .[]
|
|
405
453
|
| select(.type == "assistant")
|
|
406
454
|
| .message.content[]?
|
|
407
455
|
| select(.type == "tool_use")
|
|
408
|
-
| {
|
|
456
|
+
| {
|
|
457
|
+
tool: .name,
|
|
458
|
+
input: (.input // {} | tostring | .[0:200]),
|
|
459
|
+
result: ($results[.id] // null)
|
|
460
|
+
}
|
|
409
461
|
] | .[-5:]' 2>/dev/null || echo "[]")
|
|
410
462
|
fi
|
|
411
463
|
|
|
464
|
+
CC_MODEL=""
|
|
465
|
+
CC_USAGE="{}"
|
|
466
|
+
if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
|
|
467
|
+
_LAST_ASSISTANT=$(tail -50 "$TRANSCRIPT_PATH" | jq -c 'select(.type == "assistant")' 2>/dev/null | tail -1)
|
|
468
|
+
if [ -n "$_LAST_ASSISTANT" ]; then
|
|
469
|
+
CC_MODEL=$(echo "$_LAST_ASSISTANT" | jq -r '.message.model // empty' 2>/dev/null)
|
|
470
|
+
CC_USAGE=$(echo "$_LAST_ASSISTANT" | jq -c '{
|
|
471
|
+
input_tokens: .message.usage.input_tokens,
|
|
472
|
+
output_tokens: .message.usage.output_tokens,
|
|
473
|
+
cache_creation_input_tokens: .message.usage.cache_creation_input_tokens,
|
|
474
|
+
cache_read_input_tokens: .message.usage.cache_read_input_tokens,
|
|
475
|
+
service_tier: .message.usage.service_tier,
|
|
476
|
+
speed: .message.usage.speed
|
|
477
|
+
}' 2>/dev/null || echo "{}")
|
|
478
|
+
fi
|
|
479
|
+
fi
|
|
480
|
+
|
|
481
|
+
# Extract session summary from CC compaction (free broad context)
|
|
482
|
+
SESSION_SUMMARY=""
|
|
483
|
+
if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
|
|
484
|
+
_SUMMARY_LINE=$(grep -n '"This session is being continued' "$TRANSCRIPT_PATH" 2>/dev/null | tail -1 | cut -d: -f1)
|
|
485
|
+
if [ -n "$_SUMMARY_LINE" ]; then
|
|
486
|
+
SESSION_SUMMARY=$(sed -n "\${_SUMMARY_LINE}p" "$TRANSCRIPT_PATH" | jq -r '
|
|
487
|
+
.message.content
|
|
488
|
+
| if type == "string" then .[0:2000]
|
|
489
|
+
else ([.[]? | if type == "string" then . elif (type == "object" and .type == "text") then (.text // "") else "" end] | join(" ") | .[0:2000])
|
|
490
|
+
end' 2>/dev/null || echo "")
|
|
491
|
+
fi
|
|
492
|
+
fi
|
|
493
|
+
|
|
412
494
|
# Build POST body \u2014 always emit all fields (use null for empty optionals)
|
|
413
495
|
# Earlier version used \`select(length > 0)\` which made the entire object
|
|
414
496
|
# evaluate to nothing when any optional was empty. Don't do that.
|
|
@@ -416,21 +498,29 @@ BODY=$(jq -n \\
|
|
|
416
498
|
--argjson tool_input "$TOOL_INPUT" \\
|
|
417
499
|
--arg user_intent "$USER_INTENT" \\
|
|
418
500
|
--argjson recent_user_messages "$RECENT_USER_MESSAGES" \\
|
|
501
|
+
--argjson recent_messages "$RECENT_MESSAGES" \\
|
|
419
502
|
--argjson recent_actions "$RECENT_ACTIONS" \\
|
|
420
503
|
--arg session_id "$SESSION_ID" \\
|
|
421
504
|
--arg tool_use_id "$TOOL_USE_ID" \\
|
|
422
505
|
--arg cwd "$CWD" \\
|
|
423
506
|
--arg repo "$GIT_REPO" \\
|
|
507
|
+
--arg cc_model "$CC_MODEL" \\
|
|
508
|
+
--argjson cc_usage "$CC_USAGE" \\
|
|
509
|
+
--arg session_summary "$SESSION_SUMMARY" \\
|
|
424
510
|
'{
|
|
425
511
|
kind: "bash_judge",
|
|
426
512
|
tool_input: $tool_input,
|
|
427
513
|
user_intent: (if ($user_intent | length) > 0 then $user_intent else null end),
|
|
428
514
|
recent_user_messages: $recent_user_messages,
|
|
515
|
+
recent_messages: $recent_messages,
|
|
429
516
|
recent_actions: $recent_actions,
|
|
430
517
|
session_id: (if ($session_id | length) > 0 then $session_id else null end),
|
|
431
518
|
tool_use_id: (if ($tool_use_id | length) > 0 then $tool_use_id else null end),
|
|
432
519
|
cwd: (if ($cwd | length) > 0 then $cwd else null end),
|
|
433
|
-
repo: (if ($repo | length) > 0 then $repo else null end)
|
|
520
|
+
repo: (if ($repo | length) > 0 then $repo else null end),
|
|
521
|
+
cc_model: (if ($cc_model | length) > 0 then $cc_model else null end),
|
|
522
|
+
cc_usage: $cc_usage,
|
|
523
|
+
session_summary: (if ($session_summary | length) > 0 then $session_summary else null end)
|
|
434
524
|
}')
|
|
435
525
|
|
|
436
526
|
# Helper: refresh JWT via /api/auth/refresh and rewrite credentials.json.
|
|
@@ -546,23 +636,15 @@ if [ -z "$VERDICT" ]; then
|
|
|
546
636
|
fi
|
|
547
637
|
|
|
548
638
|
# Parse verdict \u2014 fail open on any parse error
|
|
549
|
-
SEVERITY=$(echo "$VERDICT" | jq -r '.severity // "
|
|
639
|
+
SEVERITY=$(echo "$VERDICT" | jq -r '.severity // "audit"' 2>/dev/null)
|
|
550
640
|
VERDICT_KIND=$(echo "$VERDICT" | jq -r '.verdict // "warn"' 2>/dev/null)
|
|
551
641
|
REASONING=$(echo "$VERDICT" | jq -r '.reasoning // "matched dangerous-verb regex"' 2>/dev/null)
|
|
552
642
|
ALTERNATIVE=$(echo "$VERDICT" | jq -r '.alternative // ""' 2>/dev/null)
|
|
553
643
|
CATEGORY=$(echo "$VERDICT" | jq -r '.category // "destructive_command"' 2>/dev/null)
|
|
554
644
|
|
|
555
645
|
# Severity-driven surfacing:
|
|
556
|
-
#
|
|
557
|
-
#
|
|
558
|
-
# medium \u2192 silent allow (echo {}) \u2014 Cerebras saw it, judged the
|
|
559
|
-
# intent fits, no surface
|
|
560
|
-
# low \u2192 silent allow (echo {}) \u2014 same
|
|
561
|
-
#
|
|
562
|
-
# The grader is fully context-aware now (intent + recent_actions + shape
|
|
563
|
-
# labels), so its severity grade is trustworthy. Low/medium decisions don't
|
|
564
|
-
# need to interrupt the user \u2014 surfacing them creates alert fatigue and
|
|
565
|
-
# trains the user to click-through on warnings that turn out to be benign.
|
|
646
|
+
# block \u2192 permissionDecision: "ask" (interactive) or "deny" (headless)
|
|
647
|
+
# audit \u2192 silent allow \u2014 logged but no interruption
|
|
566
648
|
|
|
567
649
|
ALT_SUFFIX=""
|
|
568
650
|
if [ -n "$ALTERNATIVE" ] && [ "$ALTERNATIVE" != "null" ]; then
|
|
@@ -570,26 +652,9 @@ if [ -n "$ALTERNATIVE" ] && [ "$ALTERNATIVE" != "null" ]; then
|
|
|
570
652
|
fi
|
|
571
653
|
|
|
572
654
|
case "$SEVERITY" in
|
|
573
|
-
|
|
574
|
-
synkro_log "bashGuard $CMD_SHORT \u2192 BLOCKED ($CATEGORY)"
|
|
575
|
-
PERMISSION_REASON="[synkro] BLOCKED \u2014 \${REASONING}\${ALT_SUFFIX}"
|
|
576
|
-
ADDITIONAL_CTX="Synkro safety judge (severity: critical, category: \${CATEGORY}).\${ALT_SUFFIX}"
|
|
577
|
-
jq -n \\
|
|
578
|
-
--arg ctx "$ADDITIONAL_CTX" \\
|
|
579
|
-
--arg reason "$PERMISSION_REASON" \\
|
|
580
|
-
'{
|
|
581
|
-
hookSpecificOutput: {
|
|
582
|
-
hookEventName: "PreToolUse",
|
|
583
|
-
permissionDecision: "deny",
|
|
584
|
-
permissionDecisionReason: $reason,
|
|
585
|
-
additionalContext: $ctx
|
|
586
|
-
}
|
|
587
|
-
}'
|
|
588
|
-
;;
|
|
589
|
-
high)
|
|
590
|
-
synkro_log "bashGuard $CMD_SHORT \u2192 FLAGGED ($CATEGORY)"
|
|
655
|
+
block)
|
|
591
656
|
PERMISSION_REASON="[synkro] \${REASONING}\${ALT_SUFFIX}"
|
|
592
|
-
ADDITIONAL_CTX="Synkro safety judge (severity:
|
|
657
|
+
ADDITIONAL_CTX="Synkro safety judge (severity: \${SEVERITY}, category: \${CATEGORY}). Reasoning: \${REASONING}.\${ALT_SUFFIX}"
|
|
593
658
|
if [ "$IS_HEADLESS" = "1" ]; then DECISION="deny"; else DECISION="ask"; fi
|
|
594
659
|
jq -n \\
|
|
595
660
|
--arg ctx "$ADDITIONAL_CTX" \\
|
|
@@ -604,7 +669,7 @@ case "$SEVERITY" in
|
|
|
604
669
|
}
|
|
605
670
|
}'
|
|
606
671
|
;;
|
|
607
|
-
|
|
672
|
+
audit)
|
|
608
673
|
synkro_log "bashGuard $CMD_SHORT \u2192 pass ($CATEGORY): $REASONING"
|
|
609
674
|
case "$CATEGORY" in
|
|
610
675
|
trivial_utility)
|
|
@@ -615,6 +680,21 @@ case "$SEVERITY" in
|
|
|
615
680
|
jq -n --arg m "[synkro] bashGuard \u2192 pass ($CATEGORY): $REASONING" '{systemMessage: $m}' ;;
|
|
616
681
|
esac
|
|
617
682
|
;;
|
|
683
|
+
*)
|
|
684
|
+
synkro_log "bashGuard $CMD_SHORT \u2192 UNEXPECTED SEVERITY ($SEVERITY), blocking by default"
|
|
685
|
+
if [ "$IS_HEADLESS" = "1" ]; then DECISION="deny"; else DECISION="ask"; fi
|
|
686
|
+
jq -n \\
|
|
687
|
+
--arg decision "$DECISION" \\
|
|
688
|
+
--arg reason "[synkro] unexpected severity '\${SEVERITY}' \u2014 blocking by default. Please email team@synkro.sh to report this issue." \\
|
|
689
|
+
'{
|
|
690
|
+
hookSpecificOutput: {
|
|
691
|
+
hookEventName: "PreToolUse",
|
|
692
|
+
permissionDecision: $decision,
|
|
693
|
+
permissionDecisionReason: $reason,
|
|
694
|
+
additionalContext: "Synkro safety judge returned an unexpected severity value. This command has been blocked as a precaution. Please email team@synkro.sh with details of the command you were running."
|
|
695
|
+
}
|
|
696
|
+
}'
|
|
697
|
+
;;
|
|
618
698
|
esac
|
|
619
699
|
|
|
620
700
|
exit 0
|
|
@@ -983,8 +1063,14 @@ if [ "$DECISION" = "deny" ]; then
|
|
|
983
1063
|
synkro_log "editGuard $FILE_SHORT \u2192 BLOCKED: $DENY_REASON"
|
|
984
1064
|
echo "$RESP"
|
|
985
1065
|
else
|
|
986
|
-
|
|
987
|
-
|
|
1066
|
+
VERDICT_REASON=$(echo "$RESP" | jq -r '.reason // empty' 2>/dev/null)
|
|
1067
|
+
if [ -n "$VERDICT_REASON" ]; then
|
|
1068
|
+
synkro_log "editGuard $FILE_SHORT \u2192 pass: $VERDICT_REASON"
|
|
1069
|
+
RESP_WITH_MSG=$(echo "$RESP" | jq --arg m "[synkro] editGuard $FILE_SHORT \u2192 pass: $VERDICT_REASON" '. + {systemMessage: $m}')
|
|
1070
|
+
else
|
|
1071
|
+
synkro_log "editGuard $FILE_SHORT \u2192 pass"
|
|
1072
|
+
RESP_WITH_MSG=$(echo "$RESP" | jq --arg m "[synkro] editGuard $FILE_SHORT \u2192 pass" '. + {systemMessage: $m}')
|
|
1073
|
+
fi
|
|
988
1074
|
echo "$RESP_WITH_MSG"
|
|
989
1075
|
fi
|
|
990
1076
|
|
|
@@ -1232,8 +1318,13 @@ if [ "$OK" = "false" ] && [ -n "$REASON" ]; then
|
|
|
1232
1318
|
exit 0
|
|
1233
1319
|
fi
|
|
1234
1320
|
|
|
1235
|
-
|
|
1236
|
-
|
|
1321
|
+
if [ -n "$REASON" ]; then
|
|
1322
|
+
synkro_log "editScan $BASENAME \u2192 pass ($CATEGORY): $REASON"
|
|
1323
|
+
jq -n --arg m "[synkro] editScan $BASENAME \u2192 pass ($CATEGORY): $REASON" '{systemMessage: $m}'
|
|
1324
|
+
else
|
|
1325
|
+
synkro_log "editScan $BASENAME \u2192 pass"
|
|
1326
|
+
jq -n --arg m "[synkro] editScan $BASENAME \u2192 pass" '{systemMessage: $m}'
|
|
1327
|
+
fi
|
|
1237
1328
|
exit 0
|
|
1238
1329
|
`;
|
|
1239
1330
|
CC_STOP_SUMMARY_SCRIPT = `#!/bin/bash
|
|
@@ -1335,8 +1426,17 @@ if [ -z "$CWD" ]; then
|
|
|
1335
1426
|
exit 0
|
|
1336
1427
|
fi
|
|
1337
1428
|
|
|
1429
|
+
GIT_REPO=""
|
|
1430
|
+
if command -v git >/dev/null 2>&1; then
|
|
1431
|
+
_REMOTE=$(git -C "\${CWD:-.}" remote get-url origin 2>/dev/null || true)
|
|
1432
|
+
if [ -n "$_REMOTE" ]; then
|
|
1433
|
+
GIT_REPO=$(echo "$_REMOTE" | sed -E 's|^git@[^:]+:||; s|^https?://[^/]+/||; s|\\.git$||')
|
|
1434
|
+
fi
|
|
1435
|
+
fi
|
|
1436
|
+
|
|
1338
1437
|
RESP=$(curl -sS -G "\${GATEWAY_URL}/api/v1/cli/session-context" \\
|
|
1339
1438
|
--data-urlencode "cwd=$CWD" \\
|
|
1439
|
+
--data-urlencode "repo=$GIT_REPO" \\
|
|
1340
1440
|
-H "Authorization: Bearer $JWT" \\
|
|
1341
1441
|
--max-time 2 2>/dev/null || echo "")
|
|
1342
1442
|
|
|
@@ -2075,12 +2175,64 @@ function getAccessToken() {
|
|
|
2075
2175
|
const creds = loadCredentials();
|
|
2076
2176
|
return creds?.access_token || null;
|
|
2077
2177
|
}
|
|
2178
|
+
function isTokenExpired() {
|
|
2179
|
+
const creds = loadCredentials();
|
|
2180
|
+
if (!creds) return true;
|
|
2181
|
+
try {
|
|
2182
|
+
const decoded = jwt.decode(creds.access_token);
|
|
2183
|
+
if (!decoded?.exp) return true;
|
|
2184
|
+
const expiresAt = decoded.exp * 1e3;
|
|
2185
|
+
const buffer = 5 * 60 * 1e3;
|
|
2186
|
+
return Date.now() > expiresAt - buffer;
|
|
2187
|
+
} catch {
|
|
2188
|
+
return true;
|
|
2189
|
+
}
|
|
2190
|
+
}
|
|
2191
|
+
async function refreshToken() {
|
|
2192
|
+
const creds = loadCredentials();
|
|
2193
|
+
if (!creds?.refresh_token) return false;
|
|
2194
|
+
try {
|
|
2195
|
+
const response = await fetch(`${SYNKRO_WEB_AUTH_URL}/api/auth/refresh`, {
|
|
2196
|
+
method: "POST",
|
|
2197
|
+
headers: { "Content-Type": "application/json" },
|
|
2198
|
+
body: JSON.stringify({ refresh_token: creds.refresh_token })
|
|
2199
|
+
});
|
|
2200
|
+
if (!response.ok) return false;
|
|
2201
|
+
const data = await response.json();
|
|
2202
|
+
if (data.access_token) {
|
|
2203
|
+
saveCredentials({
|
|
2204
|
+
access_token: data.access_token,
|
|
2205
|
+
refresh_token: data.refresh_token || creds.refresh_token
|
|
2206
|
+
});
|
|
2207
|
+
return true;
|
|
2208
|
+
}
|
|
2209
|
+
return false;
|
|
2210
|
+
} catch {
|
|
2211
|
+
return false;
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
2214
|
+
async function ensureValidToken() {
|
|
2215
|
+
if (!isAuthenticated()) return false;
|
|
2216
|
+
if (isTokenExpired()) {
|
|
2217
|
+
if (!refreshPromise) {
|
|
2218
|
+
refreshPromise = refreshToken().finally(() => {
|
|
2219
|
+
refreshPromise = null;
|
|
2220
|
+
});
|
|
2221
|
+
}
|
|
2222
|
+
const refreshed = await refreshPromise;
|
|
2223
|
+
if (!refreshed) {
|
|
2224
|
+
clearCredentials();
|
|
2225
|
+
return false;
|
|
2226
|
+
}
|
|
2227
|
+
}
|
|
2228
|
+
return true;
|
|
2229
|
+
}
|
|
2078
2230
|
function clearCredentials() {
|
|
2079
2231
|
if (existsSync4(AUTH_FILE)) {
|
|
2080
2232
|
unlinkSync2(AUTH_FILE);
|
|
2081
2233
|
}
|
|
2082
2234
|
}
|
|
2083
|
-
var PORT, RAW_WEB_AUTH_URL, SYNKRO_WEB_AUTH_URL, AUTH_FILE, ERROR_HTML;
|
|
2235
|
+
var PORT, RAW_WEB_AUTH_URL, SYNKRO_WEB_AUTH_URL, AUTH_FILE, ERROR_HTML, refreshPromise;
|
|
2084
2236
|
var init_stub = __esm({
|
|
2085
2237
|
"cli/auth/stub.ts"() {
|
|
2086
2238
|
"use strict";
|
|
@@ -2128,147 +2280,550 @@ var init_stub = __esm({
|
|
|
2128
2280
|
</body>
|
|
2129
2281
|
</html>
|
|
2130
2282
|
`;
|
|
2283
|
+
refreshPromise = null;
|
|
2131
2284
|
}
|
|
2132
2285
|
});
|
|
2133
2286
|
|
|
2134
|
-
// cli/
|
|
2135
|
-
var
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
});
|
|
2140
|
-
import { existsSync as existsSync5, mkdirSync as mkdirSync4, writeFileSync as writeFileSync4, chmodSync, readFileSync as readFileSync4 } from "fs";
|
|
2141
|
-
import { homedir as homedir4 } from "os";
|
|
2142
|
-
import { join as join4 } from "path";
|
|
2143
|
-
function sanitizeGatewayCandidate(raw) {
|
|
2144
|
-
if (!raw) return void 0;
|
|
2145
|
-
return /^https?:\/\//.test(raw) ? raw : void 0;
|
|
2146
|
-
}
|
|
2147
|
-
function parseArgs(argv) {
|
|
2148
|
-
const opts = {};
|
|
2149
|
-
for (const a of argv) {
|
|
2150
|
-
if (a.startsWith("--api-key=")) opts.apiKey = a.slice("--api-key=".length);
|
|
2151
|
-
else if (a.startsWith("--gateway=")) opts.gatewayUrl = a.slice("--gateway=".length);
|
|
2152
|
-
else if (a === "--skip-auth") opts.skipAuth = true;
|
|
2153
|
-
else if (a === "--no-mcp") opts.noMcp = true;
|
|
2154
|
-
else if (a === "--force" || a === "-f") opts.force = true;
|
|
2287
|
+
// cli/auth/index.ts
|
|
2288
|
+
var init_auth = __esm({
|
|
2289
|
+
"cli/auth/index.ts"() {
|
|
2290
|
+
"use strict";
|
|
2291
|
+
init_stub();
|
|
2155
2292
|
}
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2293
|
+
});
|
|
2294
|
+
|
|
2295
|
+
// cli/api/projects.ts
|
|
2296
|
+
import { config } from "dotenv";
|
|
2297
|
+
async function callApi(method, endpoint, body) {
|
|
2298
|
+
if (!API_URL) {
|
|
2299
|
+
throw new Error("SYNKRO_CRUD_URL (or SYNKRO_API_URL) is not set. Add it to your .env file.");
|
|
2159
2300
|
}
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
mkdirSync4(HOOKS_DIR, { recursive: true });
|
|
2165
|
-
mkdirSync4(BIN_DIR, { recursive: true });
|
|
2166
|
-
}
|
|
2167
|
-
function writeGraderDaemon() {
|
|
2168
|
-
writeFileSync4(GRADER_DAEMON_PATH, GRADER_DAEMON_PY, "utf-8");
|
|
2169
|
-
chmodSync(GRADER_DAEMON_PATH, 493);
|
|
2170
|
-
writeFileSync4(GRADER_PRIMER_EDIT_PATH, GRADER_PRIMER_EDIT, "utf-8");
|
|
2171
|
-
chmodSync(GRADER_PRIMER_EDIT_PATH, 420);
|
|
2172
|
-
writeFileSync4(GRADER_PRIMER_BASH_PATH, GRADER_PRIMER_BASH, "utf-8");
|
|
2173
|
-
chmodSync(GRADER_PRIMER_BASH_PATH, 420);
|
|
2174
|
-
}
|
|
2175
|
-
function writeHookScripts() {
|
|
2176
|
-
const bashScriptPath = join4(HOOKS_DIR, "cc-bash-judge.sh");
|
|
2177
|
-
const bashFollowupScriptPath = join4(HOOKS_DIR, "cc-bash-followup.sh");
|
|
2178
|
-
const editCaptureScriptPath = join4(HOOKS_DIR, "cc-edit-capture.sh");
|
|
2179
|
-
const editPrecheckScriptPath = join4(HOOKS_DIR, "cc-edit-precheck.sh");
|
|
2180
|
-
const stopSummaryScriptPath = join4(HOOKS_DIR, "cc-stop-summary.sh");
|
|
2181
|
-
const sessionStartScriptPath = join4(HOOKS_DIR, "cc-session-start.sh");
|
|
2182
|
-
writeFileSync4(bashScriptPath, CC_BASH_JUDGE_SCRIPT, "utf-8");
|
|
2183
|
-
writeFileSync4(bashFollowupScriptPath, CC_BASH_FOLLOWUP_SCRIPT, "utf-8");
|
|
2184
|
-
writeFileSync4(editCaptureScriptPath, CC_EDIT_CAPTURE_SCRIPT, "utf-8");
|
|
2185
|
-
writeFileSync4(editPrecheckScriptPath, CC_EDIT_PRECHECK_SCRIPT, "utf-8");
|
|
2186
|
-
writeFileSync4(stopSummaryScriptPath, CC_STOP_SUMMARY_SCRIPT, "utf-8");
|
|
2187
|
-
writeFileSync4(sessionStartScriptPath, CC_SESSION_START_SCRIPT, "utf-8");
|
|
2188
|
-
chmodSync(bashScriptPath, 493);
|
|
2189
|
-
chmodSync(bashFollowupScriptPath, 493);
|
|
2190
|
-
chmodSync(editCaptureScriptPath, 493);
|
|
2191
|
-
chmodSync(editPrecheckScriptPath, 493);
|
|
2192
|
-
chmodSync(stopSummaryScriptPath, 493);
|
|
2193
|
-
chmodSync(sessionStartScriptPath, 493);
|
|
2194
|
-
return {
|
|
2195
|
-
bashScript: bashScriptPath,
|
|
2196
|
-
bashFollowupScript: bashFollowupScriptPath,
|
|
2197
|
-
editCaptureScript: editCaptureScriptPath,
|
|
2198
|
-
editPrecheckScript: editPrecheckScriptPath,
|
|
2199
|
-
stopSummaryScript: stopSummaryScriptPath,
|
|
2200
|
-
sessionStartScript: sessionStartScriptPath
|
|
2301
|
+
const url = `${API_URL}${endpoint}`;
|
|
2302
|
+
const accessToken = getAccessToken();
|
|
2303
|
+
const headers = {
|
|
2304
|
+
"Content-Type": "application/json"
|
|
2201
2305
|
};
|
|
2306
|
+
if (accessToken) {
|
|
2307
|
+
headers["Authorization"] = `Bearer ${accessToken}`;
|
|
2308
|
+
}
|
|
2309
|
+
const response = await fetch(url, {
|
|
2310
|
+
method,
|
|
2311
|
+
headers,
|
|
2312
|
+
body: body ? JSON.stringify(body) : void 0
|
|
2313
|
+
});
|
|
2314
|
+
if (!response.ok) {
|
|
2315
|
+
const error = await response.json().catch(() => ({ detail: response.statusText }));
|
|
2316
|
+
throw new Error(error.detail || `API error: ${response.status}`);
|
|
2317
|
+
}
|
|
2318
|
+
return response.json();
|
|
2202
2319
|
}
|
|
2203
|
-
function
|
|
2204
|
-
|
|
2205
|
-
|
|
2320
|
+
async function createProject(name, repos) {
|
|
2321
|
+
const body = { name };
|
|
2322
|
+
if (repos && repos.length > 0) body.repos = repos;
|
|
2323
|
+
return callApi("POST", "/projects", body);
|
|
2206
2324
|
}
|
|
2207
|
-
function
|
|
2208
|
-
return
|
|
2325
|
+
async function listProjects() {
|
|
2326
|
+
return callApi("GET", "/projects");
|
|
2209
2327
|
}
|
|
2210
|
-
function
|
|
2211
|
-
|
|
2212
|
-
const safeGateway = sanitizeConfigValue(opts.gatewayUrl);
|
|
2213
|
-
const safeUserId = sanitizeConfigValue(opts.userId);
|
|
2214
|
-
const safeOrgId = sanitizeConfigValue(opts.orgId);
|
|
2215
|
-
const safeEmail = sanitizeConfigValue(opts.email);
|
|
2216
|
-
const safeTier = sanitizeConfigValue(opts.tier ?? "pro", 32);
|
|
2217
|
-
const lines = [
|
|
2218
|
-
"# Synkro CLI config (managed by synkro install)",
|
|
2219
|
-
"# JWT auth \u2014 the hook scripts read SYNKRO_CREDENTIALS_PATH at runtime",
|
|
2220
|
-
"# and send Authorization: Bearer <access_token> on every gateway call.",
|
|
2221
|
-
`SYNKRO_GATEWAY_URL=${shellQuoteSingle(safeGateway)}`,
|
|
2222
|
-
`SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
|
|
2223
|
-
`SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
|
|
2224
|
-
`SYNKRO_VERSION=${shellQuoteSingle("1.2.6")}`
|
|
2225
|
-
];
|
|
2226
|
-
if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
|
|
2227
|
-
if (safeOrgId) lines.push(`SYNKRO_ORG_ID=${shellQuoteSingle(safeOrgId)}`);
|
|
2228
|
-
if (safeEmail) lines.push(`SYNKRO_EMAIL=${shellQuoteSingle(safeEmail)}`);
|
|
2229
|
-
lines.push("");
|
|
2230
|
-
writeFileSync4(CONFIG_PATH, lines.join("\n"), "utf-8");
|
|
2231
|
-
chmodSync(CONFIG_PATH, 384);
|
|
2328
|
+
async function unlinkRepo(projectId, repoId) {
|
|
2329
|
+
return callApi("DELETE", `/projects/${projectId}/repos/${repoId}`);
|
|
2232
2330
|
}
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
const proto = parsed.protocol;
|
|
2241
|
-
const host = parsed.hostname;
|
|
2242
|
-
if (proto !== "http:" && proto !== "https:") {
|
|
2243
|
-
throw new Error(`Gateway URL must be http(s); got ${proto}`);
|
|
2244
|
-
}
|
|
2245
|
-
const isLocalhost = host === "localhost" || host === "127.0.0.1" || host === "::1";
|
|
2246
|
-
const isSynkro = host === "synkro.sh" || host.endsWith(".synkro.sh");
|
|
2247
|
-
if (proto === "http:" && !isLocalhost) {
|
|
2248
|
-
throw new Error(`Gateway URL must be HTTPS for non-localhost hosts; got ${gatewayUrl}`);
|
|
2331
|
+
var API_URL;
|
|
2332
|
+
var init_projects = __esm({
|
|
2333
|
+
"cli/api/projects.ts"() {
|
|
2334
|
+
"use strict";
|
|
2335
|
+
init_auth();
|
|
2336
|
+
config({ quiet: true });
|
|
2337
|
+
API_URL = process.env.SYNKRO_CRUD_URL || process.env.SYNKRO_API_URL;
|
|
2249
2338
|
}
|
|
2250
|
-
|
|
2251
|
-
|
|
2339
|
+
});
|
|
2340
|
+
|
|
2341
|
+
// cli/installer/workflowTemplate.ts
|
|
2342
|
+
var SYNKRO_WORKFLOW_YAML, WORKFLOW_PATH;
|
|
2343
|
+
var init_workflowTemplate = __esm({
|
|
2344
|
+
"cli/installer/workflowTemplate.ts"() {
|
|
2345
|
+
"use strict";
|
|
2346
|
+
SYNKRO_WORKFLOW_YAML = `name: Synkro Security Review
|
|
2347
|
+
on:
|
|
2348
|
+
pull_request:
|
|
2349
|
+
types: [opened, synchronize, reopened]
|
|
2350
|
+
|
|
2351
|
+
jobs:
|
|
2352
|
+
scan:
|
|
2353
|
+
runs-on: ubuntu-latest
|
|
2354
|
+
permissions:
|
|
2355
|
+
contents: read
|
|
2356
|
+
pull-requests: write
|
|
2357
|
+
checks: write
|
|
2358
|
+
steps:
|
|
2359
|
+
- uses: actions/checkout@v4
|
|
2360
|
+
with:
|
|
2361
|
+
fetch-depth: 0
|
|
2362
|
+
|
|
2363
|
+
- name: Cache npm globals
|
|
2364
|
+
id: cache-npm-global
|
|
2365
|
+
uses: actions/cache@v4
|
|
2366
|
+
with:
|
|
2367
|
+
path: ~/.npm-global
|
|
2368
|
+
key: synkro-cli-\${{ runner.os }}-v1
|
|
2369
|
+
|
|
2370
|
+
- name: Install Synkro CLI + Claude Code CLI
|
|
2371
|
+
run: |
|
|
2372
|
+
npm config set prefix ~/.npm-global
|
|
2373
|
+
npm install -g @synkro-sh/cli @anthropic-ai/claude-code
|
|
2374
|
+
echo "~/.npm-global/bin" >> $GITHUB_PATH
|
|
2375
|
+
|
|
2376
|
+
- name: Run Synkro PR scan
|
|
2377
|
+
run: synkro-cli scan-pr
|
|
2378
|
+
env:
|
|
2379
|
+
CLAUDE_CODE_OAUTH_TOKEN: \${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
|
2380
|
+
SYNKRO_API_KEY: \${{ secrets.SYNKRO_API_KEY }}
|
|
2381
|
+
GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
|
2382
|
+
SYNKRO_PR_NUMBER: \${{ github.event.pull_request.number }}
|
|
2383
|
+
SYNKRO_REPO: \${{ github.repository }}
|
|
2384
|
+
SYNKRO_SHA: \${{ github.event.pull_request.head.sha }}
|
|
2385
|
+
SYNKRO_GATEWAY_URL: \${{ vars.SYNKRO_GATEWAY_URL || 'https://api.synkro.sh' }}
|
|
2386
|
+
`;
|
|
2387
|
+
WORKFLOW_PATH = ".github/workflows/synkro.yml";
|
|
2252
2388
|
}
|
|
2389
|
+
});
|
|
2390
|
+
|
|
2391
|
+
// cli/installer/githubSetup.ts
|
|
2392
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync4, writeFileSync as writeFileSync4 } from "fs";
|
|
2393
|
+
import { join as join4 } from "path";
|
|
2394
|
+
async function encryptSecret(publicKeyBase64, secret) {
|
|
2395
|
+
const sodium = await import("libsodium-wrappers").then((m) => m.default ?? m);
|
|
2396
|
+
await sodium.ready;
|
|
2397
|
+
const keyBytes = sodium.from_base64(publicKeyBase64, sodium.base64_variants.ORIGINAL);
|
|
2398
|
+
const messageBytes = sodium.from_string(secret);
|
|
2399
|
+
const encryptedBytes = sodium.crypto_box_seal(messageBytes, keyBytes);
|
|
2400
|
+
return sodium.to_base64(encryptedBytes, sodium.base64_variants.ORIGINAL);
|
|
2253
2401
|
}
|
|
2254
|
-
function
|
|
2255
|
-
const
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
if (!
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2402
|
+
async function getRepoPublicKey(opts, owner, repo) {
|
|
2403
|
+
const url = `https://api.github.com/repos/${owner}/${repo}/actions/secrets/public-key`;
|
|
2404
|
+
const resp = await fetch(url, {
|
|
2405
|
+
headers: {
|
|
2406
|
+
Authorization: `Bearer ${opts.token}`,
|
|
2407
|
+
Accept: "application/vnd.github+json",
|
|
2408
|
+
"X-GitHub-Api-Version": "2022-11-28"
|
|
2409
|
+
}
|
|
2410
|
+
});
|
|
2411
|
+
if (!resp.ok) {
|
|
2412
|
+
const text = await resp.text().catch(() => "");
|
|
2413
|
+
throw new Error(`GitHub API ${resp.status} fetching public key for ${owner}/${repo}: ${text.slice(0, 200)}`);
|
|
2414
|
+
}
|
|
2415
|
+
return await resp.json();
|
|
2416
|
+
}
|
|
2417
|
+
async function putRepoSecret(opts, owner, repo, secretName, secretValue, publicKey) {
|
|
2418
|
+
const encryptedValue = await encryptSecret(publicKey.key, secretValue);
|
|
2419
|
+
const url = `https://api.github.com/repos/${owner}/${repo}/actions/secrets/${encodeURIComponent(secretName)}`;
|
|
2420
|
+
const resp = await fetch(url, {
|
|
2421
|
+
method: "PUT",
|
|
2422
|
+
headers: {
|
|
2423
|
+
Authorization: `Bearer ${opts.token}`,
|
|
2424
|
+
Accept: "application/vnd.github+json",
|
|
2425
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
2426
|
+
"Content-Type": "application/json"
|
|
2427
|
+
},
|
|
2428
|
+
body: JSON.stringify({
|
|
2429
|
+
encrypted_value: encryptedValue,
|
|
2430
|
+
key_id: publicKey.key_id
|
|
2431
|
+
})
|
|
2432
|
+
});
|
|
2433
|
+
if (!resp.ok) {
|
|
2434
|
+
const text = await resp.text().catch(() => "");
|
|
2435
|
+
throw new Error(`GitHub API ${resp.status} setting secret ${secretName}: ${text.slice(0, 200)}`);
|
|
2436
|
+
}
|
|
2437
|
+
}
|
|
2438
|
+
async function listAccessibleRepos(opts) {
|
|
2439
|
+
const repos = [];
|
|
2440
|
+
let page = 1;
|
|
2441
|
+
while (page <= 5) {
|
|
2442
|
+
const url = `https://api.github.com/user/repos?per_page=100&page=${page}&affiliation=owner,collaborator`;
|
|
2443
|
+
const resp = await fetch(url, {
|
|
2444
|
+
headers: {
|
|
2445
|
+
Authorization: `Bearer ${opts.token}`,
|
|
2446
|
+
Accept: "application/vnd.github+json",
|
|
2447
|
+
"X-GitHub-Api-Version": "2022-11-28"
|
|
2448
|
+
}
|
|
2449
|
+
});
|
|
2450
|
+
if (!resp.ok) {
|
|
2451
|
+
throw new Error(`GitHub API ${resp.status} listing repos`);
|
|
2452
|
+
}
|
|
2453
|
+
const data = await resp.json();
|
|
2454
|
+
if (data.length === 0) break;
|
|
2455
|
+
for (const r of data) {
|
|
2456
|
+
repos.push({ owner: r.owner.login, repo: r.name, full_name: r.full_name });
|
|
2457
|
+
}
|
|
2458
|
+
if (data.length < 100) break;
|
|
2459
|
+
page++;
|
|
2460
|
+
}
|
|
2461
|
+
return repos;
|
|
2462
|
+
}
|
|
2463
|
+
async function pushSecretsToRepo(opts, owner, repo, secrets) {
|
|
2464
|
+
const pubkey = await getRepoPublicKey(opts, owner, repo);
|
|
2465
|
+
await putRepoSecret(opts, owner, repo, "CLAUDE_CODE_OAUTH_TOKEN", secrets.claudeCodeOauthToken, pubkey);
|
|
2466
|
+
await putRepoSecret(opts, owner, repo, "SYNKRO_API_KEY", secrets.synkroApiKey, pubkey);
|
|
2467
|
+
}
|
|
2468
|
+
function writeWorkflowFile(repoRootPath) {
|
|
2469
|
+
const workflowDir = join4(repoRootPath, ".github", "workflows");
|
|
2470
|
+
mkdirSync4(workflowDir, { recursive: true });
|
|
2471
|
+
const workflowFile = join4(workflowDir, "synkro.yml");
|
|
2472
|
+
writeFileSync4(workflowFile, SYNKRO_WORKFLOW_YAML, "utf-8");
|
|
2473
|
+
return workflowFile;
|
|
2474
|
+
}
|
|
2475
|
+
function findGitRoot(startCwd) {
|
|
2476
|
+
let cur = startCwd;
|
|
2477
|
+
while (cur && cur !== "/") {
|
|
2478
|
+
if (existsSync5(join4(cur, ".git"))) return cur;
|
|
2479
|
+
const parent = join4(cur, "..");
|
|
2480
|
+
if (parent === cur) break;
|
|
2481
|
+
cur = parent;
|
|
2482
|
+
}
|
|
2483
|
+
return null;
|
|
2484
|
+
}
|
|
2485
|
+
var SECRET_NAMES, WORKFLOW_RELATIVE_PATH;
|
|
2486
|
+
var init_githubSetup = __esm({
|
|
2487
|
+
"cli/installer/githubSetup.ts"() {
|
|
2488
|
+
"use strict";
|
|
2489
|
+
init_workflowTemplate();
|
|
2490
|
+
SECRET_NAMES = {
|
|
2491
|
+
CLAUDE_OAUTH: "CLAUDE_CODE_OAUTH_TOKEN",
|
|
2492
|
+
SYNKRO_API_KEY: "SYNKRO_API_KEY"
|
|
2493
|
+
};
|
|
2494
|
+
WORKFLOW_RELATIVE_PATH = WORKFLOW_PATH;
|
|
2495
|
+
}
|
|
2496
|
+
});
|
|
2497
|
+
|
|
2498
|
+
// cli/commands/repoConnect.ts
|
|
2499
|
+
import { execSync as execSync2 } from "child_process";
|
|
2500
|
+
import { createServer as createServer2 } from "http";
|
|
2501
|
+
import { createInterface } from "readline";
|
|
2502
|
+
function detectGitRepo() {
|
|
2503
|
+
try {
|
|
2504
|
+
const remoteUrl = execSync2("git remote get-url origin", { encoding: "utf-8", timeout: 5e3 }).trim();
|
|
2505
|
+
const match = remoteUrl.match(/(?:github\.com|gitlab\.com|bitbucket\.org)[:/](.+?)(?:\.git)?$/);
|
|
2506
|
+
if (!match) return null;
|
|
2507
|
+
const fullName = match[1];
|
|
2508
|
+
return { fullName, shortName: fullName.split("/").pop() || fullName };
|
|
2509
|
+
} catch {
|
|
2510
|
+
return null;
|
|
2511
|
+
}
|
|
2512
|
+
}
|
|
2513
|
+
function ask(rl, question) {
|
|
2514
|
+
return new Promise((resolve2) => rl.question(question, resolve2));
|
|
2515
|
+
}
|
|
2516
|
+
function waitForGithubToken() {
|
|
2517
|
+
return new Promise((resolve2, reject) => {
|
|
2518
|
+
const server = createServer2((req, res) => {
|
|
2519
|
+
if (req.method === "OPTIONS") {
|
|
2520
|
+
res.writeHead(204, {
|
|
2521
|
+
"Access-Control-Allow-Origin": SYNKRO_WEB_AUTH_URL2,
|
|
2522
|
+
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
|
2523
|
+
"Access-Control-Allow-Headers": "Content-Type"
|
|
2524
|
+
});
|
|
2525
|
+
res.end();
|
|
2526
|
+
return;
|
|
2527
|
+
}
|
|
2528
|
+
if (req.url !== "/auth" || req.method !== "POST") {
|
|
2529
|
+
res.writeHead(404);
|
|
2530
|
+
res.end();
|
|
2531
|
+
return;
|
|
2532
|
+
}
|
|
2533
|
+
let body = "";
|
|
2534
|
+
req.on("data", (chunk) => {
|
|
2535
|
+
body += chunk;
|
|
2536
|
+
});
|
|
2537
|
+
req.on("end", () => {
|
|
2538
|
+
try {
|
|
2539
|
+
const parsed = JSON.parse(body);
|
|
2540
|
+
if (!parsed.github_token) {
|
|
2541
|
+
res.writeHead(400, {
|
|
2542
|
+
"Content-Type": "application/json",
|
|
2543
|
+
"Access-Control-Allow-Origin": SYNKRO_WEB_AUTH_URL2
|
|
2544
|
+
});
|
|
2545
|
+
res.end(JSON.stringify({ error: "missing github_token" }));
|
|
2546
|
+
return;
|
|
2547
|
+
}
|
|
2548
|
+
res.writeHead(200, {
|
|
2549
|
+
"Content-Type": "application/json",
|
|
2550
|
+
"Access-Control-Allow-Origin": SYNKRO_WEB_AUTH_URL2
|
|
2551
|
+
});
|
|
2552
|
+
res.end(JSON.stringify({ ok: true }));
|
|
2553
|
+
setTimeout(() => server.close(), 200);
|
|
2554
|
+
resolve2(parsed.github_token);
|
|
2555
|
+
} catch {
|
|
2556
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2557
|
+
res.end(JSON.stringify({ error: "invalid json" }));
|
|
2558
|
+
}
|
|
2559
|
+
});
|
|
2560
|
+
});
|
|
2561
|
+
server.on("error", (err) => {
|
|
2562
|
+
if (err.code === "EADDRINUSE") {
|
|
2563
|
+
reject(new Error(`Port ${GITHUB_PORT} is in use. Close other processes and try again.`));
|
|
2564
|
+
} else {
|
|
2565
|
+
reject(err);
|
|
2566
|
+
}
|
|
2567
|
+
});
|
|
2568
|
+
server.listen(GITHUB_PORT);
|
|
2569
|
+
});
|
|
2570
|
+
}
|
|
2571
|
+
function openBrowser2(url) {
|
|
2572
|
+
const { execFile: execFile2 } = __require("child_process");
|
|
2573
|
+
const plat = process.platform;
|
|
2574
|
+
if (plat === "darwin") execFile2("open", [url]);
|
|
2575
|
+
else if (plat === "win32") execFile2("cmd", ["/c", "start", "", url]);
|
|
2576
|
+
else execFile2("xdg-open", [url]);
|
|
2577
|
+
}
|
|
2578
|
+
async function connectGithubAndSelectRepos() {
|
|
2579
|
+
const url = `${SYNKRO_WEB_AUTH_URL2}/cli-github?port=${GITHUB_PORT}`;
|
|
2580
|
+
console.log(" Opening browser for GitHub authorization...");
|
|
2581
|
+
openBrowser2(url);
|
|
2582
|
+
console.log(" Waiting for GitHub authorization...");
|
|
2583
|
+
const ghToken = await waitForGithubToken();
|
|
2584
|
+
console.log(" \u2713 GitHub connected\n");
|
|
2585
|
+
const repos = await listAccessibleRepos({ token: ghToken });
|
|
2586
|
+
if (repos.length === 0) {
|
|
2587
|
+
console.log(" No accessible repos found on GitHub.");
|
|
2588
|
+
return [];
|
|
2589
|
+
}
|
|
2590
|
+
console.log(` Found ${repos.length} repos:
|
|
2591
|
+
`);
|
|
2592
|
+
repos.forEach((r, i) => {
|
|
2593
|
+
console.log(` ${String(i + 1).padStart(3)}. ${r.full_name}`);
|
|
2594
|
+
});
|
|
2595
|
+
console.log();
|
|
2596
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
2597
|
+
try {
|
|
2598
|
+
const selection = await ask(rl, " Select repos (comma-separated numbers, e.g. 1,3,5): ");
|
|
2599
|
+
const indices = selection.split(",").map((s) => parseInt(s.trim(), 10) - 1).filter((n) => !isNaN(n) && n >= 0 && n < repos.length);
|
|
2600
|
+
if (indices.length === 0) {
|
|
2601
|
+
console.log(" No repos selected.");
|
|
2602
|
+
return [];
|
|
2603
|
+
}
|
|
2604
|
+
return indices.map((i) => ({ full_name: repos[i].full_name }));
|
|
2605
|
+
} finally {
|
|
2606
|
+
rl.close();
|
|
2607
|
+
}
|
|
2608
|
+
}
|
|
2609
|
+
async function promptRepoConnection() {
|
|
2610
|
+
const localRepo = detectGitRepo();
|
|
2611
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
2612
|
+
try {
|
|
2613
|
+
console.log("Connect repos to Synkro:\n");
|
|
2614
|
+
const options = [];
|
|
2615
|
+
if (localRepo) {
|
|
2616
|
+
options.push(`Link this repo (${localRepo.fullName})`);
|
|
2617
|
+
}
|
|
2618
|
+
options.push("Connect GitHub to select repos");
|
|
2619
|
+
options.push("Skip for now");
|
|
2620
|
+
options.forEach((opt, i) => {
|
|
2621
|
+
console.log(` ${i + 1}. ${opt}`);
|
|
2622
|
+
});
|
|
2623
|
+
console.log();
|
|
2624
|
+
const choice = await ask(rl, " Choose (number): ");
|
|
2625
|
+
const choiceNum = parseInt(choice.trim(), 10);
|
|
2626
|
+
console.log();
|
|
2627
|
+
rl.close();
|
|
2628
|
+
const localIdx = localRepo ? 1 : -1;
|
|
2629
|
+
const githubIdx = localRepo ? 2 : 1;
|
|
2630
|
+
const skipIdx = localRepo ? 3 : 2;
|
|
2631
|
+
if (choiceNum === localIdx && localRepo) {
|
|
2632
|
+
try {
|
|
2633
|
+
const existing = await listProjects();
|
|
2634
|
+
const alreadyLinked = existing.some(
|
|
2635
|
+
(p) => p.repos?.some((r) => r.full_name === localRepo.fullName)
|
|
2636
|
+
);
|
|
2637
|
+
if (!alreadyLinked) {
|
|
2638
|
+
await createProject(localRepo.shortName, [{ full_name: localRepo.fullName }]);
|
|
2639
|
+
console.log(` \u2713 Created project "${localRepo.shortName}" linked to ${localRepo.fullName}`);
|
|
2640
|
+
} else {
|
|
2641
|
+
console.log(` \u2713 ${localRepo.fullName} is already linked to a Synkro project.`);
|
|
2642
|
+
}
|
|
2643
|
+
} catch (err) {
|
|
2644
|
+
console.warn(` \u26A0 Could not link repo: ${err.message}`);
|
|
2645
|
+
}
|
|
2646
|
+
} else if (choiceNum === githubIdx) {
|
|
2647
|
+
const selectedRepos = await connectGithubAndSelectRepos();
|
|
2648
|
+
if (selectedRepos.length > 0) {
|
|
2649
|
+
try {
|
|
2650
|
+
const existing = await listProjects();
|
|
2651
|
+
const existingFullNames = new Set(
|
|
2652
|
+
existing.flatMap((p) => (p.repos || []).map((r) => r.full_name))
|
|
2653
|
+
);
|
|
2654
|
+
const newRepos = selectedRepos.filter((r) => !existingFullNames.has(r.full_name));
|
|
2655
|
+
if (newRepos.length === 0) {
|
|
2656
|
+
console.log(" \u2713 All selected repos are already linked.");
|
|
2657
|
+
} else {
|
|
2658
|
+
const projectName = newRepos.length === 1 ? newRepos[0].full_name.split("/").pop() || "Project" : "Multi-Repo Project";
|
|
2659
|
+
await createProject(projectName, newRepos);
|
|
2660
|
+
console.log(` \u2713 Linked ${newRepos.length} repo(s) to project "${projectName}"`);
|
|
2661
|
+
}
|
|
2662
|
+
} catch (err) {
|
|
2663
|
+
console.warn(` \u26A0 Could not link repos: ${err.message}`);
|
|
2664
|
+
}
|
|
2665
|
+
}
|
|
2666
|
+
} else if (choiceNum === skipIdx) {
|
|
2667
|
+
console.log(" Skipped. Run `synkro link` later to connect repos.");
|
|
2668
|
+
} else {
|
|
2669
|
+
console.log(" Invalid choice. Skipping repo connection.");
|
|
2670
|
+
}
|
|
2671
|
+
} catch {
|
|
2672
|
+
rl.close();
|
|
2673
|
+
}
|
|
2674
|
+
console.log();
|
|
2675
|
+
}
|
|
2676
|
+
var RAW_WEB_AUTH_URL2, SYNKRO_WEB_AUTH_URL2, GITHUB_PORT;
|
|
2677
|
+
var init_repoConnect = __esm({
|
|
2678
|
+
"cli/commands/repoConnect.ts"() {
|
|
2679
|
+
"use strict";
|
|
2680
|
+
init_projects();
|
|
2681
|
+
init_githubSetup();
|
|
2682
|
+
RAW_WEB_AUTH_URL2 = process.env.SYNKRO_WEB_AUTH_URL;
|
|
2683
|
+
SYNKRO_WEB_AUTH_URL2 = RAW_WEB_AUTH_URL2 && /^https?:\/\//.test(RAW_WEB_AUTH_URL2) ? RAW_WEB_AUTH_URL2 : "https://app.synkro.sh";
|
|
2684
|
+
GITHUB_PORT = 8101;
|
|
2685
|
+
}
|
|
2686
|
+
});
|
|
2687
|
+
|
|
2688
|
+
// cli/commands/install.ts
|
|
2689
|
+
var install_exports = {};
|
|
2690
|
+
__export(install_exports, {
|
|
2691
|
+
installCommand: () => installCommand,
|
|
2692
|
+
parseArgs: () => parseArgs
|
|
2693
|
+
});
|
|
2694
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync5, writeFileSync as writeFileSync5, chmodSync, readFileSync as readFileSync4, readdirSync } from "fs";
|
|
2695
|
+
import { homedir as homedir4 } from "os";
|
|
2696
|
+
import { join as join5 } from "path";
|
|
2697
|
+
import { execSync as execSync3 } from "child_process";
|
|
2698
|
+
function sanitizeGatewayCandidate(raw) {
|
|
2699
|
+
if (!raw) return void 0;
|
|
2700
|
+
return /^https?:\/\//.test(raw) ? raw : void 0;
|
|
2701
|
+
}
|
|
2702
|
+
function parseArgs(argv) {
|
|
2703
|
+
const opts = {};
|
|
2704
|
+
for (const a of argv) {
|
|
2705
|
+
if (a.startsWith("--api-key=")) opts.apiKey = a.slice("--api-key=".length);
|
|
2706
|
+
else if (a.startsWith("--gateway=")) opts.gatewayUrl = a.slice("--gateway=".length);
|
|
2707
|
+
else if (a === "--skip-auth") opts.skipAuth = true;
|
|
2708
|
+
else if (a === "--no-mcp") opts.noMcp = true;
|
|
2709
|
+
else if (a === "--force" || a === "-f") opts.force = true;
|
|
2710
|
+
}
|
|
2711
|
+
if (!opts.gatewayUrl) {
|
|
2712
|
+
const fromEnv = sanitizeGatewayCandidate(process.env.SYNKRO_GATEWAY_URL);
|
|
2713
|
+
if (fromEnv) opts.gatewayUrl = fromEnv;
|
|
2714
|
+
}
|
|
2715
|
+
return opts;
|
|
2716
|
+
}
|
|
2717
|
+
function ensureSynkroDir() {
|
|
2718
|
+
mkdirSync5(SYNKRO_DIR, { recursive: true });
|
|
2719
|
+
mkdirSync5(HOOKS_DIR, { recursive: true });
|
|
2720
|
+
mkdirSync5(BIN_DIR, { recursive: true });
|
|
2721
|
+
}
|
|
2722
|
+
function writeGraderDaemon() {
|
|
2723
|
+
writeFileSync5(GRADER_DAEMON_PATH, GRADER_DAEMON_PY, "utf-8");
|
|
2724
|
+
chmodSync(GRADER_DAEMON_PATH, 493);
|
|
2725
|
+
writeFileSync5(GRADER_PRIMER_EDIT_PATH, GRADER_PRIMER_EDIT, "utf-8");
|
|
2726
|
+
chmodSync(GRADER_PRIMER_EDIT_PATH, 420);
|
|
2727
|
+
writeFileSync5(GRADER_PRIMER_BASH_PATH, GRADER_PRIMER_BASH, "utf-8");
|
|
2728
|
+
chmodSync(GRADER_PRIMER_BASH_PATH, 420);
|
|
2729
|
+
}
|
|
2730
|
+
function writeHookScripts() {
|
|
2731
|
+
const bashScriptPath = join5(HOOKS_DIR, "cc-bash-judge.sh");
|
|
2732
|
+
const bashFollowupScriptPath = join5(HOOKS_DIR, "cc-bash-followup.sh");
|
|
2733
|
+
const editCaptureScriptPath = join5(HOOKS_DIR, "cc-edit-capture.sh");
|
|
2734
|
+
const editPrecheckScriptPath = join5(HOOKS_DIR, "cc-edit-precheck.sh");
|
|
2735
|
+
const stopSummaryScriptPath = join5(HOOKS_DIR, "cc-stop-summary.sh");
|
|
2736
|
+
const sessionStartScriptPath = join5(HOOKS_DIR, "cc-session-start.sh");
|
|
2737
|
+
writeFileSync5(bashScriptPath, CC_BASH_JUDGE_SCRIPT, "utf-8");
|
|
2738
|
+
writeFileSync5(bashFollowupScriptPath, CC_BASH_FOLLOWUP_SCRIPT, "utf-8");
|
|
2739
|
+
writeFileSync5(editCaptureScriptPath, CC_EDIT_CAPTURE_SCRIPT, "utf-8");
|
|
2740
|
+
writeFileSync5(editPrecheckScriptPath, CC_EDIT_PRECHECK_SCRIPT, "utf-8");
|
|
2741
|
+
writeFileSync5(stopSummaryScriptPath, CC_STOP_SUMMARY_SCRIPT, "utf-8");
|
|
2742
|
+
writeFileSync5(sessionStartScriptPath, CC_SESSION_START_SCRIPT, "utf-8");
|
|
2743
|
+
chmodSync(bashScriptPath, 493);
|
|
2744
|
+
chmodSync(bashFollowupScriptPath, 493);
|
|
2745
|
+
chmodSync(editCaptureScriptPath, 493);
|
|
2746
|
+
chmodSync(editPrecheckScriptPath, 493);
|
|
2747
|
+
chmodSync(stopSummaryScriptPath, 493);
|
|
2748
|
+
chmodSync(sessionStartScriptPath, 493);
|
|
2749
|
+
return {
|
|
2750
|
+
bashScript: bashScriptPath,
|
|
2751
|
+
bashFollowupScript: bashFollowupScriptPath,
|
|
2752
|
+
editCaptureScript: editCaptureScriptPath,
|
|
2753
|
+
editPrecheckScript: editPrecheckScriptPath,
|
|
2754
|
+
stopSummaryScript: stopSummaryScriptPath,
|
|
2755
|
+
sessionStartScript: sessionStartScriptPath
|
|
2756
|
+
};
|
|
2757
|
+
}
|
|
2758
|
+
function sanitizeConfigValue(raw, maxLen = 256) {
|
|
2759
|
+
if (!raw) return "";
|
|
2760
|
+
return raw.replace(/[^\x20-\x7E]/g, "").slice(0, maxLen);
|
|
2761
|
+
}
|
|
2762
|
+
function shellQuoteSingle(value) {
|
|
2763
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
2764
|
+
}
|
|
2765
|
+
function writeConfigEnv(opts) {
|
|
2766
|
+
const credsPath = join5(SYNKRO_DIR, "credentials.json");
|
|
2767
|
+
const safeGateway = sanitizeConfigValue(opts.gatewayUrl);
|
|
2768
|
+
const safeUserId = sanitizeConfigValue(opts.userId);
|
|
2769
|
+
const safeOrgId = sanitizeConfigValue(opts.orgId);
|
|
2770
|
+
const safeEmail = sanitizeConfigValue(opts.email);
|
|
2771
|
+
const safeTier = sanitizeConfigValue(opts.tier ?? "pro", 32);
|
|
2772
|
+
const lines = [
|
|
2773
|
+
"# Synkro CLI config (managed by synkro install)",
|
|
2774
|
+
"# JWT auth \u2014 the hook scripts read SYNKRO_CREDENTIALS_PATH at runtime",
|
|
2775
|
+
"# and send Authorization: Bearer <access_token> on every gateway call.",
|
|
2776
|
+
`SYNKRO_GATEWAY_URL=${shellQuoteSingle(safeGateway)}`,
|
|
2777
|
+
`SYNKRO_CREDENTIALS_PATH=${shellQuoteSingle(credsPath)}`,
|
|
2778
|
+
`SYNKRO_TIER=${shellQuoteSingle(safeTier)}`,
|
|
2779
|
+
`SYNKRO_VERSION=${shellQuoteSingle("1.3.2")}`
|
|
2780
|
+
];
|
|
2781
|
+
if (safeUserId) lines.push(`SYNKRO_USER_ID=${shellQuoteSingle(safeUserId)}`);
|
|
2782
|
+
if (safeOrgId) lines.push(`SYNKRO_ORG_ID=${shellQuoteSingle(safeOrgId)}`);
|
|
2783
|
+
if (safeEmail) lines.push(`SYNKRO_EMAIL=${shellQuoteSingle(safeEmail)}`);
|
|
2784
|
+
lines.push("");
|
|
2785
|
+
writeFileSync5(CONFIG_PATH, lines.join("\n"), "utf-8");
|
|
2786
|
+
chmodSync(CONFIG_PATH, 384);
|
|
2787
|
+
}
|
|
2788
|
+
function assertGatewayAllowed(gatewayUrl) {
|
|
2789
|
+
let parsed;
|
|
2790
|
+
try {
|
|
2791
|
+
parsed = new URL(gatewayUrl);
|
|
2792
|
+
} catch {
|
|
2793
|
+
throw new Error(`Invalid gateway URL: ${gatewayUrl}`);
|
|
2794
|
+
}
|
|
2795
|
+
const proto = parsed.protocol;
|
|
2796
|
+
const host = parsed.hostname;
|
|
2797
|
+
if (proto !== "http:" && proto !== "https:") {
|
|
2798
|
+
throw new Error(`Gateway URL must be http(s); got ${proto}`);
|
|
2799
|
+
}
|
|
2800
|
+
const isLocalhost = host === "localhost" || host === "127.0.0.1" || host === "::1";
|
|
2801
|
+
const isSynkro = host === "synkro.sh" || host.endsWith(".synkro.sh");
|
|
2802
|
+
if (proto === "http:" && !isLocalhost) {
|
|
2803
|
+
throw new Error(`Gateway URL must be HTTPS for non-localhost hosts; got ${gatewayUrl}`);
|
|
2804
|
+
}
|
|
2805
|
+
if (!isLocalhost && !isSynkro) {
|
|
2806
|
+
throw new Error(`Gateway host not in allowlist (synkro.sh or *.synkro.sh): ${host}`);
|
|
2807
|
+
}
|
|
2808
|
+
}
|
|
2809
|
+
function isAlreadyInstalled() {
|
|
2810
|
+
const requiredScripts = [
|
|
2811
|
+
join5(HOOKS_DIR, "cc-bash-judge.sh"),
|
|
2812
|
+
join5(HOOKS_DIR, "cc-bash-followup.sh"),
|
|
2813
|
+
join5(HOOKS_DIR, "cc-edit-precheck.sh"),
|
|
2814
|
+
join5(HOOKS_DIR, "cc-edit-capture.sh"),
|
|
2815
|
+
join5(HOOKS_DIR, "cc-stop-summary.sh"),
|
|
2816
|
+
join5(HOOKS_DIR, "cc-session-start.sh")
|
|
2817
|
+
];
|
|
2818
|
+
if (!requiredScripts.every((p) => existsSync6(p))) return false;
|
|
2819
|
+
if (!existsSync6(CONFIG_PATH)) return false;
|
|
2820
|
+
const settingsPath = join5(homedir4(), ".claude", "settings.json");
|
|
2821
|
+
if (!existsSync6(settingsPath)) return false;
|
|
2822
|
+
try {
|
|
2823
|
+
const settings = JSON.parse(readFileSync4(settingsPath, "utf-8"));
|
|
2824
|
+
const hooks = settings?.hooks;
|
|
2825
|
+
if (!hooks || typeof hooks !== "object") return false;
|
|
2826
|
+
const hasManaged = (kind) => Array.isArray(hooks[kind]) && hooks[kind].some((entry) => entry?.__synkro_managed__ === true);
|
|
2272
2827
|
if (!hasManaged("PreToolUse")) return false;
|
|
2273
2828
|
if (!hasManaged("PostToolUse")) return false;
|
|
2274
2829
|
if (!hasManaged("SessionEnd")) return false;
|
|
@@ -2324,6 +2879,7 @@ async function installCommand(opts = {}) {
|
|
|
2324
2879
|
console.error("No access token available after auth.");
|
|
2325
2880
|
process.exit(1);
|
|
2326
2881
|
}
|
|
2882
|
+
await promptRepoConnection();
|
|
2327
2883
|
const agents = detectAgents();
|
|
2328
2884
|
if (agents.length === 0) {
|
|
2329
2885
|
console.error("No AI coding agents detected. Install Claude Code first: https://docs.claude.com/claude-code");
|
|
@@ -2346,7 +2902,7 @@ async function installCommand(opts = {}) {
|
|
|
2346
2902
|
`);
|
|
2347
2903
|
writeGraderDaemon();
|
|
2348
2904
|
for (const mode of ["edit", "bash"]) {
|
|
2349
|
-
const pidFile =
|
|
2905
|
+
const pidFile = join5(SYNKRO_DIR, "daemon", mode, "daemon.pid");
|
|
2350
2906
|
try {
|
|
2351
2907
|
const pid = parseInt(readFileSync4(pidFile, "utf-8").trim(), 10);
|
|
2352
2908
|
if (pid > 0) {
|
|
@@ -2417,12 +2973,115 @@ async function installCommand(opts = {}) {
|
|
|
2417
2973
|
writeConfigEnv({ gatewayUrl, userId, orgId, email });
|
|
2418
2974
|
console.log(`Wrote config to ${CONFIG_PATH}
|
|
2419
2975
|
`);
|
|
2976
|
+
try {
|
|
2977
|
+
const repo = detectGitRepo2();
|
|
2978
|
+
if (repo) {
|
|
2979
|
+
const ingested = await ingestSessionTranscripts(gatewayUrl, token, repo);
|
|
2980
|
+
if (ingested > 0) {
|
|
2981
|
+
console.log(`Indexed ${ingested} session insights from Claude Code history for ${repo}.`);
|
|
2982
|
+
console.log(" This helps the safety judge understand your workflow.\n");
|
|
2983
|
+
}
|
|
2984
|
+
}
|
|
2985
|
+
} catch (err) {
|
|
2986
|
+
console.warn(` \u26A0 Session indexing skipped: ${err.message}
|
|
2987
|
+
`);
|
|
2988
|
+
}
|
|
2420
2989
|
console.log("\u2713 Synkro installed.");
|
|
2421
2990
|
console.log();
|
|
2422
2991
|
console.log("Next steps:");
|
|
2423
2992
|
console.log(" \u2022 synkro-cli setup-github (enable PR scanning)");
|
|
2424
2993
|
console.log(" \u2022 synkro-cli status (check what is configured)");
|
|
2425
2994
|
}
|
|
2995
|
+
function detectGitRepo2() {
|
|
2996
|
+
try {
|
|
2997
|
+
const remoteUrl = execSync3("git remote get-url origin", { encoding: "utf-8", timeout: 5e3 }).trim();
|
|
2998
|
+
const match = remoteUrl.match(/(?:github\.com|gitlab\.com|bitbucket\.org)[:/](.+?)(?:\.git)?$/);
|
|
2999
|
+
return match ? match[1] : null;
|
|
3000
|
+
} catch {
|
|
3001
|
+
return null;
|
|
3002
|
+
}
|
|
3003
|
+
}
|
|
3004
|
+
function getClaudeProjectsFolder() {
|
|
3005
|
+
const cwd = process.cwd();
|
|
3006
|
+
const sanitized = "-" + cwd.replace(/\//g, "-");
|
|
3007
|
+
const projectsDir = join5(homedir4(), ".claude", "projects", sanitized);
|
|
3008
|
+
return existsSync6(projectsDir) ? projectsDir : null;
|
|
3009
|
+
}
|
|
3010
|
+
function extractSessionInsights(projectsDir) {
|
|
3011
|
+
const insights = [];
|
|
3012
|
+
const files = readdirSync(projectsDir).filter((f) => f.endsWith(".jsonl"));
|
|
3013
|
+
for (const file of files) {
|
|
3014
|
+
const sessionId = file.replace(".jsonl", "");
|
|
3015
|
+
const filePath = join5(projectsDir, file);
|
|
3016
|
+
try {
|
|
3017
|
+
const content = readFileSync4(filePath, "utf-8");
|
|
3018
|
+
const lines = content.split("\n").filter(Boolean);
|
|
3019
|
+
for (let i = 0; i < lines.length; i++) {
|
|
3020
|
+
try {
|
|
3021
|
+
const entry = JSON.parse(lines[i]);
|
|
3022
|
+
if (entry.type === "user" && typeof entry.message?.content === "string" && entry.message.content.startsWith("This session is being continued")) {
|
|
3023
|
+
insights.push({
|
|
3024
|
+
session_id: sessionId,
|
|
3025
|
+
insight_type: "summary",
|
|
3026
|
+
content: entry.message.content.slice(0, 4e3),
|
|
3027
|
+
metadata: { source: "compaction_summary" }
|
|
3028
|
+
});
|
|
3029
|
+
}
|
|
3030
|
+
} catch {
|
|
3031
|
+
}
|
|
3032
|
+
}
|
|
3033
|
+
const userMessages = [];
|
|
3034
|
+
for (let i = lines.length - 1; i >= 0 && userMessages.length < 20; i--) {
|
|
3035
|
+
try {
|
|
3036
|
+
const entry = JSON.parse(lines[i]);
|
|
3037
|
+
if (entry.type === "user") {
|
|
3038
|
+
const text = typeof entry.message?.content === "string" ? entry.message.content : Array.isArray(entry.message?.content) ? entry.message.content.map((b) => b.text ?? b).filter((t) => typeof t === "string").join(" ") : null;
|
|
3039
|
+
if (text && text.length > 10 && text.length < 2e3 && !text.startsWith("This session is being continued")) {
|
|
3040
|
+
userMessages.push(text);
|
|
3041
|
+
}
|
|
3042
|
+
}
|
|
3043
|
+
} catch {
|
|
3044
|
+
}
|
|
3045
|
+
}
|
|
3046
|
+
for (const msg of userMessages.reverse()) {
|
|
3047
|
+
insights.push({
|
|
3048
|
+
session_id: sessionId,
|
|
3049
|
+
insight_type: "user_message",
|
|
3050
|
+
content: msg.slice(0, 2e3)
|
|
3051
|
+
});
|
|
3052
|
+
}
|
|
3053
|
+
} catch {
|
|
3054
|
+
}
|
|
3055
|
+
}
|
|
3056
|
+
return insights;
|
|
3057
|
+
}
|
|
3058
|
+
async function ingestSessionTranscripts(gatewayUrl, token, repo) {
|
|
3059
|
+
const projectsDir = getClaudeProjectsFolder();
|
|
3060
|
+
if (!projectsDir) return 0;
|
|
3061
|
+
const insights = extractSessionInsights(projectsDir);
|
|
3062
|
+
if (insights.length === 0) return 0;
|
|
3063
|
+
console.log(`Found ${insights.length} session insights from Claude Code history...`);
|
|
3064
|
+
let total = 0;
|
|
3065
|
+
for (let i = 0; i < insights.length; i += 100) {
|
|
3066
|
+
const batch = insights.slice(i, i + 100);
|
|
3067
|
+
try {
|
|
3068
|
+
const resp = await fetch(`${gatewayUrl}/api/v1/cli/ingest-sessions`, {
|
|
3069
|
+
method: "POST",
|
|
3070
|
+
headers: {
|
|
3071
|
+
"Authorization": `Bearer ${token}`,
|
|
3072
|
+
"Content-Type": "application/json"
|
|
3073
|
+
},
|
|
3074
|
+
body: JSON.stringify({ repo, sessions: batch })
|
|
3075
|
+
});
|
|
3076
|
+
if (resp.ok) {
|
|
3077
|
+
const result = await resp.json();
|
|
3078
|
+
total += result.ingested;
|
|
3079
|
+
}
|
|
3080
|
+
} catch {
|
|
3081
|
+
}
|
|
3082
|
+
}
|
|
3083
|
+
return total;
|
|
3084
|
+
}
|
|
2426
3085
|
var SYNKRO_DIR, HOOKS_DIR, BIN_DIR, CONFIG_PATH, GRADER_DAEMON_PATH, GRADER_PRIMER_EDIT_PATH, GRADER_PRIMER_BASH_PATH;
|
|
2427
3086
|
var init_install = __esm({
|
|
2428
3087
|
"cli/commands/install.ts"() {
|
|
@@ -2433,13 +3092,14 @@ var init_install = __esm({
|
|
|
2433
3092
|
init_hookScripts();
|
|
2434
3093
|
init_graderDaemon();
|
|
2435
3094
|
init_stub();
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
3095
|
+
init_repoConnect();
|
|
3096
|
+
SYNKRO_DIR = join5(homedir4(), ".synkro");
|
|
3097
|
+
HOOKS_DIR = join5(SYNKRO_DIR, "hooks");
|
|
3098
|
+
BIN_DIR = join5(SYNKRO_DIR, "bin");
|
|
3099
|
+
CONFIG_PATH = join5(SYNKRO_DIR, "config.env");
|
|
3100
|
+
GRADER_DAEMON_PATH = join5(BIN_DIR, "grader_daemon.py");
|
|
3101
|
+
GRADER_PRIMER_EDIT_PATH = join5(SYNKRO_DIR, "grader-primer-edit.txt");
|
|
3102
|
+
GRADER_PRIMER_BASH_PATH = join5(SYNKRO_DIR, "grader-primer-bash.txt");
|
|
2443
3103
|
}
|
|
2444
3104
|
});
|
|
2445
3105
|
|
|
@@ -2515,11 +3175,11 @@ var status_exports = {};
|
|
|
2515
3175
|
__export(status_exports, {
|
|
2516
3176
|
statusCommand: () => statusCommand
|
|
2517
3177
|
});
|
|
2518
|
-
import { existsSync as
|
|
3178
|
+
import { existsSync as existsSync7, readFileSync as readFileSync5 } from "fs";
|
|
2519
3179
|
import { homedir as homedir5 } from "os";
|
|
2520
|
-
import { join as
|
|
3180
|
+
import { join as join6 } from "path";
|
|
2521
3181
|
function readConfigEnv() {
|
|
2522
|
-
if (!
|
|
3182
|
+
if (!existsSync7(CONFIG_PATH2)) return {};
|
|
2523
3183
|
const out = {};
|
|
2524
3184
|
const raw = readFileSync5(CONFIG_PATH2, "utf-8");
|
|
2525
3185
|
for (const line of raw.split("\n")) {
|
|
@@ -2545,21 +3205,21 @@ function statusCommand() {
|
|
|
2545
3205
|
console.log("Authentication: \u2717 not logged in (run: synkro-cli login)");
|
|
2546
3206
|
}
|
|
2547
3207
|
console.log();
|
|
2548
|
-
const
|
|
3208
|
+
const config2 = readConfigEnv();
|
|
2549
3209
|
console.log("Config:");
|
|
2550
|
-
console.log(` gateway: ${
|
|
2551
|
-
console.log(` credentials: ${
|
|
2552
|
-
console.log(` tier: ${
|
|
3210
|
+
console.log(` gateway: ${config2.SYNKRO_GATEWAY_URL ?? "(unset)"}`);
|
|
3211
|
+
console.log(` credentials: ${config2.SYNKRO_CREDENTIALS_PATH ?? "(unset)"}`);
|
|
3212
|
+
console.log(` tier: ${config2.SYNKRO_TIER ?? "(unset)"}`);
|
|
2553
3213
|
const info2 = getUserInfo();
|
|
2554
|
-
const userId = info2?.id ??
|
|
2555
|
-
const tierCacheFile =
|
|
2556
|
-
let inferenceTier =
|
|
2557
|
-
if (!inferenceTier &&
|
|
3214
|
+
const userId = info2?.id ?? config2.SYNKRO_USER_ID ?? "default";
|
|
3215
|
+
const tierCacheFile = join6(SYNKRO_DIR2, `.tier-cache-${userId}`);
|
|
3216
|
+
let inferenceTier = config2.SYNKRO_INFERENCE_TIER || null;
|
|
3217
|
+
if (!inferenceTier && existsSync7(tierCacheFile)) {
|
|
2558
3218
|
inferenceTier = readFileSync5(tierCacheFile, "utf-8").trim() || null;
|
|
2559
3219
|
}
|
|
2560
3220
|
const tierLabel = inferenceTier === "fast" ? "'fast' (server-side grading)" : inferenceTier === "free" ? "'free' (local daemon grading)" : "(unknown \u2014 fires on next hook)";
|
|
2561
3221
|
console.log(` inference: ${tierLabel}`);
|
|
2562
|
-
console.log(` version: ${
|
|
3222
|
+
console.log(` version: ${config2.SYNKRO_VERSION ?? "(unset)"}`);
|
|
2563
3223
|
console.log();
|
|
2564
3224
|
const agents = detectAgents();
|
|
2565
3225
|
console.log("Detected agents:");
|
|
@@ -2582,19 +3242,19 @@ function statusCommand() {
|
|
|
2582
3242
|
}
|
|
2583
3243
|
}
|
|
2584
3244
|
console.log();
|
|
2585
|
-
const bashScript =
|
|
2586
|
-
const bashFollowupScript =
|
|
2587
|
-
const editPrecheckScript =
|
|
2588
|
-
const editCaptureScript =
|
|
2589
|
-
const stopSummaryScript =
|
|
2590
|
-
const sessionStartScript =
|
|
3245
|
+
const bashScript = join6(SYNKRO_DIR2, "hooks", "cc-bash-judge.sh");
|
|
3246
|
+
const bashFollowupScript = join6(SYNKRO_DIR2, "hooks", "cc-bash-followup.sh");
|
|
3247
|
+
const editPrecheckScript = join6(SYNKRO_DIR2, "hooks", "cc-edit-precheck.sh");
|
|
3248
|
+
const editCaptureScript = join6(SYNKRO_DIR2, "hooks", "cc-edit-capture.sh");
|
|
3249
|
+
const stopSummaryScript = join6(SYNKRO_DIR2, "hooks", "cc-stop-summary.sh");
|
|
3250
|
+
const sessionStartScript = join6(SYNKRO_DIR2, "hooks", "cc-session-start.sh");
|
|
2591
3251
|
console.log("Hook scripts:");
|
|
2592
|
-
console.log(` ${
|
|
2593
|
-
console.log(` ${
|
|
2594
|
-
console.log(` ${
|
|
2595
|
-
console.log(` ${
|
|
2596
|
-
console.log(` ${
|
|
2597
|
-
console.log(` ${
|
|
3252
|
+
console.log(` ${existsSync7(bashScript) ? "\u2713" : "\u2717"} ${bashScript}`);
|
|
3253
|
+
console.log(` ${existsSync7(bashFollowupScript) ? "\u2713" : "\u2717"} ${bashFollowupScript}`);
|
|
3254
|
+
console.log(` ${existsSync7(editPrecheckScript) ? "\u2713" : "\u2717"} ${editPrecheckScript}`);
|
|
3255
|
+
console.log(` ${existsSync7(editCaptureScript) ? "\u2713" : "\u2717"} ${editCaptureScript}`);
|
|
3256
|
+
console.log(` ${existsSync7(stopSummaryScript) ? "\u2713" : "\u2717"} ${stopSummaryScript}`);
|
|
3257
|
+
console.log(` ${existsSync7(sessionStartScript) ? "\u2713" : "\u2717"} ${sessionStartScript}`);
|
|
2598
3258
|
console.log();
|
|
2599
3259
|
const mcp = inspectMcpConfig();
|
|
2600
3260
|
console.log("Guardrails MCP server (Claude Code):");
|
|
@@ -2614,165 +3274,88 @@ var init_status = __esm({
|
|
|
2614
3274
|
init_agentDetect();
|
|
2615
3275
|
init_ccHookConfig();
|
|
2616
3276
|
init_mcpConfig();
|
|
2617
|
-
SYNKRO_DIR2 =
|
|
2618
|
-
CONFIG_PATH2 =
|
|
3277
|
+
SYNKRO_DIR2 = join6(homedir5(), ".synkro");
|
|
3278
|
+
CONFIG_PATH2 = join6(SYNKRO_DIR2, "config.env");
|
|
2619
3279
|
}
|
|
2620
3280
|
});
|
|
2621
3281
|
|
|
2622
|
-
// cli/
|
|
2623
|
-
var
|
|
2624
|
-
|
|
2625
|
-
|
|
3282
|
+
// cli/commands/link.ts
|
|
3283
|
+
var link_exports = {};
|
|
3284
|
+
__export(link_exports, {
|
|
3285
|
+
linkCommand: () => linkCommand
|
|
3286
|
+
});
|
|
3287
|
+
async function linkCommand() {
|
|
3288
|
+
if (!isAuthenticated()) {
|
|
3289
|
+
console.error("Not authenticated. Run `synkro install` or `synkro login` first.");
|
|
3290
|
+
process.exit(1);
|
|
3291
|
+
}
|
|
3292
|
+
await ensureValidToken();
|
|
3293
|
+
await promptRepoConnection();
|
|
3294
|
+
}
|
|
3295
|
+
var init_link = __esm({
|
|
3296
|
+
"cli/commands/link.ts"() {
|
|
2626
3297
|
"use strict";
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
pull_request:
|
|
2630
|
-
types: [opened, synchronize, reopened]
|
|
2631
|
-
|
|
2632
|
-
jobs:
|
|
2633
|
-
scan:
|
|
2634
|
-
runs-on: ubuntu-latest
|
|
2635
|
-
permissions:
|
|
2636
|
-
contents: read
|
|
2637
|
-
pull-requests: write
|
|
2638
|
-
checks: write
|
|
2639
|
-
steps:
|
|
2640
|
-
- uses: actions/checkout@v4
|
|
2641
|
-
with:
|
|
2642
|
-
fetch-depth: 0
|
|
2643
|
-
|
|
2644
|
-
- name: Cache npm globals
|
|
2645
|
-
id: cache-npm-global
|
|
2646
|
-
uses: actions/cache@v4
|
|
2647
|
-
with:
|
|
2648
|
-
path: ~/.npm-global
|
|
2649
|
-
key: synkro-cli-\${{ runner.os }}-v1
|
|
2650
|
-
|
|
2651
|
-
- name: Install Synkro CLI + Claude Code CLI
|
|
2652
|
-
run: |
|
|
2653
|
-
npm config set prefix ~/.npm-global
|
|
2654
|
-
npm install -g @synkro-sh/cli @anthropic-ai/claude-code
|
|
2655
|
-
echo "~/.npm-global/bin" >> $GITHUB_PATH
|
|
2656
|
-
|
|
2657
|
-
- name: Run Synkro PR scan
|
|
2658
|
-
run: synkro-cli scan-pr
|
|
2659
|
-
env:
|
|
2660
|
-
CLAUDE_CODE_OAUTH_TOKEN: \${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
|
2661
|
-
SYNKRO_API_KEY: \${{ secrets.SYNKRO_API_KEY }}
|
|
2662
|
-
GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
|
2663
|
-
SYNKRO_PR_NUMBER: \${{ github.event.pull_request.number }}
|
|
2664
|
-
SYNKRO_REPO: \${{ github.repository }}
|
|
2665
|
-
SYNKRO_SHA: \${{ github.event.pull_request.head.sha }}
|
|
2666
|
-
SYNKRO_GATEWAY_URL: \${{ vars.SYNKRO_GATEWAY_URL || 'https://api.synkro.sh' }}
|
|
2667
|
-
`;
|
|
2668
|
-
WORKFLOW_PATH = ".github/workflows/synkro.yml";
|
|
3298
|
+
init_stub();
|
|
3299
|
+
init_repoConnect();
|
|
2669
3300
|
}
|
|
2670
3301
|
});
|
|
2671
3302
|
|
|
2672
|
-
// cli/
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
const encryptedBytes = sodium.crypto_box_seal(messageBytes, keyBytes);
|
|
2681
|
-
return sodium.to_base64(encryptedBytes, sodium.base64_variants.ORIGINAL);
|
|
3303
|
+
// cli/commands/unlink.ts
|
|
3304
|
+
var unlink_exports = {};
|
|
3305
|
+
__export(unlink_exports, {
|
|
3306
|
+
unlinkCommand: () => unlinkCommand
|
|
3307
|
+
});
|
|
3308
|
+
import { createInterface as createInterface2 } from "readline";
|
|
3309
|
+
function ask2(rl, question) {
|
|
3310
|
+
return new Promise((resolve2) => rl.question(question, resolve2));
|
|
2682
3311
|
}
|
|
2683
|
-
async function
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
3312
|
+
async function unlinkCommand() {
|
|
3313
|
+
if (!isAuthenticated()) {
|
|
3314
|
+
console.error("Not authenticated. Run `synkro install` or `synkro login` first.");
|
|
3315
|
+
process.exit(1);
|
|
3316
|
+
}
|
|
3317
|
+
await ensureValidToken();
|
|
3318
|
+
const projects = await listProjects();
|
|
3319
|
+
const linked = [];
|
|
3320
|
+
for (const p of projects) {
|
|
3321
|
+
for (const r of p.repos || []) {
|
|
3322
|
+
if (r.full_name && r.id) {
|
|
3323
|
+
linked.push({ projectId: p.id, projectName: p.name, repoId: r.id, fullName: r.full_name });
|
|
3324
|
+
}
|
|
2690
3325
|
}
|
|
2691
|
-
});
|
|
2692
|
-
if (!resp.ok) {
|
|
2693
|
-
const text = await resp.text().catch(() => "");
|
|
2694
|
-
throw new Error(`GitHub API ${resp.status} fetching public key for ${owner}/${repo}: ${text.slice(0, 200)}`);
|
|
2695
3326
|
}
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
const encryptedValue = await encryptSecret(publicKey.key, secretValue);
|
|
2700
|
-
const url = `https://api.github.com/repos/${owner}/${repo}/actions/secrets/${encodeURIComponent(secretName)}`;
|
|
2701
|
-
const resp = await fetch(url, {
|
|
2702
|
-
method: "PUT",
|
|
2703
|
-
headers: {
|
|
2704
|
-
Authorization: `Bearer ${opts.token}`,
|
|
2705
|
-
Accept: "application/vnd.github+json",
|
|
2706
|
-
"X-GitHub-Api-Version": "2022-11-28",
|
|
2707
|
-
"Content-Type": "application/json"
|
|
2708
|
-
},
|
|
2709
|
-
body: JSON.stringify({
|
|
2710
|
-
encrypted_value: encryptedValue,
|
|
2711
|
-
key_id: publicKey.key_id
|
|
2712
|
-
})
|
|
2713
|
-
});
|
|
2714
|
-
if (!resp.ok) {
|
|
2715
|
-
const text = await resp.text().catch(() => "");
|
|
2716
|
-
throw new Error(`GitHub API ${resp.status} setting secret ${secretName}: ${text.slice(0, 200)}`);
|
|
3327
|
+
if (linked.length === 0) {
|
|
3328
|
+
console.log("No linked repos found.");
|
|
3329
|
+
return;
|
|
2717
3330
|
}
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
});
|
|
2731
|
-
if (!resp.ok) {
|
|
2732
|
-
throw new Error(`GitHub API ${resp.status} listing repos`);
|
|
3331
|
+
console.log("\nLinked repos:\n");
|
|
3332
|
+
linked.forEach((r, i) => {
|
|
3333
|
+
console.log(` ${i + 1}. ${r.fullName} (${r.projectName})`);
|
|
3334
|
+
});
|
|
3335
|
+
console.log();
|
|
3336
|
+
const rl = createInterface2({ input: process.stdin, output: process.stdout });
|
|
3337
|
+
try {
|
|
3338
|
+
const selection = await ask2(rl, " Select repos to unlink (comma-separated numbers): ");
|
|
3339
|
+
const indices = selection.split(",").map((s) => parseInt(s.trim(), 10) - 1).filter((n) => !isNaN(n) && n >= 0 && n < linked.length);
|
|
3340
|
+
if (indices.length === 0) {
|
|
3341
|
+
console.log(" No repos selected.");
|
|
3342
|
+
return;
|
|
2733
3343
|
}
|
|
2734
|
-
const
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
3344
|
+
for (const idx of indices) {
|
|
3345
|
+
const r = linked[idx];
|
|
3346
|
+
await unlinkRepo(r.projectId, r.repoId);
|
|
3347
|
+
console.log(` \u2713 Unlinked ${r.fullName} from ${r.projectName}`);
|
|
2738
3348
|
}
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
}
|
|
2742
|
-
return repos;
|
|
2743
|
-
}
|
|
2744
|
-
async function pushSecretsToRepo(opts, owner, repo, secrets) {
|
|
2745
|
-
const pubkey = await getRepoPublicKey(opts, owner, repo);
|
|
2746
|
-
await putRepoSecret(opts, owner, repo, "CLAUDE_CODE_OAUTH_TOKEN", secrets.claudeCodeOauthToken, pubkey);
|
|
2747
|
-
await putRepoSecret(opts, owner, repo, "SYNKRO_API_KEY", secrets.synkroApiKey, pubkey);
|
|
2748
|
-
}
|
|
2749
|
-
function writeWorkflowFile(repoRootPath) {
|
|
2750
|
-
const workflowDir = join6(repoRootPath, ".github", "workflows");
|
|
2751
|
-
mkdirSync5(workflowDir, { recursive: true });
|
|
2752
|
-
const workflowFile = join6(workflowDir, "synkro.yml");
|
|
2753
|
-
writeFileSync5(workflowFile, SYNKRO_WORKFLOW_YAML, "utf-8");
|
|
2754
|
-
return workflowFile;
|
|
2755
|
-
}
|
|
2756
|
-
function findGitRoot(startCwd) {
|
|
2757
|
-
let cur = startCwd;
|
|
2758
|
-
while (cur && cur !== "/") {
|
|
2759
|
-
if (existsSync7(join6(cur, ".git"))) return cur;
|
|
2760
|
-
const parent = join6(cur, "..");
|
|
2761
|
-
if (parent === cur) break;
|
|
2762
|
-
cur = parent;
|
|
3349
|
+
} finally {
|
|
3350
|
+
rl.close();
|
|
2763
3351
|
}
|
|
2764
|
-
|
|
3352
|
+
console.log();
|
|
2765
3353
|
}
|
|
2766
|
-
var
|
|
2767
|
-
|
|
2768
|
-
"cli/installer/githubSetup.ts"() {
|
|
3354
|
+
var init_unlink = __esm({
|
|
3355
|
+
"cli/commands/unlink.ts"() {
|
|
2769
3356
|
"use strict";
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
CLAUDE_OAUTH: "CLAUDE_CODE_OAUTH_TOKEN",
|
|
2773
|
-
SYNKRO_API_KEY: "SYNKRO_API_KEY"
|
|
2774
|
-
};
|
|
2775
|
-
WORKFLOW_RELATIVE_PATH = WORKFLOW_PATH;
|
|
3357
|
+
init_stub();
|
|
3358
|
+
init_projects();
|
|
2776
3359
|
}
|
|
2777
3360
|
});
|
|
2778
3361
|
|
|
@@ -2781,7 +3364,7 @@ var setupGithub_exports = {};
|
|
|
2781
3364
|
__export(setupGithub_exports, {
|
|
2782
3365
|
setupGithubCommand: () => setupGithubCommand
|
|
2783
3366
|
});
|
|
2784
|
-
import { createInterface } from "readline/promises";
|
|
3367
|
+
import { createInterface as createInterface3 } from "readline/promises";
|
|
2785
3368
|
import { stdin as input, stdout as output } from "process";
|
|
2786
3369
|
import { existsSync as existsSync8, readFileSync as readFileSync6 } from "fs";
|
|
2787
3370
|
import { homedir as homedir6 } from "os";
|
|
@@ -2832,8 +3415,8 @@ async function setupGithubCommand() {
|
|
|
2832
3415
|
console.error("Not authenticated. Run `synkro-cli login` first.");
|
|
2833
3416
|
process.exit(1);
|
|
2834
3417
|
}
|
|
2835
|
-
const
|
|
2836
|
-
const gatewayUrl = (
|
|
3418
|
+
const config2 = readConfig();
|
|
3419
|
+
const gatewayUrl = (config2.SYNKRO_GATEWAY_URL || process.env.SYNKRO_GATEWAY_URL || "https://api.synkro.sh").replace(/\/$/, "");
|
|
2837
3420
|
const jwt2 = getAccessToken();
|
|
2838
3421
|
if (!jwt2) {
|
|
2839
3422
|
console.error("Could not load access token from ~/.synkro/credentials.json. Run `synkro-cli login`.");
|
|
@@ -2862,7 +3445,7 @@ async function setupGithubCommand() {
|
|
|
2862
3445
|
console.error(`Failed to mint CI API key: ${err.message}`);
|
|
2863
3446
|
process.exit(1);
|
|
2864
3447
|
}
|
|
2865
|
-
const rl =
|
|
3448
|
+
const rl = createInterface3({ input, output });
|
|
2866
3449
|
console.log("Synkro PR scan setup\n");
|
|
2867
3450
|
console.log("Requirements:");
|
|
2868
3451
|
console.log(" \u2022 Claude Code Pro or Max subscription (for `claude setup-token`)");
|
|
@@ -2969,7 +3552,7 @@ var scanPr_exports = {};
|
|
|
2969
3552
|
__export(scanPr_exports, {
|
|
2970
3553
|
scanPrCommand: () => scanPrCommand
|
|
2971
3554
|
});
|
|
2972
|
-
import { execSync as
|
|
3555
|
+
import { execSync as execSync4, spawn } from "child_process";
|
|
2973
3556
|
function parseMatchSpec(condition) {
|
|
2974
3557
|
if (!condition.startsWith("match_spec:")) return null;
|
|
2975
3558
|
try {
|
|
@@ -3075,7 +3658,7 @@ function shouldSkipFile(filename) {
|
|
|
3075
3658
|
return SKIP_FILE_PATTERNS.some((p) => p.test(filename));
|
|
3076
3659
|
}
|
|
3077
3660
|
function ghJson(args2) {
|
|
3078
|
-
const out =
|
|
3661
|
+
const out = execSync4(`gh ${args2.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(" ")}`, {
|
|
3079
3662
|
encoding: "utf-8",
|
|
3080
3663
|
maxBuffer: 16 * 1024 * 1024
|
|
3081
3664
|
});
|
|
@@ -3191,7 +3774,7 @@ ${finding.description}
|
|
|
3191
3774
|
|
|
3192
3775
|
**Fix:** ${finding.fix}`;
|
|
3193
3776
|
try {
|
|
3194
|
-
|
|
3777
|
+
execSync4(
|
|
3195
3778
|
`gh api -X POST /repos/${repo}/pulls/${prNumber}/comments -f body=${JSON.stringify(body)} -f commit_id=${sha} -f path=${JSON.stringify(finding.file)} -F line=${finding.line} -f side=RIGHT`,
|
|
3196
3779
|
{ encoding: "utf-8", stdio: ["ignore", "ignore", "pipe"] }
|
|
3197
3780
|
);
|
|
@@ -3213,7 +3796,7 @@ function postCheckRun(repo, sha, conclusion, findings) {
|
|
|
3213
3796
|
}
|
|
3214
3797
|
});
|
|
3215
3798
|
try {
|
|
3216
|
-
|
|
3799
|
+
execSync4(`gh api -X POST /repos/${repo}/check-runs --input -`, {
|
|
3217
3800
|
encoding: "utf-8",
|
|
3218
3801
|
input: body,
|
|
3219
3802
|
stdio: ["pipe", "ignore", "pipe"]
|
|
@@ -3431,6 +4014,43 @@ var init_disconnect = __esm({
|
|
|
3431
4014
|
}
|
|
3432
4015
|
});
|
|
3433
4016
|
|
|
4017
|
+
// cli/commands/uninstall.ts
|
|
4018
|
+
var uninstall_exports = {};
|
|
4019
|
+
__export(uninstall_exports, {
|
|
4020
|
+
uninstallCommand: () => uninstallCommand
|
|
4021
|
+
});
|
|
4022
|
+
function uninstallCommand() {
|
|
4023
|
+
console.log("Uninstalling Synkro...\n");
|
|
4024
|
+
disconnectCommand(["--purge"]);
|
|
4025
|
+
console.log("\nTo reinstall later: synkro install");
|
|
4026
|
+
}
|
|
4027
|
+
var init_uninstall = __esm({
|
|
4028
|
+
"cli/commands/uninstall.ts"() {
|
|
4029
|
+
"use strict";
|
|
4030
|
+
init_disconnect();
|
|
4031
|
+
}
|
|
4032
|
+
});
|
|
4033
|
+
|
|
4034
|
+
// cli/commands/reinstall.ts
|
|
4035
|
+
var reinstall_exports = {};
|
|
4036
|
+
__export(reinstall_exports, {
|
|
4037
|
+
reinstallCommand: () => reinstallCommand
|
|
4038
|
+
});
|
|
4039
|
+
async function reinstallCommand() {
|
|
4040
|
+
console.log("Reinstalling Synkro...\n");
|
|
4041
|
+
disconnectCommand(["--purge"]);
|
|
4042
|
+
console.log("");
|
|
4043
|
+
await installCommand({ force: true });
|
|
4044
|
+
console.log("\n\u2713 Synkro reinstalled.");
|
|
4045
|
+
}
|
|
4046
|
+
var init_reinstall = __esm({
|
|
4047
|
+
"cli/commands/reinstall.ts"() {
|
|
4048
|
+
"use strict";
|
|
4049
|
+
init_disconnect();
|
|
4050
|
+
init_install();
|
|
4051
|
+
}
|
|
4052
|
+
});
|
|
4053
|
+
|
|
3434
4054
|
// cli/bootstrap.js
|
|
3435
4055
|
import { readFileSync as readFileSync7, existsSync as existsSync10 } from "fs";
|
|
3436
4056
|
import { resolve } from "path";
|
|
@@ -3466,10 +4086,14 @@ Commands:
|
|
|
3466
4086
|
login Authenticate with Synkro (browser OAuth via WorkOS)
|
|
3467
4087
|
logout Clear local credentials
|
|
3468
4088
|
status Show current setup state
|
|
4089
|
+
link Link repos to a Synkro project (local git or GitHub OAuth)
|
|
4090
|
+
unlink Remove repo links from Synkro projects
|
|
3469
4091
|
setup-github Configure GitHub PR scanning (push secrets + workflow file)
|
|
3470
4092
|
scan-pr Run a PR scan (used by GitHub Actions, not for direct invocation)
|
|
3471
4093
|
update Refresh hook configs and judge prompts
|
|
3472
4094
|
disconnect [--purge] Remove Synkro hooks from agents (--purge also removes ~/.synkro)
|
|
4095
|
+
uninstall Fully remove Synkro from this machine
|
|
4096
|
+
reinstall Clean uninstall + fresh install
|
|
3473
4097
|
help Show this message
|
|
3474
4098
|
|
|
3475
4099
|
Quick start:
|
|
@@ -3502,6 +4126,16 @@ async function main() {
|
|
|
3502
4126
|
statusCommand2();
|
|
3503
4127
|
break;
|
|
3504
4128
|
}
|
|
4129
|
+
case "link": {
|
|
4130
|
+
const { linkCommand: linkCommand2 } = await Promise.resolve().then(() => (init_link(), link_exports));
|
|
4131
|
+
await linkCommand2();
|
|
4132
|
+
break;
|
|
4133
|
+
}
|
|
4134
|
+
case "unlink": {
|
|
4135
|
+
const { unlinkCommand: unlinkCommand2 } = await Promise.resolve().then(() => (init_unlink(), unlink_exports));
|
|
4136
|
+
await unlinkCommand2();
|
|
4137
|
+
break;
|
|
4138
|
+
}
|
|
3505
4139
|
case "setup-github": {
|
|
3506
4140
|
const { setupGithubCommand: setupGithubCommand2 } = await Promise.resolve().then(() => (init_setupGithub(), setupGithub_exports));
|
|
3507
4141
|
await setupGithubCommand2();
|
|
@@ -3522,6 +4156,16 @@ async function main() {
|
|
|
3522
4156
|
disconnectCommand2(subArgs);
|
|
3523
4157
|
break;
|
|
3524
4158
|
}
|
|
4159
|
+
case "uninstall": {
|
|
4160
|
+
const { uninstallCommand: uninstallCommand2 } = await Promise.resolve().then(() => (init_uninstall(), uninstall_exports));
|
|
4161
|
+
uninstallCommand2();
|
|
4162
|
+
break;
|
|
4163
|
+
}
|
|
4164
|
+
case "reinstall": {
|
|
4165
|
+
const { reinstallCommand: reinstallCommand2 } = await Promise.resolve().then(() => (init_reinstall(), reinstall_exports));
|
|
4166
|
+
await reinstallCommand2();
|
|
4167
|
+
break;
|
|
4168
|
+
}
|
|
3525
4169
|
case "help":
|
|
3526
4170
|
case "--help":
|
|
3527
4171
|
case "-h":
|