openwrk 0.1.0

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.
Files changed (3) hide show
  1. package/README.md +63 -0
  2. package/dist/cli.js +830 -0
  3. package/package.json +47 -0
package/README.md ADDED
@@ -0,0 +1,63 @@
1
+ # Openwrk
2
+
3
+ Headless host orchestrator for OpenCode + OpenWork server + Owpenbot. This is a CLI-first way to run host mode without the desktop UI.
4
+
5
+ ## Quick start
6
+
7
+ ```bash
8
+ npm install -g openwrk
9
+ openwrk start --workspace /path/to/workspace --approval auto
10
+ ```
11
+
12
+ Or from source:
13
+
14
+ ```bash
15
+ pnpm --filter openwrk dev -- \
16
+ start --workspace /path/to/workspace --approval auto
17
+ ```
18
+
19
+ The command prints pairing details (OpenWork server URL + token, OpenCode URL + auth) so remote OpenWork clients can connect.
20
+
21
+ ## Pairing notes
22
+
23
+ - Use the **OpenWork connect URL** and **client token** to connect a remote OpenWork client.
24
+ - The OpenWork server advertises the **OpenCode connect URL** plus optional basic auth credentials to the client.
25
+
26
+ ## Approvals (manual mode)
27
+
28
+ ```bash
29
+ openwrk approvals list \
30
+ --openwork-url http://<host>:8787 \
31
+ --host-token <token>
32
+
33
+ openwrk approvals reply <id> --allow \
34
+ --openwork-url http://<host>:8787 \
35
+ --host-token <token>
36
+ ```
37
+
38
+ ## Health checks
39
+
40
+ ```bash
41
+ openwrk status \
42
+ --openwork-url http://<host>:8787 \
43
+ --opencode-url http://<host>:4096
44
+ ```
45
+
46
+ ## Smoke checks
47
+
48
+ ```bash
49
+ openwrk start --workspace /path/to/workspace --check --check-events
50
+ ```
51
+
52
+ This starts the services, verifies health + SSE events, then exits cleanly.
53
+
54
+ ## Local development
55
+
56
+ Point to source CLIs for fast iteration:
57
+
58
+ ```bash
59
+ openwrk start \
60
+ --workspace /path/to/workspace \
61
+ --openwork-server-bin packages/server/src/cli.ts \
62
+ --owpenbot-bin packages/owpenbot/src/cli.ts
63
+ ```
package/dist/cli.js ADDED
@@ -0,0 +1,830 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import { randomUUID } from "node:crypto";
4
+ import { mkdir, stat, writeFile } from "node:fs/promises";
5
+ import { createServer } from "node:net";
6
+ import { hostname, networkInterfaces } from "node:os";
7
+ import { join, resolve } from "node:path";
8
+ import { once } from "node:events";
9
+ import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
10
+ const VERSION = "0.1.0";
11
+ const DEFAULT_OPENWORK_PORT = 8787;
12
+ const DEFAULT_APPROVAL_TIMEOUT = 30000;
13
+ const DEFAULT_OPENCODE_USERNAME = "opencode";
14
+ function parseArgs(argv) {
15
+ const flags = new Map();
16
+ const positionals = [];
17
+ for (let i = 0; i < argv.length; i += 1) {
18
+ const arg = argv[i];
19
+ if (!arg)
20
+ continue;
21
+ if (arg === "-h") {
22
+ flags.set("help", true);
23
+ continue;
24
+ }
25
+ if (arg === "-v") {
26
+ flags.set("version", true);
27
+ continue;
28
+ }
29
+ if (!arg.startsWith("--")) {
30
+ positionals.push(arg);
31
+ continue;
32
+ }
33
+ const trimmed = arg.slice(2);
34
+ if (!trimmed)
35
+ continue;
36
+ if (trimmed.startsWith("no-")) {
37
+ flags.set(trimmed.slice(3), false);
38
+ continue;
39
+ }
40
+ const [key, inlineValue] = trimmed.split("=");
41
+ if (inlineValue !== undefined) {
42
+ flags.set(key, inlineValue);
43
+ continue;
44
+ }
45
+ const next = argv[i + 1];
46
+ if (next && !next.startsWith("--")) {
47
+ flags.set(key, next);
48
+ i += 1;
49
+ }
50
+ else {
51
+ flags.set(key, true);
52
+ }
53
+ }
54
+ return { positionals, flags };
55
+ }
56
+ function parseList(value) {
57
+ if (!value)
58
+ return [];
59
+ const trimmed = value.trim();
60
+ if (!trimmed)
61
+ return [];
62
+ if (trimmed.startsWith("[")) {
63
+ try {
64
+ const parsed = JSON.parse(trimmed);
65
+ if (Array.isArray(parsed))
66
+ return parsed.map((item) => String(item)).filter(Boolean);
67
+ }
68
+ catch {
69
+ return [];
70
+ }
71
+ }
72
+ return trimmed
73
+ .split(/[,;]/)
74
+ .map((item) => item.trim())
75
+ .filter(Boolean);
76
+ }
77
+ function readFlag(flags, key) {
78
+ const value = flags.get(key);
79
+ if (value === undefined || value === null)
80
+ return undefined;
81
+ if (typeof value === "boolean")
82
+ return value ? "true" : "false";
83
+ return value;
84
+ }
85
+ function readBool(flags, key, fallback, envKey) {
86
+ const raw = flags.get(key);
87
+ if (raw !== undefined) {
88
+ if (typeof raw === "boolean")
89
+ return raw;
90
+ const normalized = String(raw).toLowerCase();
91
+ if (["false", "0", "no"].includes(normalized))
92
+ return false;
93
+ if (["true", "1", "yes"].includes(normalized))
94
+ return true;
95
+ }
96
+ const envValue = envKey ? process.env[envKey] : undefined;
97
+ if (envValue) {
98
+ const normalized = envValue.toLowerCase();
99
+ if (["false", "0", "no"].includes(normalized))
100
+ return false;
101
+ if (["true", "1", "yes"].includes(normalized))
102
+ return true;
103
+ }
104
+ return fallback;
105
+ }
106
+ function readNumber(flags, key, fallback, envKey) {
107
+ const raw = flags.get(key);
108
+ if (raw !== undefined) {
109
+ const parsed = Number(raw);
110
+ if (!Number.isNaN(parsed))
111
+ return parsed;
112
+ }
113
+ if (envKey) {
114
+ const envValue = process.env[envKey];
115
+ if (envValue) {
116
+ const parsed = Number(envValue);
117
+ if (!Number.isNaN(parsed))
118
+ return parsed;
119
+ }
120
+ }
121
+ return fallback;
122
+ }
123
+ async function fileExists(path) {
124
+ try {
125
+ await stat(path);
126
+ return true;
127
+ }
128
+ catch {
129
+ return false;
130
+ }
131
+ }
132
+ async function ensureWorkspace(workspace) {
133
+ const resolved = resolve(workspace);
134
+ await mkdir(resolved, { recursive: true });
135
+ const configPath = join(resolved, "opencode.json");
136
+ if (!(await fileExists(configPath))) {
137
+ const payload = JSON.stringify({ "$schema": "https://opencode.ai/config.json" }, null, 2);
138
+ await writeFile(configPath, `${payload}\n`, "utf8");
139
+ }
140
+ return resolved;
141
+ }
142
+ async function canBind(host, port) {
143
+ return new Promise((resolve) => {
144
+ const server = createServer();
145
+ server.once("error", () => {
146
+ server.close();
147
+ resolve(false);
148
+ });
149
+ server.listen(port, host, () => {
150
+ server.close(() => resolve(true));
151
+ });
152
+ });
153
+ }
154
+ async function findFreePort(host) {
155
+ return new Promise((resolve, reject) => {
156
+ const server = createServer();
157
+ server.unref();
158
+ server.once("error", (err) => reject(err));
159
+ server.listen(0, host, () => {
160
+ const address = server.address();
161
+ if (!address || typeof address === "string") {
162
+ server.close();
163
+ reject(new Error("Failed to allocate free port"));
164
+ return;
165
+ }
166
+ const port = address.port;
167
+ server.close(() => resolve(port));
168
+ });
169
+ });
170
+ }
171
+ async function resolvePort(preferred, host, fallback) {
172
+ if (preferred && (await canBind(host, preferred))) {
173
+ return preferred;
174
+ }
175
+ if (fallback && fallback !== preferred && (await canBind(host, fallback))) {
176
+ return fallback;
177
+ }
178
+ return findFreePort(host);
179
+ }
180
+ function resolveLanIp() {
181
+ const interfaces = networkInterfaces();
182
+ for (const key of Object.keys(interfaces)) {
183
+ const entries = interfaces[key];
184
+ if (!entries)
185
+ continue;
186
+ for (const entry of entries) {
187
+ if (entry.family !== "IPv4" || entry.internal)
188
+ continue;
189
+ return entry.address;
190
+ }
191
+ }
192
+ return null;
193
+ }
194
+ function resolveConnectUrl(port, overrideHost) {
195
+ if (overrideHost) {
196
+ const trimmed = overrideHost.trim();
197
+ if (trimmed) {
198
+ const url = `http://${trimmed}:${port}`;
199
+ return { connectUrl: url, lanUrl: url };
200
+ }
201
+ }
202
+ const host = hostname().trim();
203
+ const mdnsUrl = host ? `http://${host.replace(/\.local$/, "")}.local:${port}` : undefined;
204
+ const lanIp = resolveLanIp();
205
+ const lanUrl = lanIp ? `http://${lanIp}:${port}` : undefined;
206
+ const connectUrl = lanUrl ?? mdnsUrl;
207
+ return { connectUrl, lanUrl, mdnsUrl };
208
+ }
209
+ function encodeBasicAuth(username, password) {
210
+ return Buffer.from(`${username}:${password}`, "utf8").toString("base64");
211
+ }
212
+ function unwrap(result) {
213
+ if (result.data !== undefined) {
214
+ return result.data;
215
+ }
216
+ const message = result.error instanceof Error
217
+ ? result.error.message
218
+ : typeof result.error === "string"
219
+ ? result.error
220
+ : JSON.stringify(result.error);
221
+ throw new Error(message || "Unknown error");
222
+ }
223
+ function prefixStream(stream, label, level) {
224
+ if (!stream)
225
+ return;
226
+ stream.setEncoding("utf8");
227
+ let buffer = "";
228
+ stream.on("data", (chunk) => {
229
+ buffer += chunk;
230
+ const lines = buffer.split(/\r?\n/);
231
+ buffer = lines.pop() ?? "";
232
+ for (const line of lines) {
233
+ if (!line.trim())
234
+ continue;
235
+ const message = `[${label}] ${line}`;
236
+ if (level === "stderr") {
237
+ console.error(message);
238
+ }
239
+ else {
240
+ console.log(message);
241
+ }
242
+ }
243
+ });
244
+ stream.on("end", () => {
245
+ if (!buffer.trim())
246
+ return;
247
+ const message = `[${label}] ${buffer}`;
248
+ if (level === "stderr") {
249
+ console.error(message);
250
+ }
251
+ else {
252
+ console.log(message);
253
+ }
254
+ });
255
+ }
256
+ function resolveBinCommand(bin) {
257
+ if (bin.endsWith(".ts")) {
258
+ return { command: "bun", prefixArgs: [bin, "--"] };
259
+ }
260
+ if (bin.endsWith(".js")) {
261
+ return { command: "node", prefixArgs: [bin, "--"] };
262
+ }
263
+ return { command: bin, prefixArgs: [] };
264
+ }
265
+ function resolveBinPath(bin) {
266
+ if (bin.includes("/") || bin.startsWith(".")) {
267
+ return resolve(process.cwd(), bin);
268
+ }
269
+ return bin;
270
+ }
271
+ async function waitForHealthy(url, timeoutMs = 10_000, pollMs = 250) {
272
+ const start = Date.now();
273
+ let lastError = null;
274
+ while (Date.now() - start < timeoutMs) {
275
+ try {
276
+ const response = await fetch(`${url.replace(/\/$/, "")}/health`);
277
+ if (response.ok)
278
+ return;
279
+ lastError = `HTTP ${response.status}`;
280
+ }
281
+ catch (error) {
282
+ lastError = error instanceof Error ? error.message : String(error);
283
+ }
284
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
285
+ }
286
+ throw new Error(lastError ?? "Timed out waiting for health check");
287
+ }
288
+ async function waitForOpencodeHealthy(client, timeoutMs = 10_000, pollMs = 250) {
289
+ const start = Date.now();
290
+ let lastError = null;
291
+ while (Date.now() - start < timeoutMs) {
292
+ try {
293
+ const health = unwrap(await client.global.health());
294
+ if (health?.healthy)
295
+ return health;
296
+ lastError = "Server reported unhealthy";
297
+ }
298
+ catch (error) {
299
+ lastError = error instanceof Error ? error.message : String(error);
300
+ }
301
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
302
+ }
303
+ throw new Error(lastError ?? "Timed out waiting for OpenCode health");
304
+ }
305
+ function printHelp() {
306
+ const message = [
307
+ "openwrk",
308
+ "",
309
+ "Usage:",
310
+ " openwrk start [--workspace <path>] [options]",
311
+ " openwrk approvals list --openwork-url <url> --host-token <token>",
312
+ " openwrk approvals reply <id> --allow|--deny --openwork-url <url> --host-token <token>",
313
+ " openwrk status [--openwork-url <url>] [--opencode-url <url>]",
314
+ "",
315
+ "Commands:",
316
+ " start Start OpenCode + OpenWork server + Owpenbot",
317
+ " approvals list List pending approval requests",
318
+ " approvals reply <id> Approve or deny a request",
319
+ " status Check OpenCode/OpenWork health",
320
+ "",
321
+ "Options:",
322
+ " --workspace <path> Workspace directory (default: cwd)",
323
+ " --opencode-bin <path> Path to opencode binary (default: opencode)",
324
+ " --opencode-host <host> Bind host for opencode serve (default: 0.0.0.0)",
325
+ " --opencode-port <port> Port for opencode serve (default: random)",
326
+ " --opencode-auth Enable OpenCode basic auth (default: true)",
327
+ " --no-opencode-auth Disable OpenCode basic auth",
328
+ " --opencode-username <u> OpenCode basic auth username",
329
+ " --opencode-password <p> OpenCode basic auth password",
330
+ " --openwork-host <host> Bind host for openwork-server (default: 0.0.0.0)",
331
+ " --openwork-port <port> Port for openwork-server (default: 8787)",
332
+ " --openwork-token <token> Client token for openwork-server",
333
+ " --openwork-host-token <t> Host token for approvals",
334
+ " --approval <mode> manual | auto (default: manual)",
335
+ " --approval-timeout <ms> Approval timeout in ms",
336
+ " --read-only Start OpenWork server in read-only mode",
337
+ " --cors <origins> Comma-separated CORS origins or *",
338
+ " --connect-host <host> Override LAN host used for pairing URLs",
339
+ " --openwork-server-bin <p> Path to openwork-server binary",
340
+ " --owpenbot-bin <path> Path to owpenbot binary (default: owpenbot)",
341
+ " --no-owpenbot Disable owpenbot sidecar",
342
+ " --check Run health checks then exit",
343
+ " --check-events Verify SSE events during check",
344
+ " --json Output JSON when applicable",
345
+ " --help Show help",
346
+ " --version Show version",
347
+ ].join("\n");
348
+ console.log(message);
349
+ }
350
+ async function stopChild(child, timeoutMs = 2500) {
351
+ if (child.exitCode !== null || child.signalCode !== null)
352
+ return;
353
+ try {
354
+ child.kill("SIGTERM");
355
+ }
356
+ catch {
357
+ return;
358
+ }
359
+ const exited = await Promise.race([
360
+ once(child, "exit").then(() => true),
361
+ new Promise((resolve) => setTimeout(resolve, timeoutMs, false)),
362
+ ]);
363
+ if (exited)
364
+ return;
365
+ try {
366
+ child.kill("SIGKILL");
367
+ }
368
+ catch {
369
+ return;
370
+ }
371
+ await Promise.race([
372
+ once(child, "exit").then(() => true),
373
+ new Promise((resolve) => setTimeout(resolve, timeoutMs, false)),
374
+ ]);
375
+ }
376
+ async function startOpencode(options) {
377
+ const args = ["serve", "--hostname", options.bindHost, "--port", String(options.port)];
378
+ for (const origin of options.corsOrigins) {
379
+ args.push("--cors", origin);
380
+ }
381
+ const child = spawn(options.bin, args, {
382
+ cwd: options.workspace,
383
+ stdio: ["ignore", "pipe", "pipe"],
384
+ env: {
385
+ ...process.env,
386
+ OPENCODE_CLIENT: "openwrk",
387
+ OPENWORK: "1",
388
+ ...(options.username ? { OPENCODE_SERVER_USERNAME: options.username } : {}),
389
+ ...(options.password ? { OPENCODE_SERVER_PASSWORD: options.password } : {}),
390
+ },
391
+ });
392
+ prefixStream(child.stdout, "opencode", "stdout");
393
+ prefixStream(child.stderr, "opencode", "stderr");
394
+ return child;
395
+ }
396
+ async function startOpenworkServer(options) {
397
+ const args = [
398
+ "--host",
399
+ options.host,
400
+ "--port",
401
+ String(options.port),
402
+ "--token",
403
+ options.token,
404
+ "--host-token",
405
+ options.hostToken,
406
+ "--workspace",
407
+ options.workspace,
408
+ "--approval",
409
+ options.approvalMode,
410
+ "--approval-timeout",
411
+ String(options.approvalTimeoutMs),
412
+ ];
413
+ if (options.readOnly) {
414
+ args.push("--read-only");
415
+ }
416
+ if (options.corsOrigins.length) {
417
+ args.push("--cors", options.corsOrigins.join(","));
418
+ }
419
+ if (options.opencodeBaseUrl) {
420
+ args.push("--opencode-base-url", options.opencodeBaseUrl);
421
+ }
422
+ if (options.opencodeDirectory) {
423
+ args.push("--opencode-directory", options.opencodeDirectory);
424
+ }
425
+ if (options.opencodeUsername) {
426
+ args.push("--opencode-username", options.opencodeUsername);
427
+ }
428
+ if (options.opencodePassword) {
429
+ args.push("--opencode-password", options.opencodePassword);
430
+ }
431
+ const resolved = resolveBinCommand(options.bin);
432
+ const child = spawn(resolved.command, [...resolved.prefixArgs, ...args], {
433
+ cwd: options.workspace,
434
+ stdio: ["ignore", "pipe", "pipe"],
435
+ });
436
+ prefixStream(child.stdout, "openwork-server", "stdout");
437
+ prefixStream(child.stderr, "openwork-server", "stderr");
438
+ return child;
439
+ }
440
+ async function startOwpenbot(options) {
441
+ const args = ["start", options.workspace];
442
+ if (options.opencodeUrl) {
443
+ args.push("--opencode-url", options.opencodeUrl);
444
+ }
445
+ const resolved = resolveBinCommand(options.bin);
446
+ const child = spawn(resolved.command, [...resolved.prefixArgs, ...args], {
447
+ cwd: options.workspace,
448
+ stdio: ["ignore", "pipe", "pipe"],
449
+ env: {
450
+ ...process.env,
451
+ ...(options.opencodeUsername ? { OPENCODE_SERVER_USERNAME: options.opencodeUsername } : {}),
452
+ ...(options.opencodePassword ? { OPENCODE_SERVER_PASSWORD: options.opencodePassword } : {}),
453
+ },
454
+ });
455
+ prefixStream(child.stdout, "owpenbot", "stdout");
456
+ prefixStream(child.stderr, "owpenbot", "stderr");
457
+ return child;
458
+ }
459
+ async function runChecks(input) {
460
+ const headers = { Authorization: `Bearer ${input.openworkToken}` };
461
+ const workspaces = await fetchJson(`${input.openworkUrl}/workspaces`, { headers });
462
+ if (!workspaces?.items?.length) {
463
+ throw new Error("OpenWork server returned no workspaces");
464
+ }
465
+ const workspaceId = workspaces.items[0].id;
466
+ await fetchJson(`${input.openworkUrl}/workspace/${workspaceId}/config`, { headers });
467
+ const created = await input.opencodeClient.session.create({ title: "OpenWork headless check" });
468
+ const createdSession = unwrap(created);
469
+ unwrap(await input.opencodeClient.session.messages({ sessionID: createdSession.id, limit: 10 }));
470
+ if (input.checkEvents) {
471
+ const events = [];
472
+ const controller = new AbortController();
473
+ const subscription = await input.opencodeClient.event.subscribe(undefined, { signal: controller.signal });
474
+ const reader = (async () => {
475
+ try {
476
+ for await (const raw of subscription.stream) {
477
+ const normalized = normalizeEvent(raw);
478
+ if (!normalized)
479
+ continue;
480
+ events.push(normalized);
481
+ if (events.length >= 10)
482
+ break;
483
+ }
484
+ }
485
+ catch {
486
+ // ignore
487
+ }
488
+ })();
489
+ unwrap(await input.opencodeClient.session.create({ title: "OpenWork headless check events" }));
490
+ await new Promise((resolve) => setTimeout(resolve, 1200));
491
+ controller.abort();
492
+ await Promise.race([reader, new Promise((resolve) => setTimeout(resolve, 500))]);
493
+ if (!events.length) {
494
+ throw new Error("No SSE events observed during check");
495
+ }
496
+ }
497
+ }
498
+ async function fetchJson(url, init) {
499
+ const response = await fetch(url, init);
500
+ let payload = null;
501
+ try {
502
+ payload = await response.json();
503
+ }
504
+ catch {
505
+ payload = null;
506
+ }
507
+ if (!response.ok) {
508
+ const message = payload?.message ? ` ${payload.message}` : "";
509
+ throw new Error(`HTTP ${response.status}${message}`);
510
+ }
511
+ return payload;
512
+ }
513
+ function normalizeEvent(raw) {
514
+ if (!raw || typeof raw !== "object")
515
+ return null;
516
+ const record = raw;
517
+ if (typeof record.type === "string")
518
+ return { type: record.type };
519
+ const payload = record.payload;
520
+ if (payload && typeof payload.type === "string")
521
+ return { type: payload.type };
522
+ return null;
523
+ }
524
+ async function runApprovals(args) {
525
+ const subcommand = args.positionals[1];
526
+ if (!subcommand || (subcommand !== "list" && subcommand !== "reply")) {
527
+ throw new Error("approvals requires 'list' or 'reply'");
528
+ }
529
+ const openworkUrl = readFlag(args.flags, "openwork-url") ??
530
+ process.env.OPENWORK_URL ??
531
+ process.env.OPENWORK_SERVER_URL ??
532
+ "";
533
+ const hostToken = readFlag(args.flags, "host-token") ?? process.env.OPENWORK_HOST_TOKEN ?? "";
534
+ if (!openworkUrl || !hostToken) {
535
+ throw new Error("openwork-url and host-token are required for approvals");
536
+ }
537
+ const headers = {
538
+ "Content-Type": "application/json",
539
+ "X-OpenWork-Host-Token": hostToken,
540
+ };
541
+ if (subcommand === "list") {
542
+ const response = await fetch(`${openworkUrl.replace(/\/$/, "")}/approvals`, { headers });
543
+ if (!response.ok) {
544
+ throw new Error(`Failed to list approvals: ${response.status}`);
545
+ }
546
+ const body = await response.json();
547
+ console.log(JSON.stringify(body, null, 2));
548
+ return;
549
+ }
550
+ const approvalId = args.positionals[2];
551
+ if (!approvalId) {
552
+ throw new Error("approval id is required for approvals reply");
553
+ }
554
+ const allow = readBool(args.flags, "allow", false);
555
+ const deny = readBool(args.flags, "deny", false);
556
+ if (allow === deny) {
557
+ throw new Error("use --allow or --deny");
558
+ }
559
+ const payload = { reply: allow ? "allow" : "deny" };
560
+ const response = await fetch(`${openworkUrl.replace(/\/$/, "")}/approvals/${approvalId}`, {
561
+ method: "POST",
562
+ headers,
563
+ body: JSON.stringify(payload),
564
+ });
565
+ if (!response.ok) {
566
+ throw new Error(`Failed to reply to approval: ${response.status}`);
567
+ }
568
+ const body = await response.json();
569
+ console.log(JSON.stringify(body, null, 2));
570
+ }
571
+ async function runStatus(args) {
572
+ const openworkUrl = readFlag(args.flags, "openwork-url") ?? process.env.OPENWORK_URL ?? "";
573
+ const opencodeUrl = readFlag(args.flags, "opencode-url") ?? process.env.OPENCODE_URL ?? "";
574
+ const username = readFlag(args.flags, "opencode-username") ?? process.env.OPENCODE_SERVER_USERNAME;
575
+ const password = readFlag(args.flags, "opencode-password") ?? process.env.OPENCODE_SERVER_PASSWORD;
576
+ const outputJson = readBool(args.flags, "json", false);
577
+ const status = {};
578
+ if (openworkUrl) {
579
+ try {
580
+ await waitForHealthy(openworkUrl, 5000, 400);
581
+ status.openwork = { ok: true, url: openworkUrl };
582
+ }
583
+ catch (error) {
584
+ status.openwork = { ok: false, url: openworkUrl, error: String(error) };
585
+ }
586
+ }
587
+ if (opencodeUrl) {
588
+ try {
589
+ const headers = {};
590
+ if (username && password) {
591
+ headers.Authorization = `Basic ${encodeBasicAuth(username, password)}`;
592
+ }
593
+ const client = createOpencodeClient({
594
+ baseUrl: opencodeUrl,
595
+ headers,
596
+ });
597
+ const health = await waitForOpencodeHealthy(client, 5000, 400);
598
+ status.opencode = { ok: true, url: opencodeUrl, health };
599
+ }
600
+ catch (error) {
601
+ status.opencode = { ok: false, url: opencodeUrl, error: String(error) };
602
+ }
603
+ }
604
+ if (outputJson) {
605
+ console.log(JSON.stringify(status, null, 2));
606
+ }
607
+ else {
608
+ if (status.openwork) {
609
+ const openwork = status.openwork;
610
+ console.log(`OpenWork server: ${openwork.ok ? "ok" : "error"} (${openwork.url})`);
611
+ if (openwork.error)
612
+ console.log(` ${openwork.error}`);
613
+ }
614
+ if (status.opencode) {
615
+ const opencode = status.opencode;
616
+ console.log(`OpenCode server: ${opencode.ok ? "ok" : "error"} (${opencode.url})`);
617
+ if (opencode.error)
618
+ console.log(` ${opencode.error}`);
619
+ }
620
+ }
621
+ }
622
+ async function runStart(args) {
623
+ const outputJson = readBool(args.flags, "json", false);
624
+ const checkOnly = readBool(args.flags, "check", false);
625
+ const checkEvents = readBool(args.flags, "check-events", false);
626
+ const workspace = readFlag(args.flags, "workspace") ?? process.env.OPENWORK_WORKSPACE ?? process.cwd();
627
+ const resolvedWorkspace = await ensureWorkspace(workspace);
628
+ const opencodeBin = readFlag(args.flags, "opencode-bin") ?? process.env.OPENWORK_OPENCODE_BIN ?? "opencode";
629
+ const opencodeBindHost = readFlag(args.flags, "opencode-host") ?? process.env.OPENWORK_OPENCODE_BIND_HOST ?? "0.0.0.0";
630
+ const opencodePort = await resolvePort(readNumber(args.flags, "opencode-port", undefined, "OPENWORK_OPENCODE_PORT"), "127.0.0.1");
631
+ const opencodeAuth = readBool(args.flags, "opencode-auth", true, "OPENWORK_OPENCODE_AUTH");
632
+ const opencodeUsername = opencodeAuth
633
+ ? readFlag(args.flags, "opencode-username") ?? process.env.OPENWORK_OPENCODE_USERNAME ?? DEFAULT_OPENCODE_USERNAME
634
+ : undefined;
635
+ const opencodePassword = opencodeAuth
636
+ ? readFlag(args.flags, "opencode-password") ?? process.env.OPENWORK_OPENCODE_PASSWORD ?? randomUUID()
637
+ : undefined;
638
+ const openworkHost = readFlag(args.flags, "openwork-host") ?? process.env.OPENWORK_HOST ?? "0.0.0.0";
639
+ const openworkPort = await resolvePort(readNumber(args.flags, "openwork-port", undefined, "OPENWORK_PORT"), "127.0.0.1", DEFAULT_OPENWORK_PORT);
640
+ const openworkToken = readFlag(args.flags, "openwork-token") ?? process.env.OPENWORK_TOKEN ?? randomUUID();
641
+ const openworkHostToken = readFlag(args.flags, "openwork-host-token") ?? process.env.OPENWORK_HOST_TOKEN ?? randomUUID();
642
+ const approvalMode = readFlag(args.flags, "approval") ??
643
+ process.env.OPENWORK_APPROVAL_MODE ??
644
+ "manual";
645
+ const approvalTimeoutMs = readNumber(args.flags, "approval-timeout", DEFAULT_APPROVAL_TIMEOUT, "OPENWORK_APPROVAL_TIMEOUT_MS");
646
+ const readOnly = readBool(args.flags, "read-only", false, "OPENWORK_READONLY");
647
+ const corsValue = readFlag(args.flags, "cors") ?? process.env.OPENWORK_CORS_ORIGINS ?? "*";
648
+ const corsOrigins = parseList(corsValue);
649
+ const connectHost = readFlag(args.flags, "connect-host");
650
+ const openworkServerBin = resolveBinPath(readFlag(args.flags, "openwork-server-bin") ?? process.env.OPENWORK_SERVER_BIN ?? "openwork-server");
651
+ const owpenbotBin = resolveBinPath(readFlag(args.flags, "owpenbot-bin") ?? process.env.OWPENBOT_BIN ?? "owpenbot");
652
+ const owpenbotEnabled = readBool(args.flags, "owpenbot", true);
653
+ const opencodeBaseUrl = `http://127.0.0.1:${opencodePort}`;
654
+ const opencodeConnect = resolveConnectUrl(opencodePort, connectHost);
655
+ const opencodeConnectUrl = opencodeConnect.connectUrl ?? opencodeBaseUrl;
656
+ const openworkBaseUrl = `http://127.0.0.1:${openworkPort}`;
657
+ const openworkConnect = resolveConnectUrl(openworkPort, connectHost);
658
+ const openworkConnectUrl = openworkConnect.connectUrl ?? openworkBaseUrl;
659
+ const children = [];
660
+ let shuttingDown = false;
661
+ const shutdown = async () => {
662
+ if (shuttingDown)
663
+ return;
664
+ shuttingDown = true;
665
+ await Promise.all(children.map((handle) => stopChild(handle.child)));
666
+ };
667
+ const handleExit = (name, code, signal) => {
668
+ if (shuttingDown)
669
+ return;
670
+ const reason = code !== null ? `code ${code}` : signal ? `signal ${signal}` : "unknown";
671
+ console.error(`[${name}] exited (${reason})`);
672
+ void shutdown().then(() => process.exit(code ?? 1));
673
+ };
674
+ const handleSpawnError = (name, error) => {
675
+ if (shuttingDown)
676
+ return;
677
+ console.error(`[${name}] failed to start: ${String(error)}`);
678
+ void shutdown().then(() => process.exit(1));
679
+ };
680
+ const opencodeChild = await startOpencode({
681
+ bin: opencodeBin,
682
+ workspace: resolvedWorkspace,
683
+ bindHost: opencodeBindHost,
684
+ port: opencodePort,
685
+ username: opencodeUsername,
686
+ password: opencodePassword,
687
+ corsOrigins: corsOrigins.length ? corsOrigins : ["*"],
688
+ });
689
+ children.push({ name: "opencode", child: opencodeChild });
690
+ opencodeChild.on("exit", (code, signal) => handleExit("opencode", code, signal));
691
+ opencodeChild.on("error", (error) => handleSpawnError("opencode", error));
692
+ const authHeaders = {};
693
+ if (opencodeUsername && opencodePassword) {
694
+ authHeaders.Authorization = `Basic ${encodeBasicAuth(opencodeUsername, opencodePassword)}`;
695
+ }
696
+ const opencodeClient = createOpencodeClient({
697
+ baseUrl: opencodeBaseUrl,
698
+ directory: resolvedWorkspace,
699
+ headers: Object.keys(authHeaders).length ? authHeaders : undefined,
700
+ });
701
+ await waitForOpencodeHealthy(opencodeClient);
702
+ const openworkChild = await startOpenworkServer({
703
+ bin: openworkServerBin,
704
+ host: openworkHost,
705
+ port: openworkPort,
706
+ workspace: resolvedWorkspace,
707
+ token: openworkToken,
708
+ hostToken: openworkHostToken,
709
+ approvalMode: approvalMode === "auto" ? "auto" : "manual",
710
+ approvalTimeoutMs,
711
+ readOnly,
712
+ corsOrigins: corsOrigins.length ? corsOrigins : ["*"],
713
+ opencodeBaseUrl: opencodeConnectUrl,
714
+ opencodeDirectory: resolvedWorkspace,
715
+ opencodeUsername,
716
+ opencodePassword,
717
+ });
718
+ children.push({ name: "openwork-server", child: openworkChild });
719
+ openworkChild.on("exit", (code, signal) => handleExit("openwork-server", code, signal));
720
+ openworkChild.on("error", (error) => handleSpawnError("openwork-server", error));
721
+ await waitForHealthy(openworkBaseUrl);
722
+ if (owpenbotEnabled) {
723
+ const owpenbotChild = await startOwpenbot({
724
+ bin: owpenbotBin,
725
+ workspace: resolvedWorkspace,
726
+ opencodeUrl: opencodeConnectUrl,
727
+ opencodeUsername,
728
+ opencodePassword,
729
+ });
730
+ children.push({ name: "owpenbot", child: owpenbotChild });
731
+ owpenbotChild.on("exit", (code, signal) => handleExit("owpenbot", code, signal));
732
+ owpenbotChild.on("error", (error) => handleSpawnError("owpenbot", error));
733
+ }
734
+ const payload = {
735
+ workspace: resolvedWorkspace,
736
+ approval: {
737
+ mode: approvalMode,
738
+ timeoutMs: approvalTimeoutMs,
739
+ readOnly,
740
+ },
741
+ opencode: {
742
+ baseUrl: opencodeBaseUrl,
743
+ connectUrl: opencodeConnectUrl,
744
+ username: opencodeUsername,
745
+ password: opencodePassword,
746
+ bindHost: opencodeBindHost,
747
+ port: opencodePort,
748
+ },
749
+ openwork: {
750
+ baseUrl: openworkBaseUrl,
751
+ connectUrl: openworkConnectUrl,
752
+ host: openworkHost,
753
+ port: openworkPort,
754
+ token: openworkToken,
755
+ hostToken: openworkHostToken,
756
+ },
757
+ owpenbot: {
758
+ enabled: owpenbotEnabled,
759
+ },
760
+ };
761
+ if (outputJson) {
762
+ console.log(JSON.stringify(payload, null, 2));
763
+ }
764
+ else {
765
+ console.log("Openwrk running");
766
+ console.log(`Workspace: ${payload.workspace}`);
767
+ console.log(`OpenCode: ${payload.opencode.baseUrl}`);
768
+ console.log(`OpenCode connect URL: ${payload.opencode.connectUrl}`);
769
+ if (payload.opencode.username && payload.opencode.password) {
770
+ console.log(`OpenCode auth: ${payload.opencode.username} / ${payload.opencode.password}`);
771
+ }
772
+ console.log(`OpenWork server: ${payload.openwork.baseUrl}`);
773
+ console.log(`OpenWork connect URL: ${payload.openwork.connectUrl}`);
774
+ console.log(`Client token: ${payload.openwork.token}`);
775
+ console.log(`Host token: ${payload.openwork.hostToken}`);
776
+ }
777
+ if (checkOnly) {
778
+ try {
779
+ await runChecks({
780
+ opencodeClient,
781
+ openworkUrl: openworkBaseUrl,
782
+ openworkToken,
783
+ checkEvents,
784
+ });
785
+ if (!outputJson) {
786
+ console.log("Checks: ok");
787
+ }
788
+ }
789
+ catch (error) {
790
+ console.error(`Checks failed: ${String(error)}`);
791
+ await shutdown();
792
+ process.exit(1);
793
+ }
794
+ await shutdown();
795
+ process.exit(0);
796
+ }
797
+ process.on("SIGINT", () => shutdown().then(() => process.exit(0)));
798
+ process.on("SIGTERM", () => shutdown().then(() => process.exit(0)));
799
+ await new Promise(() => undefined);
800
+ }
801
+ async function main() {
802
+ const args = parseArgs(process.argv.slice(2));
803
+ if (readBool(args.flags, "help", false) || args.flags.get("help") === true) {
804
+ printHelp();
805
+ return;
806
+ }
807
+ if (readBool(args.flags, "version", false) || args.flags.get("version") === true) {
808
+ console.log(VERSION);
809
+ return;
810
+ }
811
+ const command = args.positionals[0] ?? "start";
812
+ if (command === "start") {
813
+ await runStart(args);
814
+ return;
815
+ }
816
+ if (command === "approvals") {
817
+ await runApprovals(args);
818
+ return;
819
+ }
820
+ if (command === "status") {
821
+ await runStatus(args);
822
+ return;
823
+ }
824
+ printHelp();
825
+ process.exitCode = 1;
826
+ }
827
+ main().catch((error) => {
828
+ console.error(error instanceof Error ? error.message : String(error));
829
+ process.exitCode = 1;
830
+ });
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "openwrk",
3
+ "version": "0.1.0",
4
+ "description": "Headless OpenWork host orchestrator for OpenCode + OpenWork server + Owpenbot",
5
+ "type": "module",
6
+ "bin": {
7
+ "openwrk": "dist/cli.js"
8
+ },
9
+ "scripts": {
10
+ "dev": "bun src/cli.ts",
11
+ "build": "tsc -p tsconfig.json",
12
+ "typecheck": "tsc -p tsconfig.json --noEmit"
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "README.md"
17
+ ],
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "https://github.com/different-ai/openwork",
21
+ "directory": "packages/headless"
22
+ },
23
+ "homepage": "https://github.com/different-ai/openwork/tree/dev/packages/headless",
24
+ "bugs": {
25
+ "url": "https://github.com/different-ai/openwork/issues"
26
+ },
27
+ "keywords": [
28
+ "openwork",
29
+ "opencode",
30
+ "headless",
31
+ "cli",
32
+ "agent"
33
+ ],
34
+ "license": "MIT",
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "dependencies": {
39
+ "@opencode-ai/sdk": "^1.1.31"
40
+ },
41
+ "devDependencies": {
42
+ "@types/node": "^22.10.2",
43
+ "bun-types": "^1.3.6",
44
+ "typescript": "^5.6.3"
45
+ },
46
+ "packageManager": "pnpm@10.27.0"
47
+ }