patchwork-os 0.2.0-beta.2 → 0.2.0-beta.3
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/README.bridge.md +5 -5
- package/README.md +156 -12
- package/dist/activityLog.d.ts +6 -0
- package/dist/activityLog.js +8 -0
- package/dist/activityLog.js.map +1 -1
- package/dist/analyticsPrefs.d.ts +35 -2
- package/dist/analyticsPrefs.js +120 -21
- package/dist/analyticsPrefs.js.map +1 -1
- package/dist/analyticsSend.js +5 -1
- package/dist/analyticsSend.js.map +1 -1
- package/dist/bridge.d.ts +2 -0
- package/dist/bridge.js +111 -7
- package/dist/bridge.js.map +1 -1
- package/dist/bridgeLockDiscovery.d.ts +27 -1
- package/dist/bridgeLockDiscovery.js +37 -11
- package/dist/bridgeLockDiscovery.js.map +1 -1
- package/dist/commands/patchworkInit.d.ts +5 -0
- package/dist/commands/patchworkInit.js +86 -7
- package/dist/commands/patchworkInit.js.map +1 -1
- package/dist/commands/recipe.d.ts +51 -0
- package/dist/commands/recipe.js +353 -2
- package/dist/commands/recipe.js.map +1 -1
- package/dist/commands/recipeInstall.js +6 -3
- package/dist/commands/recipeInstall.js.map +1 -1
- package/dist/commands/task.js +2 -2
- package/dist/commands/task.js.map +1 -1
- package/dist/config.d.ts +9 -2
- package/dist/config.js +35 -17
- package/dist/config.js.map +1 -1
- package/dist/connectors/tokenStorage.js +46 -10
- package/dist/connectors/tokenStorage.js.map +1 -1
- package/dist/featureFlags.d.ts +76 -0
- package/dist/featureFlags.js +166 -2
- package/dist/featureFlags.js.map +1 -1
- package/dist/index.js +765 -69
- package/dist/index.js.map +1 -1
- package/dist/lockfile.js +4 -1
- package/dist/lockfile.js.map +1 -1
- package/dist/patchworkConfig.js +5 -0
- package/dist/patchworkConfig.js.map +1 -1
- package/dist/recipeOrchestration.js +35 -1
- package/dist/recipeOrchestration.js.map +1 -1
- package/dist/recipeRoutes.d.ts +36 -0
- package/dist/recipeRoutes.js +231 -32
- package/dist/recipeRoutes.js.map +1 -1
- package/dist/recipes/agentExecutor.d.ts +25 -5
- package/dist/recipes/agentExecutor.js.map +1 -1
- package/dist/recipes/chainedRunner.js +16 -2
- package/dist/recipes/chainedRunner.js.map +1 -1
- package/dist/recipes/connectorPreflight.d.ts +53 -0
- package/dist/recipes/connectorPreflight.js +79 -0
- package/dist/recipes/connectorPreflight.js.map +1 -0
- package/dist/recipes/githubInstallSource.d.ts +62 -0
- package/dist/recipes/githubInstallSource.js +125 -0
- package/dist/recipes/githubInstallSource.js.map +1 -0
- package/dist/recipes/haltCategory.d.ts +80 -0
- package/dist/recipes/haltCategory.js +125 -0
- package/dist/recipes/haltCategory.js.map +1 -0
- package/dist/recipes/idempotencyKey.d.ts +126 -0
- package/dist/recipes/idempotencyKey.js +298 -0
- package/dist/recipes/idempotencyKey.js.map +1 -0
- package/dist/recipes/judgeSummary.d.ts +50 -0
- package/dist/recipes/judgeSummary.js +47 -0
- package/dist/recipes/judgeSummary.js.map +1 -0
- package/dist/recipes/judgeVerdict.d.ts +48 -0
- package/dist/recipes/judgeVerdict.js +174 -0
- package/dist/recipes/judgeVerdict.js.map +1 -0
- package/dist/recipes/migrations/index.d.ts +9 -0
- package/dist/recipes/migrations/index.js +133 -0
- package/dist/recipes/migrations/index.js.map +1 -1
- package/dist/recipes/runBudget.d.ts +70 -0
- package/dist/recipes/runBudget.js +109 -0
- package/dist/recipes/runBudget.js.map +1 -0
- package/dist/recipes/scheduler.js +1 -1
- package/dist/recipes/scheduler.js.map +1 -1
- package/dist/recipes/schema.d.ts +30 -0
- package/dist/recipes/toolRegistry.js +19 -0
- package/dist/recipes/toolRegistry.js.map +1 -1
- package/dist/recipes/tools/http.d.ts +10 -0
- package/dist/recipes/tools/http.js +176 -0
- package/dist/recipes/tools/http.js.map +1 -0
- package/dist/recipes/tools/index.d.ts +1 -0
- package/dist/recipes/tools/index.js +1 -0
- package/dist/recipes/tools/index.js.map +1 -1
- package/dist/recipes/validation.js +1 -1
- package/dist/recipes/validation.js.map +1 -1
- package/dist/recipes/yamlRunner.d.ts +71 -7
- package/dist/recipes/yamlRunner.js +156 -22
- package/dist/recipes/yamlRunner.js.map +1 -1
- package/dist/runLog.d.ts +28 -0
- package/dist/runLog.js +5 -0
- package/dist/runLog.js.map +1 -1
- package/dist/server.d.ts +65 -0
- package/dist/server.js +302 -3
- package/dist/server.js.map +1 -1
- package/dist/streamableHttp.js +17 -6
- package/dist/streamableHttp.js.map +1 -1
- package/dist/tools/bridgeDoctor.js +6 -2
- package/dist/tools/bridgeDoctor.js.map +1 -1
- package/dist/tools/ccRoutines.d.ts +221 -0
- package/dist/tools/ccRoutines.js +264 -0
- package/dist/tools/ccRoutines.js.map +1 -0
- package/dist/tools/getCodeCoverage.js +7 -3
- package/dist/tools/getCodeCoverage.js.map +1 -1
- package/dist/tools/index.js +6 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/recentTracesDigest.js +56 -11
- package/dist/tools/recentTracesDigest.js.map +1 -1
- package/dist/tools/testRunners/vitestJest.js +3 -1
- package/dist/tools/testRunners/vitestJest.js.map +1 -1
- package/dist/tools/utils.js +6 -3
- package/dist/tools/utils.js.map +1 -1
- package/package.json +17 -6
- package/scripts/postinstall.mjs +27 -0
- package/scripts/smoke/run-all.mjs +162 -0
- package/scripts/start-all.mjs +513 -0
- package/scripts/start-all.ps1 +209 -0
- package/scripts/start-all.sh +73 -17
- package/scripts/start-orchestrator.ps1 +158 -0
- package/scripts/start-remote.mjs +122 -0
- package/templates/automation-policies/recipe-authoring.json +1 -1
- package/templates/automation-policies/security-first.json +1 -1
- package/templates/automation-policies/strict-lint.json +1 -1
- package/templates/automation-policies/test-driven.json +1 -1
- package/templates/automation-policy.example.json +1 -1
- package/templates/co.patchwork-os.bridge.plist +1 -1
- package/templates/recipes/approval-queue-ui-test.yaml +1 -1
- package/templates/recipes/ctx-loop-test.yaml +1 -1
- package/templates/recipes/webhook/apple-watch-health-log.yaml +145 -0
- package/dist/commands/marketplace.d.ts +0 -16
- package/dist/commands/marketplace.js +0 -32
- package/dist/commands/marketplace.js.map +0 -1
- package/dist/recipes/legacyRecipeCompat.d.ts +0 -10
- package/dist/recipes/legacyRecipeCompat.js +0 -131
- package/dist/recipes/legacyRecipeCompat.js.map +0 -1
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Cross-platform smoke test runner — replaces run-all.sh.
|
|
3
|
+
// Works on Windows (PowerShell/cmd), macOS, and Linux.
|
|
4
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
|
|
10
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const BRIDGE = process.env.BRIDGE ?? "claude-ide-bridge";
|
|
12
|
+
const PORT = 37210;
|
|
13
|
+
const CAT2_PORT = 37211;
|
|
14
|
+
|
|
15
|
+
const TMPWS = fs.mkdtempSync(path.join(os.tmpdir(), "patchwork-smoke-ws-"));
|
|
16
|
+
const CLAUDE_CFG = fs.mkdtempSync(
|
|
17
|
+
path.join(os.tmpdir(), "patchwork-smoke-cfg-"),
|
|
18
|
+
);
|
|
19
|
+
fs.mkdirSync(path.join(CLAUDE_CFG, "ide"), { recursive: true });
|
|
20
|
+
|
|
21
|
+
process.env.CLAUDE_CONFIG_DIR = CLAUDE_CFG;
|
|
22
|
+
|
|
23
|
+
let bridgePid = null;
|
|
24
|
+
let cat2Pid = null;
|
|
25
|
+
let cat2Cfg = null;
|
|
26
|
+
|
|
27
|
+
function cleanup() {
|
|
28
|
+
for (const pid of [bridgePid, cat2Pid]) {
|
|
29
|
+
if (pid == null) continue;
|
|
30
|
+
try {
|
|
31
|
+
process.kill(pid);
|
|
32
|
+
} catch {
|
|
33
|
+
/* already gone */
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
for (const dir of [TMPWS, CLAUDE_CFG, cat2Cfg]) {
|
|
37
|
+
if (!dir) continue;
|
|
38
|
+
try {
|
|
39
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
40
|
+
} catch {
|
|
41
|
+
/* best-effort */
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
process.on("exit", cleanup);
|
|
47
|
+
process.on("SIGINT", () => process.exit(1));
|
|
48
|
+
process.on("SIGTERM", () => process.exit(1));
|
|
49
|
+
|
|
50
|
+
function waitForLock(cfgDir, port, timeoutMs = 10_000) {
|
|
51
|
+
const lockPath = path.join(cfgDir, "ide", `${port}.lock`);
|
|
52
|
+
const deadline = Date.now() + timeoutMs;
|
|
53
|
+
while (!fs.existsSync(lockPath)) {
|
|
54
|
+
if (Date.now() > deadline) return false;
|
|
55
|
+
// busy-wait in 100ms increments — same as the bash script
|
|
56
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
|
|
57
|
+
}
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Spawn bridge for the given port and config dir. Returns {proc, token}.
|
|
62
|
+
function startBridge(port, cfgDir, wsDir) {
|
|
63
|
+
const proc = spawn(BRIDGE, ["--port", String(port), "--workspace", wsDir], {
|
|
64
|
+
env: { ...process.env, CLAUDE_CONFIG_DIR: cfgDir },
|
|
65
|
+
stdio: "ignore",
|
|
66
|
+
// On Windows, npm global bins are .cmd wrappers that need shell:true
|
|
67
|
+
shell: process.platform === "win32",
|
|
68
|
+
});
|
|
69
|
+
return proc;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Start main bridge ─────────────────────────────────────────────────────────
|
|
73
|
+
console.log(`Starting bridge on port ${PORT}...`);
|
|
74
|
+
const bridgeProc = startBridge(PORT, CLAUDE_CFG, TMPWS);
|
|
75
|
+
bridgePid = bridgeProc.pid;
|
|
76
|
+
|
|
77
|
+
if (!waitForLock(CLAUDE_CFG, PORT)) {
|
|
78
|
+
console.error("ERROR: bridge lock file not written after 10s");
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
// tiny extra buffer for WS listener to bind
|
|
82
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200);
|
|
83
|
+
|
|
84
|
+
const TOKEN = execFileSync(BRIDGE, ["print-token", "--port", String(PORT)], {
|
|
85
|
+
encoding: "utf-8",
|
|
86
|
+
shell: process.platform === "win32",
|
|
87
|
+
}).trim();
|
|
88
|
+
|
|
89
|
+
console.log(`Bridge ready. Token: ${TOKEN.slice(0, 8)}...\n`);
|
|
90
|
+
|
|
91
|
+
// ── Start CAT-2 bridge (separate instance — CAT-2 kills the bridge it tests) ─
|
|
92
|
+
cat2Cfg = fs.mkdtempSync(path.join(os.tmpdir(), "patchwork-smoke-cat2-"));
|
|
93
|
+
fs.mkdirSync(path.join(cat2Cfg, "ide"), { recursive: true });
|
|
94
|
+
|
|
95
|
+
const cat2Proc = startBridge(CAT2_PORT, cat2Cfg, TMPWS);
|
|
96
|
+
cat2Pid = cat2Proc.pid;
|
|
97
|
+
waitForLock(cat2Cfg, CAT2_PORT);
|
|
98
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200);
|
|
99
|
+
|
|
100
|
+
// ── Category runner ───────────────────────────────────────────────────────────
|
|
101
|
+
let pass = 0;
|
|
102
|
+
let fail = 0;
|
|
103
|
+
const failures = [];
|
|
104
|
+
|
|
105
|
+
function runCat(label, scriptFile, args = [], extraEnv = {}) {
|
|
106
|
+
try {
|
|
107
|
+
execFileSync(process.execPath, [scriptFile, ...args], {
|
|
108
|
+
env: { ...process.env, ...extraEnv },
|
|
109
|
+
stdio: "inherit",
|
|
110
|
+
});
|
|
111
|
+
console.log(`\x1b[32m[PASS]\x1b[0m ${label}`);
|
|
112
|
+
pass++;
|
|
113
|
+
} catch {
|
|
114
|
+
console.log(`\x1b[31m[FAIL]\x1b[0m ${label}`);
|
|
115
|
+
fail++;
|
|
116
|
+
failures.push(label);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const S = __dirname;
|
|
121
|
+
const P = String(PORT);
|
|
122
|
+
const T = TOKEN;
|
|
123
|
+
|
|
124
|
+
runCat(
|
|
125
|
+
"CAT-2 (lockfile)",
|
|
126
|
+
path.join(S, "cat2-lockfile.mjs"),
|
|
127
|
+
[String(CAT2_PORT), String(cat2Pid)],
|
|
128
|
+
{ CLAUDE_CONFIG_DIR: cat2Cfg },
|
|
129
|
+
);
|
|
130
|
+
fs.rmSync(cat2Cfg, { recursive: true, force: true });
|
|
131
|
+
cat2Cfg = null;
|
|
132
|
+
cat2Pid = null;
|
|
133
|
+
|
|
134
|
+
runCat("CAT-3 (auth)", path.join(S, "cat3-auth.mjs"), [P, T]);
|
|
135
|
+
runCat("CAT-4 (tools)", path.join(S, "cat4-tools.mjs"));
|
|
136
|
+
runCat("CAT-5 (http)", path.join(S, "cat5-http.mjs"), [P, T]);
|
|
137
|
+
runCat("CAT-6 (oauth)", path.join(S, "cat6-oauth.mjs"));
|
|
138
|
+
runCat("CAT-7 (plugin)", path.join(S, "cat7-plugin.mjs"));
|
|
139
|
+
runCat("CAT-8 (ratelimit)", path.join(S, "cat8-ratelimit.mjs"), [P, T]);
|
|
140
|
+
|
|
141
|
+
// Give bridge 1s to reset after CAT-8 saturates the rate limiter
|
|
142
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1000);
|
|
143
|
+
|
|
144
|
+
runCat("CAT-9 (prompts/res)", path.join(S, "cat9-prompts-resources.mjs"), [
|
|
145
|
+
P,
|
|
146
|
+
T,
|
|
147
|
+
]);
|
|
148
|
+
runCat("CAT-10 (health)", path.join(S, "cat10-health.mjs"), [P, T]);
|
|
149
|
+
runCat("CAT-11 (shutdown)", path.join(S, "cat11-shutdown.mjs"));
|
|
150
|
+
runCat("CAT-12 (automation)", path.join(S, "cat12-automation.mjs"));
|
|
151
|
+
|
|
152
|
+
// ── Summary ───────────────────────────────────────────────────────────────────
|
|
153
|
+
const total = pass + fail;
|
|
154
|
+
console.log("\n═══════════════════════════════════");
|
|
155
|
+
if (fail === 0) {
|
|
156
|
+
console.log(`\x1b[32mALL PASS\x1b[0m (${pass}/${total} categories)`);
|
|
157
|
+
} else {
|
|
158
|
+
console.log(`\x1b[31mFAILURES: ${fail}/${total} categories\x1b[0m`);
|
|
159
|
+
for (const c of failures) console.log(` ✗ ${c}`);
|
|
160
|
+
}
|
|
161
|
+
console.log("═══════════════════════════════════");
|
|
162
|
+
process.exit(fail > 0 ? 1 : 0);
|
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Cross-platform orchestrator: bridge + Claude + dashboard + health monitor.
|
|
4
|
+
* Replaces start-all.sh for native Windows, and works identically on macOS/Linux.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* node scripts/start-all.mjs [options]
|
|
8
|
+
* npm run start-all:node -- --workspace /my/project
|
|
9
|
+
*
|
|
10
|
+
* Options:
|
|
11
|
+
* --workspace <path> Directory to open in Claude (default: current directory)
|
|
12
|
+
* --full Register all ~170 tools (git, terminal, file ops, HTTP, GitHub)
|
|
13
|
+
* --no-dashboard Skip the Patchwork dashboard
|
|
14
|
+
* --dashboard-port <N> Dashboard port (default: 3200)
|
|
15
|
+
* --bridge-port <N> Bridge port (auto-assigned if omitted)
|
|
16
|
+
* --notify <topic> ntfy.sh topic for push notifications
|
|
17
|
+
* --no-remote Skip starting claude remote-control
|
|
18
|
+
* --automation-policy <path> Path to automation policy JSON
|
|
19
|
+
* --driver <name> AI driver (default: subprocess)
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
23
|
+
import fs from "node:fs";
|
|
24
|
+
import net from "node:net";
|
|
25
|
+
import os from "node:os";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
import { fileURLToPath } from "node:url";
|
|
28
|
+
|
|
29
|
+
// ── Arg parsing ───────────────────────────────────────────────────────────────
|
|
30
|
+
const args = process.argv.slice(2);
|
|
31
|
+
function flag(name) {
|
|
32
|
+
const i = args.indexOf(name);
|
|
33
|
+
if (i === -1) return null;
|
|
34
|
+
return args[i + 1] ?? true;
|
|
35
|
+
}
|
|
36
|
+
function boolFlag(name) {
|
|
37
|
+
return args.includes(name);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const WORKSPACE = path.resolve(flag("--workspace") || ".");
|
|
41
|
+
const FULL_MODE = boolFlag("--full");
|
|
42
|
+
const NO_DASHBOARD = boolFlag("--no-dashboard");
|
|
43
|
+
const NO_REMOTE = boolFlag("--no-remote");
|
|
44
|
+
const DASHBOARD_PORT = parseInt(flag("--dashboard-port") || "3200", 10);
|
|
45
|
+
const BRIDGE_PORT = parseInt(flag("--bridge-port") || "0", 10);
|
|
46
|
+
const NTFY_TOPIC = flag("--notify") || "";
|
|
47
|
+
const AUTO_POLICY = flag("--automation-policy") || "";
|
|
48
|
+
const DRIVER = flag("--driver") || "subprocess";
|
|
49
|
+
|
|
50
|
+
if (!fs.existsSync(WORKSPACE)) {
|
|
51
|
+
console.error(`Error: workspace directory not found: ${WORKSPACE}`);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
56
|
+
const BRIDGE_DIR = path.resolve(SCRIPT_DIR, "..");
|
|
57
|
+
const DASH_DIR = path.join(BRIDGE_DIR, "dashboard");
|
|
58
|
+
const DIST_INDEX = path.join(BRIDGE_DIR, "dist", "index.js");
|
|
59
|
+
const IS_WIN = process.platform === "win32";
|
|
60
|
+
|
|
61
|
+
// ── Colour helpers ────────────────────────────────────────────────────────────
|
|
62
|
+
const C = {
|
|
63
|
+
cyan: (s) => `\x1b[36m${s}\x1b[0m`,
|
|
64
|
+
green: (s) => `\x1b[32m${s}\x1b[0m`,
|
|
65
|
+
yellow: (s) => `\x1b[33m${s}\x1b[0m`,
|
|
66
|
+
red: (s) => `\x1b[31m${s}\x1b[0m`,
|
|
67
|
+
grey: (s) => `\x1b[90m${s}\x1b[0m`,
|
|
68
|
+
bold: (s) => `\x1b[1m${s}\x1b[0m`,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
function ts() {
|
|
72
|
+
return new Date().toLocaleTimeString();
|
|
73
|
+
}
|
|
74
|
+
function log(label, msg, col = C.cyan) {
|
|
75
|
+
process.stdout.write(`${C.grey(ts())} ${col(`[${label}]`)} ${msg}\n`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── ntfy notifications ────────────────────────────────────────────────────────
|
|
79
|
+
let lastNotifyMs = 0;
|
|
80
|
+
const NOTIFY_COOLDOWN_MS = 60_000;
|
|
81
|
+
|
|
82
|
+
function notify(msg, priority = "default") {
|
|
83
|
+
log("notify", msg);
|
|
84
|
+
if (!NTFY_TOPIC) return;
|
|
85
|
+
const now = Date.now();
|
|
86
|
+
if (priority !== "high" && now - lastNotifyMs < NOTIFY_COOLDOWN_MS) return;
|
|
87
|
+
lastNotifyMs = now;
|
|
88
|
+
// Fire-and-forget via fetch (Node 18+)
|
|
89
|
+
fetch(`https://ntfy.sh/${NTFY_TOPIC}`, {
|
|
90
|
+
method: "POST",
|
|
91
|
+
body: msg,
|
|
92
|
+
headers: { Title: "Claude IDE Bridge", Priority: priority },
|
|
93
|
+
signal: AbortSignal.timeout(10_000),
|
|
94
|
+
}).catch(() => {});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Process registry ──────────────────────────────────────────────────────────
|
|
98
|
+
const procs = new Map(); // name → ChildProcess
|
|
99
|
+
|
|
100
|
+
function spawnProc(name, cmd, cmdArgs, opts = {}) {
|
|
101
|
+
// shell:false everywhere — on Windows we always invoke cmd.exe explicitly
|
|
102
|
+
// for .cmd shim resolution, so shell:true would only widen the attack
|
|
103
|
+
// surface by interpolating env-derived paths into a shell string.
|
|
104
|
+
const child = spawn(cmd, cmdArgs, {
|
|
105
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
106
|
+
shell: false,
|
|
107
|
+
cwd: opts.cwd ?? BRIDGE_DIR,
|
|
108
|
+
env: { ...process.env, ...opts.env },
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
child.stdout?.on("data", (d) => {
|
|
112
|
+
for (const l of d.toString().split("\n").filter(Boolean))
|
|
113
|
+
log(name, l, C.grey);
|
|
114
|
+
});
|
|
115
|
+
child.stderr?.on("data", (d) => {
|
|
116
|
+
for (const l of d.toString().split("\n").filter(Boolean))
|
|
117
|
+
log(name, l, C.yellow);
|
|
118
|
+
});
|
|
119
|
+
child.on("error", (err) => log(name, `spawn error: ${err.message}`, C.red));
|
|
120
|
+
|
|
121
|
+
procs.set(name, child);
|
|
122
|
+
return child;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function killProc(name) {
|
|
126
|
+
const p = procs.get(name);
|
|
127
|
+
if (!p || p.exitCode !== null) return;
|
|
128
|
+
try {
|
|
129
|
+
if (IS_WIN) p.kill();
|
|
130
|
+
else p.kill("SIGTERM");
|
|
131
|
+
} catch {
|
|
132
|
+
/* best-effort */
|
|
133
|
+
}
|
|
134
|
+
procs.delete(name);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function killAll() {
|
|
138
|
+
for (const name of [...procs.keys()]) killProc(name);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── Cleanup on exit ───────────────────────────────────────────────────────────
|
|
142
|
+
let cleanedUp = false;
|
|
143
|
+
function cleanup() {
|
|
144
|
+
if (cleanedUp) return;
|
|
145
|
+
cleanedUp = true;
|
|
146
|
+
log("orchestrator", "Stopping all processes...", C.yellow);
|
|
147
|
+
killAll();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
process.on("exit", cleanup);
|
|
151
|
+
process.on("SIGINT", () => {
|
|
152
|
+
cleanup();
|
|
153
|
+
process.exit(0);
|
|
154
|
+
});
|
|
155
|
+
process.on("SIGTERM", () => {
|
|
156
|
+
cleanup();
|
|
157
|
+
process.exit(0);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// ── Wait helpers ──────────────────────────────────────────────────────────────
|
|
161
|
+
function sleep(ms) {
|
|
162
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function waitForLock(cfgDir, port, timeoutMs = 30_000) {
|
|
166
|
+
return new Promise((resolve) => {
|
|
167
|
+
const lockPath = path.join(cfgDir, "ide", `${port}.lock`);
|
|
168
|
+
const deadline = Date.now() + timeoutMs;
|
|
169
|
+
const poll = setInterval(() => {
|
|
170
|
+
if (fs.existsSync(lockPath)) {
|
|
171
|
+
clearInterval(poll);
|
|
172
|
+
resolve(lockPath);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (Date.now() > deadline) {
|
|
176
|
+
clearInterval(poll);
|
|
177
|
+
resolve(null);
|
|
178
|
+
}
|
|
179
|
+
}, 150);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Find the lock file written by the newly spawned bridge (any new lock in ide/).
|
|
184
|
+
function waitForNewLock(cfgDir, knownLocks, timeoutMs = 30_000) {
|
|
185
|
+
return new Promise((resolve) => {
|
|
186
|
+
const ideDir = path.join(cfgDir, "ide");
|
|
187
|
+
const deadline = Date.now() + timeoutMs;
|
|
188
|
+
const poll = setInterval(() => {
|
|
189
|
+
let locks = [];
|
|
190
|
+
try {
|
|
191
|
+
locks = fs.readdirSync(ideDir).filter((f) => f.endsWith(".lock"));
|
|
192
|
+
} catch {}
|
|
193
|
+
const newLock = locks.find((l) => !knownLocks.has(l));
|
|
194
|
+
if (newLock) {
|
|
195
|
+
clearInterval(poll);
|
|
196
|
+
resolve(path.join(ideDir, newLock));
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (Date.now() > deadline) {
|
|
200
|
+
clearInterval(poll);
|
|
201
|
+
resolve(null);
|
|
202
|
+
}
|
|
203
|
+
}, 200);
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function waitForPort(port, timeoutMs = 60_000) {
|
|
208
|
+
return new Promise((resolve) => {
|
|
209
|
+
const deadline = Date.now() + timeoutMs;
|
|
210
|
+
function attempt() {
|
|
211
|
+
const sock = net.createConnection({ port, host: "127.0.0.1" });
|
|
212
|
+
sock.on("connect", () => {
|
|
213
|
+
sock.destroy();
|
|
214
|
+
resolve(true);
|
|
215
|
+
});
|
|
216
|
+
sock.on("error", () => {
|
|
217
|
+
if (Date.now() > deadline) {
|
|
218
|
+
resolve(false);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
setTimeout(attempt, 500);
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
attempt();
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function readLock(lockPath) {
|
|
229
|
+
try {
|
|
230
|
+
return JSON.parse(fs.readFileSync(lockPath, "utf-8"));
|
|
231
|
+
} catch {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ── Resolve bridge binary ─────────────────────────────────────────────────────
|
|
237
|
+
// Prefer dist/index.js when available (npm install); fallback to src via tsx for local dev.
|
|
238
|
+
function bridgeBin() {
|
|
239
|
+
if (fs.existsSync(DIST_INDEX)) return [process.execPath, [DIST_INDEX]];
|
|
240
|
+
const srcIndex = path.join(BRIDGE_DIR, "src", "index.ts");
|
|
241
|
+
if (fs.existsSync(srcIndex)) return ["npx", ["tsx", srcIndex]];
|
|
242
|
+
console.error("Error: dist/index.js not found. Run 'npm run build' first.");
|
|
243
|
+
process.exit(1);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ── Cross-platform browser open ───────────────────────────────────────────────
|
|
247
|
+
function openBrowser(url) {
|
|
248
|
+
try {
|
|
249
|
+
if (IS_WIN) {
|
|
250
|
+
spawn("cmd.exe", ["/c", "start", url], { stdio: "ignore", shell: false });
|
|
251
|
+
} else if (process.platform === "darwin") {
|
|
252
|
+
spawn("open", [url], { stdio: "ignore" });
|
|
253
|
+
} else {
|
|
254
|
+
spawn("xdg-open", [url], { stdio: "ignore" });
|
|
255
|
+
}
|
|
256
|
+
} catch {
|
|
257
|
+
/* best-effort */
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ── Dependency checks ─────────────────────────────────────────────────────────
|
|
262
|
+
function probe(bin) {
|
|
263
|
+
try {
|
|
264
|
+
execFileSync(IS_WIN ? "where" : "which", [bin], {
|
|
265
|
+
stdio: "pipe",
|
|
266
|
+
timeout: 3_000,
|
|
267
|
+
});
|
|
268
|
+
return true;
|
|
269
|
+
} catch {
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (!probe("claude")) {
|
|
275
|
+
console.error(
|
|
276
|
+
"Error: claude CLI not found on PATH. Install from https://docs.anthropic.com/en/docs/claude-code",
|
|
277
|
+
);
|
|
278
|
+
process.exit(1);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ── Banner ────────────────────────────────────────────────────────────────────
|
|
282
|
+
console.log(
|
|
283
|
+
C.bold("\n=== Claude IDE Bridge — Cross-Platform Orchestrator ==="),
|
|
284
|
+
);
|
|
285
|
+
console.log(` Workspace : ${WORKSPACE}`);
|
|
286
|
+
console.log(
|
|
287
|
+
` Tools : ${FULL_MODE ? "full (~170)" : "slim (27 IDE-only)"}`,
|
|
288
|
+
);
|
|
289
|
+
if (!NO_DASHBOARD)
|
|
290
|
+
console.log(` Dashboard : http://localhost:${DASHBOARD_PORT}`);
|
|
291
|
+
if (NTFY_TOPIC) console.log(` Notify : ntfy.sh/${NTFY_TOPIC}`);
|
|
292
|
+
console.log(` Ctrl+C : stop everything\n`);
|
|
293
|
+
|
|
294
|
+
// ── State ─────────────────────────────────────────────────────────────────────
|
|
295
|
+
const CLAUDE_CFG =
|
|
296
|
+
process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
|
|
297
|
+
const IDE_DIR = path.join(CLAUDE_CFG, "ide");
|
|
298
|
+
fs.mkdirSync(IDE_DIR, { recursive: true });
|
|
299
|
+
|
|
300
|
+
let lockPath = null;
|
|
301
|
+
let restartCount = 0;
|
|
302
|
+
let restartDelayMs = 5_000;
|
|
303
|
+
let lastStartMs = 0;
|
|
304
|
+
|
|
305
|
+
// ── Build bridge spawn args ───────────────────────────────────────────────────
|
|
306
|
+
function buildBridgeArgs() {
|
|
307
|
+
const [bin, prefix] = bridgeBin();
|
|
308
|
+
const extra = [
|
|
309
|
+
"--workspace",
|
|
310
|
+
WORKSPACE,
|
|
311
|
+
"--driver",
|
|
312
|
+
DRIVER,
|
|
313
|
+
...(BRIDGE_PORT > 0 ? ["--port", String(BRIDGE_PORT)] : []),
|
|
314
|
+
...(FULL_MODE ? ["--full"] : []),
|
|
315
|
+
...(AUTO_POLICY
|
|
316
|
+
? ["--automation", "--automation-policy", AUTO_POLICY]
|
|
317
|
+
: []),
|
|
318
|
+
];
|
|
319
|
+
return { bin, args: [...prefix, ...extra] };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ── Start bridge ──────────────────────────────────────────────────────────────
|
|
323
|
+
async function startBridge() {
|
|
324
|
+
const existingLocks = new Set(
|
|
325
|
+
fs.existsSync(IDE_DIR)
|
|
326
|
+
? fs.readdirSync(IDE_DIR).filter((f) => f.endsWith(".lock"))
|
|
327
|
+
: [],
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
const { bin, args: bArgs } = buildBridgeArgs();
|
|
331
|
+
log("bridge", `Starting: ${path.basename(bin)} ${bArgs.slice(-4).join(" ")}`);
|
|
332
|
+
lastStartMs = Date.now();
|
|
333
|
+
|
|
334
|
+
spawnProc("bridge", bin, bArgs);
|
|
335
|
+
|
|
336
|
+
// Wait for lock file (bridge writes it before accepting connections)
|
|
337
|
+
const newLock =
|
|
338
|
+
BRIDGE_PORT > 0
|
|
339
|
+
? await waitForLock(CLAUDE_CFG, BRIDGE_PORT)
|
|
340
|
+
: await waitForNewLock(CLAUDE_CFG, existingLocks);
|
|
341
|
+
|
|
342
|
+
if (!newLock) {
|
|
343
|
+
log(
|
|
344
|
+
"bridge",
|
|
345
|
+
"Lock file not written after 30s — bridge failed to start",
|
|
346
|
+
C.red,
|
|
347
|
+
);
|
|
348
|
+
notify("Bridge failed to start!", "high");
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
lockPath = newLock;
|
|
353
|
+
const content = readLock(lockPath);
|
|
354
|
+
const port = content?.port ?? parseInt(path.basename(lockPath, ".lock"), 10);
|
|
355
|
+
|
|
356
|
+
log("bridge", `Ready on port ${port}`, C.green);
|
|
357
|
+
notify(`Bridge started on port ${port}`);
|
|
358
|
+
return port;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ── Start Claude --ide ────────────────────────────────────────────────────────
|
|
362
|
+
function startClaude(sessionId) {
|
|
363
|
+
const extraArgs = sessionId ? ["--resume", sessionId] : [];
|
|
364
|
+
log("claude", "Starting claude --ide");
|
|
365
|
+
spawnProc(
|
|
366
|
+
"claude",
|
|
367
|
+
IS_WIN ? "cmd.exe" : "claude",
|
|
368
|
+
IS_WIN ? ["/c", "claude", "--ide", ...extraArgs] : ["--ide", ...extraArgs],
|
|
369
|
+
{ env: { CLAUDE_CODE_IDE_SKIP_VALID_CHECK: "true" } },
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ── Start remote-control ──────────────────────────────────────────────────────
|
|
374
|
+
function startRemote() {
|
|
375
|
+
if (NO_REMOTE) return;
|
|
376
|
+
log("remote", "Starting claude remote-control --spawn=session");
|
|
377
|
+
spawnProc(
|
|
378
|
+
"remote",
|
|
379
|
+
IS_WIN ? "cmd.exe" : "claude",
|
|
380
|
+
IS_WIN
|
|
381
|
+
? ["/c", "claude", "remote-control", "--spawn=session"]
|
|
382
|
+
: ["remote-control", "--spawn=session"],
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// ── Load ~/.patchwork/.env into process.env ───────────────────────────────────
|
|
387
|
+
function loadPatchworkEnv() {
|
|
388
|
+
const envPath = path.join(os.homedir(), ".patchwork", ".env");
|
|
389
|
+
if (!fs.existsSync(envPath)) return;
|
|
390
|
+
for (const line of fs.readFileSync(envPath, "utf-8").split("\n")) {
|
|
391
|
+
const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
|
|
392
|
+
if (m && !(m[1] in process.env)) process.env[m[1]] = m[2];
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
loadPatchworkEnv();
|
|
396
|
+
|
|
397
|
+
// ── Start dashboard ───────────────────────────────────────────────────────────
|
|
398
|
+
async function startDashboard(bridgePort) {
|
|
399
|
+
if (NO_DASHBOARD) return;
|
|
400
|
+
if (!fs.existsSync(path.join(DASH_DIR, "node_modules"))) {
|
|
401
|
+
log("dashboard", "node_modules not found — installing...", C.yellow);
|
|
402
|
+
try {
|
|
403
|
+
execFileSync(
|
|
404
|
+
IS_WIN ? "cmd.exe" : "npm",
|
|
405
|
+
IS_WIN
|
|
406
|
+
? ["/c", "npm", "install", "--prefer-offline"]
|
|
407
|
+
: ["install", "--prefer-offline"],
|
|
408
|
+
{ cwd: DASH_DIR, stdio: "inherit" },
|
|
409
|
+
);
|
|
410
|
+
} catch {
|
|
411
|
+
log(
|
|
412
|
+
"dashboard",
|
|
413
|
+
"npm install failed — pass --no-dashboard to skip",
|
|
414
|
+
C.red,
|
|
415
|
+
);
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const dashEnv = {
|
|
421
|
+
PATCHWORK_BRIDGE_PORT: String(bridgePort),
|
|
422
|
+
...(process.env.DASHBOARD_PASSWORD
|
|
423
|
+
? { DASHBOARD_PASSWORD: process.env.DASHBOARD_PASSWORD }
|
|
424
|
+
: {}),
|
|
425
|
+
...(process.env.DASHBOARD_SESSION_SECRET
|
|
426
|
+
? { DASHBOARD_SESSION_SECRET: process.env.DASHBOARD_SESSION_SECRET }
|
|
427
|
+
: {}),
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
log("dashboard", `Starting on http://localhost:${DASHBOARD_PORT}`);
|
|
431
|
+
spawnProc(
|
|
432
|
+
"dashboard",
|
|
433
|
+
IS_WIN ? "cmd.exe" : "npx",
|
|
434
|
+
IS_WIN
|
|
435
|
+
? ["/c", "npx", "next", "dev", "-p", String(DASHBOARD_PORT)]
|
|
436
|
+
: ["next", "dev", "-p", String(DASHBOARD_PORT)],
|
|
437
|
+
{ cwd: DASH_DIR, env: dashEnv },
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
const ready = await waitForPort(DASHBOARD_PORT, 60_000);
|
|
441
|
+
if (ready) {
|
|
442
|
+
log(
|
|
443
|
+
"dashboard",
|
|
444
|
+
`Ready — opening http://localhost:${DASHBOARD_PORT}`,
|
|
445
|
+
C.green,
|
|
446
|
+
);
|
|
447
|
+
openBrowser(`http://localhost:${DASHBOARD_PORT}`);
|
|
448
|
+
} else {
|
|
449
|
+
log(
|
|
450
|
+
"dashboard",
|
|
451
|
+
`Did not respond within 60s — open http://localhost:${DASHBOARD_PORT} manually`,
|
|
452
|
+
C.yellow,
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ── Health monitor ────────────────────────────────────────────────────────────
|
|
458
|
+
const sessionId = null;
|
|
459
|
+
|
|
460
|
+
async function restartAll() {
|
|
461
|
+
log("health", "Bridge lock file gone — restarting...", C.yellow);
|
|
462
|
+
notify("Bridge died! Restarting...", "high");
|
|
463
|
+
|
|
464
|
+
killProc("bridge");
|
|
465
|
+
killProc("claude");
|
|
466
|
+
killProc("remote");
|
|
467
|
+
await sleep(2_000); // let processes wind down
|
|
468
|
+
|
|
469
|
+
// Exponential backoff on rapid restarts
|
|
470
|
+
const uptime = Date.now() - lastStartMs;
|
|
471
|
+
if (uptime < 60_000) {
|
|
472
|
+
log(
|
|
473
|
+
"health",
|
|
474
|
+
`Crashed quickly (${Math.round(uptime / 1000)}s) — backing off ${restartDelayMs / 1000}s (restart #${restartCount})`,
|
|
475
|
+
C.yellow,
|
|
476
|
+
);
|
|
477
|
+
await sleep(restartDelayMs);
|
|
478
|
+
restartDelayMs = Math.min(restartDelayMs * 2, 300_000);
|
|
479
|
+
restartCount++;
|
|
480
|
+
} else {
|
|
481
|
+
restartDelayMs = 5_000;
|
|
482
|
+
restartCount = 0;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const port = await startBridge();
|
|
486
|
+
if (!port) return;
|
|
487
|
+
|
|
488
|
+
startClaude(sessionId); // --resume if we have a session UUID
|
|
489
|
+
startRemote();
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function startHealthMonitor() {
|
|
493
|
+
setInterval(async () => {
|
|
494
|
+
if (!lockPath) return;
|
|
495
|
+
if (!fs.existsSync(lockPath)) await restartAll();
|
|
496
|
+
}, 10_000);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// ── Main ──────────────────────────────────────────────────────────────────────
|
|
500
|
+
const bridgePort = await startBridge();
|
|
501
|
+
if (!bridgePort) {
|
|
502
|
+
cleanup();
|
|
503
|
+
process.exit(1);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
startClaude();
|
|
507
|
+
startRemote();
|
|
508
|
+
startHealthMonitor();
|
|
509
|
+
await startDashboard(bridgePort);
|
|
510
|
+
|
|
511
|
+
// Keep process alive (health monitor runs on setInterval)
|
|
512
|
+
log("orchestrator", "All processes started. Ctrl+C to stop.", C.green);
|
|
513
|
+
await new Promise(() => {}); // never resolves — process lives until Ctrl+C
|