opencara 0.25.2 → 0.100.1
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/LICENSE +21 -0
- package/dist/bin.js +604 -0
- package/package.json +21 -42
- package/README.md +0 -128
- package/dist/index.js +0 -7427
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 OpenCara
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OF OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/bin.js
ADDED
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/commands/run.ts
|
|
4
|
+
import {
|
|
5
|
+
arch,
|
|
6
|
+
cpus,
|
|
7
|
+
freemem,
|
|
8
|
+
hostname,
|
|
9
|
+
networkInterfaces,
|
|
10
|
+
platform,
|
|
11
|
+
release,
|
|
12
|
+
totalmem,
|
|
13
|
+
uptime
|
|
14
|
+
} from "node:os";
|
|
15
|
+
import { readFileSync as readFileSync2, statfsSync } from "node:fs";
|
|
16
|
+
import { dirname, join as join2 } from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
|
|
19
|
+
// src/config/store.ts
|
|
20
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from "node:fs";
|
|
21
|
+
import { z } from "zod";
|
|
22
|
+
|
|
23
|
+
// src/config/paths.ts
|
|
24
|
+
import { homedir } from "node:os";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
var CONFIG_DIR = join(homedir(), ".opencara");
|
|
27
|
+
var CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
28
|
+
var DEFAULT_ORCHESTRATOR_URL = "https://opencara.com";
|
|
29
|
+
|
|
30
|
+
// src/config/store.ts
|
|
31
|
+
var ConfigSchema = z.object({
|
|
32
|
+
orchestratorUrl: z.string().url(),
|
|
33
|
+
token: z.string(),
|
|
34
|
+
agentHostId: z.string(),
|
|
35
|
+
deviceName: z.string()
|
|
36
|
+
});
|
|
37
|
+
function readConfig() {
|
|
38
|
+
if (!existsSync(CONFIG_FILE)) return null;
|
|
39
|
+
try {
|
|
40
|
+
return ConfigSchema.parse(JSON.parse(readFileSync(CONFIG_FILE, "utf8")));
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function writeConfig(cfg) {
|
|
46
|
+
if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 });
|
|
47
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2), { mode: 384 });
|
|
48
|
+
}
|
|
49
|
+
function clearConfig() {
|
|
50
|
+
if (existsSync(CONFIG_FILE)) unlinkSync(CONFIG_FILE);
|
|
51
|
+
}
|
|
52
|
+
function defaultOrchestratorUrl() {
|
|
53
|
+
return process.env["OPENCARA_URL"] ?? DEFAULT_ORCHESTRATOR_URL;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// src/commands/register.ts
|
|
57
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
58
|
+
import { spawn } from "node:child_process";
|
|
59
|
+
|
|
60
|
+
// ../shared/dist/events.js
|
|
61
|
+
import { z as z2 } from "zod";
|
|
62
|
+
var PlatformSchema = z2.enum(["github"]);
|
|
63
|
+
var PlatformEventSchema = z2.object({
|
|
64
|
+
id: z2.string(),
|
|
65
|
+
platform: PlatformSchema,
|
|
66
|
+
type: z2.string(),
|
|
67
|
+
receivedAt: z2.string().datetime(),
|
|
68
|
+
payload: z2.unknown()
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// ../shared/dist/agent.js
|
|
72
|
+
import { z as z3 } from "zod";
|
|
73
|
+
var AgentRunStatusSchema = z3.enum([
|
|
74
|
+
"queued",
|
|
75
|
+
"assigned",
|
|
76
|
+
"running",
|
|
77
|
+
"succeeded",
|
|
78
|
+
"failed",
|
|
79
|
+
"cancelled"
|
|
80
|
+
]);
|
|
81
|
+
var AgentSpecSchema = z3.object({
|
|
82
|
+
kind: z3.string(),
|
|
83
|
+
command: z3.string(),
|
|
84
|
+
args: z3.array(z3.string()).default([]),
|
|
85
|
+
env: z3.record(z3.string()).default({}),
|
|
86
|
+
cwd: z3.string().optional()
|
|
87
|
+
});
|
|
88
|
+
var AgentRunSchema = z3.object({
|
|
89
|
+
id: z3.string(),
|
|
90
|
+
spec: AgentSpecSchema,
|
|
91
|
+
triggerEventId: z3.string().optional(),
|
|
92
|
+
status: AgentRunStatusSchema,
|
|
93
|
+
hostId: z3.string().nullable(),
|
|
94
|
+
createdAt: z3.string().datetime(),
|
|
95
|
+
startedAt: z3.string().datetime().nullable(),
|
|
96
|
+
finishedAt: z3.string().datetime().nullable(),
|
|
97
|
+
exitCode: z3.number().int().nullable()
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// ../shared/dist/host-protocol.js
|
|
101
|
+
import { z as z4 } from "zod";
|
|
102
|
+
var PairingCreateRequestSchema = z4.object({
|
|
103
|
+
device_secret_hash: z4.string()
|
|
104
|
+
});
|
|
105
|
+
var PairingCreateResponseSchema = z4.object({
|
|
106
|
+
code: z4.string(),
|
|
107
|
+
expires_at: z4.string().datetime()
|
|
108
|
+
});
|
|
109
|
+
var PairingStatusResponseSchema = z4.union([
|
|
110
|
+
z4.object({ status: z4.literal("pending") }),
|
|
111
|
+
z4.object({
|
|
112
|
+
status: z4.literal("confirmed"),
|
|
113
|
+
token: z4.string(),
|
|
114
|
+
agent_host_id: z4.string(),
|
|
115
|
+
device_name: z4.string()
|
|
116
|
+
}),
|
|
117
|
+
z4.object({ status: z4.literal("expired") })
|
|
118
|
+
]);
|
|
119
|
+
var PairingConfirmRequestSchema = z4.object({
|
|
120
|
+
device_name: z4.string().min(1)
|
|
121
|
+
});
|
|
122
|
+
var SystemInfoSchema = z4.object({
|
|
123
|
+
os: z4.string(),
|
|
124
|
+
// os.platform()
|
|
125
|
+
release: z4.string(),
|
|
126
|
+
// os.release()
|
|
127
|
+
arch: z4.string(),
|
|
128
|
+
// os.arch()
|
|
129
|
+
hostname: z4.string(),
|
|
130
|
+
cpu: z4.object({
|
|
131
|
+
model: z4.string(),
|
|
132
|
+
cores: z4.number().int().nonnegative(),
|
|
133
|
+
speedMhz: z4.number().int().nonnegative()
|
|
134
|
+
}),
|
|
135
|
+
memory: z4.object({
|
|
136
|
+
totalBytes: z4.number().nonnegative(),
|
|
137
|
+
freeBytes: z4.number().nonnegative()
|
|
138
|
+
}),
|
|
139
|
+
disk: z4.object({
|
|
140
|
+
path: z4.string(),
|
|
141
|
+
totalBytes: z4.number().nonnegative(),
|
|
142
|
+
freeBytes: z4.number().nonnegative()
|
|
143
|
+
}).optional(),
|
|
144
|
+
ipAddrs: z4.array(z4.string()).default([]),
|
|
145
|
+
uptimeSec: z4.number().nonnegative()
|
|
146
|
+
});
|
|
147
|
+
var HelloMessageSchema = z4.object({
|
|
148
|
+
type: z4.literal("hello"),
|
|
149
|
+
platform: z4.string(),
|
|
150
|
+
version: z4.string(),
|
|
151
|
+
capabilities: z4.array(z4.string()).default([]),
|
|
152
|
+
systemInfo: SystemInfoSchema.optional()
|
|
153
|
+
});
|
|
154
|
+
var JobAssignmentSchema = z4.object({
|
|
155
|
+
type: z4.literal("job"),
|
|
156
|
+
run: AgentRunSchema,
|
|
157
|
+
spec: AgentSpecSchema,
|
|
158
|
+
stdinJson: z4.unknown().optional()
|
|
159
|
+
});
|
|
160
|
+
var LogFrameSchema = z4.object({
|
|
161
|
+
type: z4.literal("log"),
|
|
162
|
+
runId: z4.string(),
|
|
163
|
+
seq: z4.number().int().min(0),
|
|
164
|
+
stream: z4.enum(["stdout", "stderr"]),
|
|
165
|
+
chunk: z4.string()
|
|
166
|
+
});
|
|
167
|
+
var RunDoneSchema = z4.object({
|
|
168
|
+
type: z4.literal("done"),
|
|
169
|
+
runId: z4.string(),
|
|
170
|
+
status: z4.enum(["succeeded", "failed", "cancelled"]),
|
|
171
|
+
exitCode: z4.number().int().nullable().optional(),
|
|
172
|
+
errorMessage: z4.string().optional()
|
|
173
|
+
});
|
|
174
|
+
var HelloAckSchema = z4.object({
|
|
175
|
+
type: z4.literal("hello-ack"),
|
|
176
|
+
agentHostId: z4.string(),
|
|
177
|
+
deviceName: z4.string()
|
|
178
|
+
});
|
|
179
|
+
var PingSchema = z4.object({ type: z4.literal("ping") });
|
|
180
|
+
var PongSchema = z4.object({ type: z4.literal("pong") });
|
|
181
|
+
var ServerToDeviceMessageSchema = z4.discriminatedUnion("type", [
|
|
182
|
+
JobAssignmentSchema,
|
|
183
|
+
HelloAckSchema,
|
|
184
|
+
PingSchema
|
|
185
|
+
]);
|
|
186
|
+
var DeviceToServerMessageSchema = z4.discriminatedUnion("type", [
|
|
187
|
+
HelloMessageSchema,
|
|
188
|
+
LogFrameSchema,
|
|
189
|
+
RunDoneSchema,
|
|
190
|
+
PongSchema
|
|
191
|
+
]);
|
|
192
|
+
var HostRegisterRequestSchema = z4.object({
|
|
193
|
+
hostId: z4.string(),
|
|
194
|
+
hostName: z4.string(),
|
|
195
|
+
capabilities: z4.array(z4.string()).default([]),
|
|
196
|
+
token: z4.string()
|
|
197
|
+
});
|
|
198
|
+
var HostRegisterResponseSchema = z4.object({
|
|
199
|
+
ok: z4.literal(true),
|
|
200
|
+
pollIntervalMs: z4.number().int().positive()
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// src/commands/register.ts
|
|
204
|
+
var POLL_INTERVAL_MS = 2e3;
|
|
205
|
+
async function register(opts = {}) {
|
|
206
|
+
const orchestratorUrl = opts.url ?? defaultOrchestratorUrl();
|
|
207
|
+
if (!opts.forcePair && readConfig()) {
|
|
208
|
+
console.log("Already paired. Use --force-pair to re-pair.");
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const deviceSecret = randomBytes(32).toString("base64url");
|
|
212
|
+
const deviceSecretHash = createHash("sha256").update(deviceSecret).digest("hex");
|
|
213
|
+
const createRes = await fetch(`${orchestratorUrl}/api/devices/pairings`, {
|
|
214
|
+
method: "POST",
|
|
215
|
+
headers: { "content-type": "application/json", "x-requested-with": "fetch" },
|
|
216
|
+
body: JSON.stringify({ device_secret_hash: deviceSecretHash })
|
|
217
|
+
});
|
|
218
|
+
if (!createRes.ok) {
|
|
219
|
+
throw new Error(`pairing create failed: ${createRes.status} ${await createRes.text()}`);
|
|
220
|
+
}
|
|
221
|
+
const { code, expires_at } = PairingCreateResponseSchema.parse(await createRes.json());
|
|
222
|
+
const pairUrl = `${orchestratorUrl}/devices/pair?code=${encodeURIComponent(code)}`;
|
|
223
|
+
console.log(`
|
|
224
|
+
Pairing code: ${code}`);
|
|
225
|
+
console.log(` Open ${pairUrl} in your browser to confirm.`);
|
|
226
|
+
console.log(` Expires at ${expires_at}.
|
|
227
|
+
`);
|
|
228
|
+
openBrowser(pairUrl);
|
|
229
|
+
const expiry = new Date(expires_at).getTime();
|
|
230
|
+
while (Date.now() < expiry) {
|
|
231
|
+
await sleep(POLL_INTERVAL_MS);
|
|
232
|
+
const statusRes = await fetch(
|
|
233
|
+
`${orchestratorUrl}/api/devices/pairings/${encodeURIComponent(code)}/status?secret=${deviceSecret}`
|
|
234
|
+
);
|
|
235
|
+
if (!statusRes.ok) {
|
|
236
|
+
console.error(` status check failed: ${statusRes.status}`);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
const result = PairingStatusResponseSchema.parse(await statusRes.json());
|
|
240
|
+
if (result.status === "pending") {
|
|
241
|
+
process.stdout.write(".");
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (result.status === "expired") {
|
|
245
|
+
throw new Error("pairing expired before confirmation");
|
|
246
|
+
}
|
|
247
|
+
writeConfig({
|
|
248
|
+
orchestratorUrl,
|
|
249
|
+
token: result.token,
|
|
250
|
+
agentHostId: result.agent_host_id,
|
|
251
|
+
deviceName: result.device_name
|
|
252
|
+
});
|
|
253
|
+
console.log(`
|
|
254
|
+
|
|
255
|
+
\u2713 Paired as "${result.device_name}".`);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
throw new Error("pairing timed out");
|
|
259
|
+
}
|
|
260
|
+
function openBrowser(url) {
|
|
261
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
262
|
+
try {
|
|
263
|
+
const child = spawn(cmd, [url], { detached: true, stdio: "ignore" });
|
|
264
|
+
child.on("error", () => void 0);
|
|
265
|
+
child.unref();
|
|
266
|
+
} catch {
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
function sleep(ms) {
|
|
270
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// src/transport/ws-client.ts
|
|
274
|
+
import WebSocket from "ws";
|
|
275
|
+
var HEARTBEAT_MS = 3e4;
|
|
276
|
+
var WsClient = class {
|
|
277
|
+
constructor(opts) {
|
|
278
|
+
this.opts = opts;
|
|
279
|
+
this.backoff = opts.initialBackoffMs ?? 1e3;
|
|
280
|
+
}
|
|
281
|
+
opts;
|
|
282
|
+
ws = null;
|
|
283
|
+
backoff;
|
|
284
|
+
heartbeat = null;
|
|
285
|
+
stopped = false;
|
|
286
|
+
start() {
|
|
287
|
+
this.connect();
|
|
288
|
+
}
|
|
289
|
+
stop() {
|
|
290
|
+
this.stopped = true;
|
|
291
|
+
if (this.heartbeat) clearInterval(this.heartbeat);
|
|
292
|
+
this.ws?.close();
|
|
293
|
+
}
|
|
294
|
+
send(msg) {
|
|
295
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
|
296
|
+
const parsed = DeviceToServerMessageSchema.parse(msg);
|
|
297
|
+
this.ws.send(JSON.stringify(parsed));
|
|
298
|
+
}
|
|
299
|
+
connect() {
|
|
300
|
+
if (this.stopped) return;
|
|
301
|
+
const ws = new WebSocket(this.opts.url, {
|
|
302
|
+
headers: { Authorization: `Bearer ${this.opts.token}` }
|
|
303
|
+
});
|
|
304
|
+
this.ws = ws;
|
|
305
|
+
ws.on("open", () => {
|
|
306
|
+
this.backoff = this.opts.initialBackoffMs ?? 1e3;
|
|
307
|
+
if (this.heartbeat) clearInterval(this.heartbeat);
|
|
308
|
+
this.heartbeat = setInterval(() => {
|
|
309
|
+
if (ws.readyState === WebSocket.OPEN) ws.ping();
|
|
310
|
+
}, HEARTBEAT_MS);
|
|
311
|
+
this.opts.onOpen?.();
|
|
312
|
+
});
|
|
313
|
+
ws.on("message", (raw) => {
|
|
314
|
+
let parsed;
|
|
315
|
+
try {
|
|
316
|
+
parsed = ServerToDeviceMessageSchema.parse(JSON.parse(raw.toString()));
|
|
317
|
+
} catch (err) {
|
|
318
|
+
console.error("[ws] invalid frame", err);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
this.opts.onMessage(parsed);
|
|
322
|
+
});
|
|
323
|
+
ws.on("close", (code, reasonBuf) => {
|
|
324
|
+
const reason = reasonBuf.toString();
|
|
325
|
+
if (this.heartbeat) clearInterval(this.heartbeat);
|
|
326
|
+
this.opts.onClose?.(code, reason);
|
|
327
|
+
if (!this.stopped) this.scheduleReconnect();
|
|
328
|
+
});
|
|
329
|
+
ws.on("error", (err) => {
|
|
330
|
+
console.error("[ws] error", err.message);
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
scheduleReconnect() {
|
|
334
|
+
const max = this.opts.maxBackoffMs ?? 3e4;
|
|
335
|
+
const jittered = Math.floor(this.backoff * (0.5 + Math.random()));
|
|
336
|
+
setTimeout(() => this.connect(), jittered);
|
|
337
|
+
this.backoff = Math.min(this.backoff * 2, max);
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
// src/runner/spawn.ts
|
|
342
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
343
|
+
function runJob(spec, stdinJson, handlers) {
|
|
344
|
+
return new Promise((resolve, reject) => {
|
|
345
|
+
const child = spawn2(spec.command, spec.args ?? [], {
|
|
346
|
+
env: { ...process.env, ...spec.env ?? {} },
|
|
347
|
+
cwd: spec.cwd,
|
|
348
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
349
|
+
});
|
|
350
|
+
child.stdout.setEncoding("utf8");
|
|
351
|
+
child.stderr.setEncoding("utf8");
|
|
352
|
+
child.stdout.on("data", (c) => handlers.onLog("stdout", c));
|
|
353
|
+
child.stderr.on("data", (c) => handlers.onLog("stderr", c));
|
|
354
|
+
child.on("error", reject);
|
|
355
|
+
child.on("close", (code) => resolve({ exitCode: code ?? -1 }));
|
|
356
|
+
if (stdinJson !== void 0) {
|
|
357
|
+
try {
|
|
358
|
+
child.stdin.end(JSON.stringify(stdinJson));
|
|
359
|
+
} catch (err) {
|
|
360
|
+
child.kill();
|
|
361
|
+
reject(err);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
} else {
|
|
365
|
+
child.stdin.end();
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// src/commands/run.ts
|
|
371
|
+
var __dirname = dirname(fileURLToPath(import.meta.url));
|
|
372
|
+
var PKG_VERSION = readPkgVersion();
|
|
373
|
+
var LOG_FLUSH_MS = 800;
|
|
374
|
+
var MAX_CHUNK_SIZE = 4 * 1024;
|
|
375
|
+
async function run(opts = {}) {
|
|
376
|
+
let cfg = readConfig();
|
|
377
|
+
if (!cfg || opts.forcePair) {
|
|
378
|
+
await register({ url: opts.url, forcePair: opts.forcePair });
|
|
379
|
+
cfg = readConfig();
|
|
380
|
+
if (!cfg) throw new Error("pairing did not save a config");
|
|
381
|
+
}
|
|
382
|
+
const wsUrl = cfg.orchestratorUrl.replace(/^http/, "ws") + "/api/devices/ws";
|
|
383
|
+
const client = new WsClient({
|
|
384
|
+
url: wsUrl,
|
|
385
|
+
token: cfg.token,
|
|
386
|
+
onOpen: () => {
|
|
387
|
+
console.log(`[opencara] connected to ${cfg.orchestratorUrl}`);
|
|
388
|
+
client.send({
|
|
389
|
+
type: "hello",
|
|
390
|
+
platform: platform(),
|
|
391
|
+
version: PKG_VERSION,
|
|
392
|
+
capabilities: [],
|
|
393
|
+
systemInfo: collectSystemInfo()
|
|
394
|
+
});
|
|
395
|
+
},
|
|
396
|
+
onMessage: (msg) => handleServerMessage(msg, client, cfg),
|
|
397
|
+
onClose: (code, reason) => {
|
|
398
|
+
console.log(`[opencara] disconnected (code=${code} reason="${reason}")`);
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
console.log(`[opencara] starting as ${cfg.deviceName} (${hostname()})`);
|
|
402
|
+
client.start();
|
|
403
|
+
}
|
|
404
|
+
function handleServerMessage(msg, client, _cfg) {
|
|
405
|
+
if (msg.type === "hello-ack") {
|
|
406
|
+
console.log(`[opencara] acked as ${msg.deviceName} (${msg.agentHostId})`);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
if (msg.type === "ping") return;
|
|
410
|
+
if (msg.type === "job") {
|
|
411
|
+
void executeJob(msg, client);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
async function executeJob(job, client) {
|
|
415
|
+
const runId = job.run.id;
|
|
416
|
+
console.log(`[opencara] job ${runId.slice(-8)}: ${job.spec.command}`);
|
|
417
|
+
let seq = 0;
|
|
418
|
+
let pending = { stdout: "", stderr: "" };
|
|
419
|
+
let flushTimer = null;
|
|
420
|
+
const flush = () => {
|
|
421
|
+
for (const stream of ["stdout", "stderr"]) {
|
|
422
|
+
const chunk = pending[stream];
|
|
423
|
+
if (!chunk) continue;
|
|
424
|
+
let remaining = chunk;
|
|
425
|
+
while (remaining.length > 0) {
|
|
426
|
+
const take = remaining.slice(0, MAX_CHUNK_SIZE);
|
|
427
|
+
client.send({ type: "log", runId, seq: seq++, stream, chunk: take });
|
|
428
|
+
remaining = remaining.slice(MAX_CHUNK_SIZE);
|
|
429
|
+
}
|
|
430
|
+
pending[stream] = "";
|
|
431
|
+
}
|
|
432
|
+
flushTimer = null;
|
|
433
|
+
};
|
|
434
|
+
const scheduleFlush = () => {
|
|
435
|
+
if (flushTimer) return;
|
|
436
|
+
flushTimer = setTimeout(flush, LOG_FLUSH_MS);
|
|
437
|
+
};
|
|
438
|
+
try {
|
|
439
|
+
const result = await runJob(job.spec, job.stdinJson, {
|
|
440
|
+
onLog: (stream, chunk) => {
|
|
441
|
+
pending[stream] += chunk;
|
|
442
|
+
scheduleFlush();
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
flush();
|
|
446
|
+
client.send({
|
|
447
|
+
type: "done",
|
|
448
|
+
runId,
|
|
449
|
+
status: result.exitCode === 0 ? "succeeded" : "failed",
|
|
450
|
+
exitCode: result.exitCode
|
|
451
|
+
});
|
|
452
|
+
console.log(`[opencara] job ${runId.slice(-8)} \u2192 exit ${result.exitCode}`);
|
|
453
|
+
} catch (err) {
|
|
454
|
+
flush();
|
|
455
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
456
|
+
client.send({ type: "done", runId, status: "failed", errorMessage: message });
|
|
457
|
+
console.error(`[opencara] job ${runId.slice(-8)} failed`, message);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
function readPkgVersion() {
|
|
461
|
+
try {
|
|
462
|
+
const raw = readFileSync2(join2(__dirname, "..", "..", "package.json"), "utf8");
|
|
463
|
+
return JSON.parse(raw).version ?? "0.0.0";
|
|
464
|
+
} catch {
|
|
465
|
+
return "0.0.0";
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
function collectSystemInfo() {
|
|
469
|
+
try {
|
|
470
|
+
const cpuList = cpus();
|
|
471
|
+
const head = cpuList[0];
|
|
472
|
+
const ipAddrs = [];
|
|
473
|
+
const ifaces = networkInterfaces();
|
|
474
|
+
for (const list of Object.values(ifaces)) {
|
|
475
|
+
if (!list) continue;
|
|
476
|
+
for (const iface of list) {
|
|
477
|
+
if (!iface.internal && iface.family === "IPv4") ipAddrs.push(iface.address);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
let disk;
|
|
481
|
+
try {
|
|
482
|
+
const stats = statfsSync("/");
|
|
483
|
+
disk = {
|
|
484
|
+
path: "/",
|
|
485
|
+
totalBytes: Number(stats.blocks) * Number(stats.bsize),
|
|
486
|
+
freeBytes: Number(stats.bavail) * Number(stats.bsize)
|
|
487
|
+
};
|
|
488
|
+
} catch {
|
|
489
|
+
disk = void 0;
|
|
490
|
+
}
|
|
491
|
+
return {
|
|
492
|
+
os: platform(),
|
|
493
|
+
release: release(),
|
|
494
|
+
arch: arch(),
|
|
495
|
+
hostname: hostname(),
|
|
496
|
+
cpu: {
|
|
497
|
+
model: head?.model.trim() ?? "unknown",
|
|
498
|
+
cores: cpuList.length,
|
|
499
|
+
speedMhz: head?.speed ?? 0
|
|
500
|
+
},
|
|
501
|
+
memory: { totalBytes: totalmem(), freeBytes: freemem() },
|
|
502
|
+
disk,
|
|
503
|
+
ipAddrs,
|
|
504
|
+
uptimeSec: Math.floor(uptime())
|
|
505
|
+
};
|
|
506
|
+
} catch (err) {
|
|
507
|
+
console.warn("[opencara] system info collection failed", err);
|
|
508
|
+
return void 0;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// src/commands/status.ts
|
|
513
|
+
async function status() {
|
|
514
|
+
const cfg = readConfig();
|
|
515
|
+
if (!cfg) {
|
|
516
|
+
console.log("Not paired. Run 'opencara' to pair and start accepting jobs.");
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
console.log(`Paired:`);
|
|
520
|
+
console.log(` Device: ${cfg.deviceName}`);
|
|
521
|
+
console.log(` Host ID: ${cfg.agentHostId}`);
|
|
522
|
+
console.log(` Server: ${cfg.orchestratorUrl}`);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// src/commands/logout.ts
|
|
526
|
+
async function logout() {
|
|
527
|
+
const cfg = readConfig();
|
|
528
|
+
if (!cfg) {
|
|
529
|
+
console.log("Not paired.");
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
try {
|
|
533
|
+
const res = await fetch(
|
|
534
|
+
`${cfg.orchestratorUrl}/api/devices/${cfg.agentHostId}/revoke`,
|
|
535
|
+
{
|
|
536
|
+
method: "POST",
|
|
537
|
+
headers: {
|
|
538
|
+
authorization: `Bearer ${cfg.token}`,
|
|
539
|
+
"x-requested-with": "fetch"
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
);
|
|
543
|
+
if (!res.ok) {
|
|
544
|
+
console.warn(`server revoke responded ${res.status}; clearing local config anyway`);
|
|
545
|
+
}
|
|
546
|
+
} catch (err) {
|
|
547
|
+
console.warn(`server revoke failed: ${err.message}; clearing local config anyway`);
|
|
548
|
+
}
|
|
549
|
+
clearConfig();
|
|
550
|
+
console.log("Removed local credentials.");
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// src/bin.ts
|
|
554
|
+
async function main() {
|
|
555
|
+
const argv = process.argv.slice(2);
|
|
556
|
+
const cmd = argv[0];
|
|
557
|
+
switch (cmd) {
|
|
558
|
+
case void 0:
|
|
559
|
+
break;
|
|
560
|
+
case "status":
|
|
561
|
+
await status();
|
|
562
|
+
return;
|
|
563
|
+
case "logout":
|
|
564
|
+
await logout();
|
|
565
|
+
return;
|
|
566
|
+
case "--help":
|
|
567
|
+
case "-h":
|
|
568
|
+
printHelp();
|
|
569
|
+
return;
|
|
570
|
+
default:
|
|
571
|
+
if (!cmd.startsWith("-")) {
|
|
572
|
+
console.error(`unknown command: ${cmd}`);
|
|
573
|
+
printHelp();
|
|
574
|
+
process.exit(1);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
await run({
|
|
578
|
+
forcePair: argv.includes("--force-pair"),
|
|
579
|
+
url: pickFlag(argv, "--url")
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
function pickFlag(argv, name) {
|
|
583
|
+
const i = argv.indexOf(name);
|
|
584
|
+
if (i === -1) return void 0;
|
|
585
|
+
return argv[i + 1];
|
|
586
|
+
}
|
|
587
|
+
function printHelp() {
|
|
588
|
+
console.log(`opencara \u2014 agent host CLI
|
|
589
|
+
|
|
590
|
+
Usage:
|
|
591
|
+
opencara [--url URL] [--force-pair] Pair (if needed) and start accepting jobs.
|
|
592
|
+
opencara status Show pairing state.
|
|
593
|
+
opencara logout Forget the saved pairing.
|
|
594
|
+
|
|
595
|
+
Options:
|
|
596
|
+
--url URL Orchestrator URL (default: https://opencara.com,
|
|
597
|
+
or $OPENCARA_URL).
|
|
598
|
+
--force-pair Re-pair even if already paired.
|
|
599
|
+
`);
|
|
600
|
+
}
|
|
601
|
+
main().catch((err) => {
|
|
602
|
+
console.error(err instanceof Error ? err.message : err);
|
|
603
|
+
process.exit(1);
|
|
604
|
+
});
|
package/package.json
CHANGED
|
@@ -1,58 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencara",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
5
|
-
"type": "module",
|
|
3
|
+
"version": "0.100.1",
|
|
4
|
+
"description": "OpenCara agent-host CLI: register a machine as an agent host and run dispatched agents.",
|
|
6
5
|
"license": "MIT",
|
|
7
|
-
"author": "OpenCara <https://github.com/OpenCara>",
|
|
8
|
-
"homepage": "https://github.com/OpenCara/OpenCara#readme",
|
|
9
6
|
"repository": {
|
|
10
7
|
"type": "git",
|
|
11
8
|
"url": "https://github.com/OpenCara/OpenCara.git",
|
|
12
9
|
"directory": "packages/cli"
|
|
13
10
|
},
|
|
14
|
-
"
|
|
15
|
-
|
|
16
|
-
},
|
|
17
|
-
"keywords": [
|
|
18
|
-
"ai",
|
|
19
|
-
"code-review",
|
|
20
|
-
"github",
|
|
21
|
-
"pull-request",
|
|
22
|
-
"cli",
|
|
23
|
-
"agent",
|
|
24
|
-
"review",
|
|
25
|
-
"openai",
|
|
26
|
-
"claude",
|
|
27
|
-
"gemini"
|
|
28
|
-
],
|
|
29
|
-
"engines": {
|
|
30
|
-
"node": ">=20"
|
|
31
|
-
},
|
|
11
|
+
"homepage": "https://opencara.com",
|
|
12
|
+
"type": "module",
|
|
32
13
|
"bin": {
|
|
33
|
-
"opencara": "dist/
|
|
14
|
+
"opencara": "./dist/bin.js"
|
|
34
15
|
},
|
|
16
|
+
"main": "./dist/bin.js",
|
|
35
17
|
"files": [
|
|
36
|
-
"dist"
|
|
37
|
-
"README.md"
|
|
18
|
+
"dist/bin.js"
|
|
38
19
|
],
|
|
39
|
-
"scripts": {
|
|
40
|
-
"build": "tsup",
|
|
41
|
-
"dev": "tsx src/index.ts",
|
|
42
|
-
"clean": "rm -rf dist *.tsbuildinfo"
|
|
43
|
-
},
|
|
44
20
|
"dependencies": {
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
-
"smol-toml": "^1.6.1"
|
|
21
|
+
"ws": "^8.18.0",
|
|
22
|
+
"zod": "^3.24.1"
|
|
48
23
|
},
|
|
49
24
|
"devDependencies": {
|
|
50
|
-
"@
|
|
51
|
-
"
|
|
52
|
-
"
|
|
53
|
-
"@
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
"
|
|
25
|
+
"@types/ws": "^8.5.13",
|
|
26
|
+
"esbuild": "^0.27.3",
|
|
27
|
+
"tsx": "^4.19.2",
|
|
28
|
+
"@opencara/shared": "0.0.0"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "tsc -b && node build.mjs",
|
|
32
|
+
"dev": "tsx watch src/bin.ts",
|
|
33
|
+
"start": "node dist/bin.js",
|
|
34
|
+
"typecheck": "tsc -b",
|
|
35
|
+
"clean": "rm -rf dist *.tsbuildinfo"
|
|
57
36
|
}
|
|
58
|
-
}
|
|
37
|
+
}
|