@cloudagentfleet/worker 0.1.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/dist/cloudagentfleet-worker.mjs +958 -0
- package/dist/cloudagentfleet.mjs +936 -0
- package/package.json +29 -0
|
@@ -0,0 +1,936 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
// apps/cli/bin/setup.mjs
|
|
5
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
6
|
+
import {
|
|
7
|
+
chmodSync,
|
|
8
|
+
existsSync,
|
|
9
|
+
mkdirSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
readdirSync,
|
|
12
|
+
renameSync,
|
|
13
|
+
unlinkSync,
|
|
14
|
+
writeFileSync
|
|
15
|
+
} from "node:fs";
|
|
16
|
+
import { homedir, hostname } from "node:os";
|
|
17
|
+
import { dirname, join, resolve } from "node:path";
|
|
18
|
+
import { createInterface } from "node:readline/promises";
|
|
19
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
var thisFile = fileURLToPath(import.meta.url);
|
|
22
|
+
var thisDir = dirname(thisFile);
|
|
23
|
+
var configPath = join(homedir(), ".hosted-agents", "worker.json");
|
|
24
|
+
var instancesConfigPath = join(homedir(), ".hosted-agents", "instances.json");
|
|
25
|
+
var modes = ["claude", "shell", "opencode", "codex", "gemini", "antigravity", "pi", "grok", "deepseek"];
|
|
26
|
+
var defaultAgentNames = { claude: "Claude Code" };
|
|
27
|
+
var tailscaleCommand = process.platform === "win32" ? "tailscale.exe" : "tailscale";
|
|
28
|
+
var loopbackHosts = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
|
|
29
|
+
var wslDistro = process.env.HOSTED_AGENTS_WSL_DISTRO?.trim() || "Ubuntu";
|
|
30
|
+
function resolveWorkerScript() {
|
|
31
|
+
const candidates = [
|
|
32
|
+
// npm / built package: sibling CLI bundle next to cloudagentfleet.mjs
|
|
33
|
+
join(thisDir, "cloudagentfleet-worker.mjs"),
|
|
34
|
+
// legacy standalone layout
|
|
35
|
+
join(thisDir, "runtime", "worker.mjs"),
|
|
36
|
+
join(thisDir, "..", "runtime", "worker.mjs"),
|
|
37
|
+
// monorepo checkout without a release build
|
|
38
|
+
join(thisDir, "..", "..", "node-agent", "src", "main.ts")
|
|
39
|
+
];
|
|
40
|
+
for (const candidate of candidates) {
|
|
41
|
+
if (existsSync(candidate)) return candidate;
|
|
42
|
+
}
|
|
43
|
+
throw new Error(
|
|
44
|
+
"Unable to locate the Worker runtime. Install @cloudagentfleet/worker from npm, or run pnpm build:release in a checkout."
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
function resolveRuntimeRoot(workerScript) {
|
|
48
|
+
return dirname(workerScript);
|
|
49
|
+
}
|
|
50
|
+
function command(commandName, args = [], options = {}) {
|
|
51
|
+
return spawnSync(commandName, args, {
|
|
52
|
+
encoding: "utf8",
|
|
53
|
+
timeout: options.timeout ?? 3e3,
|
|
54
|
+
stdio: options.capture === false ? "inherit" : ["ignore", "pipe", "pipe"],
|
|
55
|
+
shell: options.shell ?? false
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
function canRun(commandName, args = ["--version"]) {
|
|
59
|
+
return command(commandName, args).status === 0;
|
|
60
|
+
}
|
|
61
|
+
function parseHubOptions(args = [], env = process.env) {
|
|
62
|
+
const parsed = parseOptions(args);
|
|
63
|
+
if (parsed.positionals.length > 0) {
|
|
64
|
+
throw new Error(`Unexpected Hub option: ${parsed.positionals[0]}`);
|
|
65
|
+
}
|
|
66
|
+
const host = optionValue(parsed, "host") ?? env.HOSTED_AGENTS_HOST ?? "127.0.0.1";
|
|
67
|
+
const rawPort = optionValue(parsed, "port") ?? env.HOSTED_AGENTS_PORT ?? "8787";
|
|
68
|
+
if (!/^\d+$/.test(rawPort)) throw new Error("Hub port must be a number between 1 and 65535");
|
|
69
|
+
const port = Number(rawPort);
|
|
70
|
+
if (!Number.isSafeInteger(port) || port < 1 || port > 65535) {
|
|
71
|
+
throw new Error("Hub port must be a number between 1 and 65535");
|
|
72
|
+
}
|
|
73
|
+
if (!loopbackHosts.has(host.toLowerCase())) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`Hub private serving requires a loopback bind address; got "${host}". Set HOSTED_AGENTS_HOST=127.0.0.1 and restart the Controller.`
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
return { host, port };
|
|
79
|
+
}
|
|
80
|
+
function tailscaleFailure(result) {
|
|
81
|
+
const detail = result?.stderr?.trim() || result?.stdout?.trim();
|
|
82
|
+
return detail ? `: ${detail}` : "";
|
|
83
|
+
}
|
|
84
|
+
function runTailscale(args, runner = command) {
|
|
85
|
+
const result = runner(tailscaleCommand, args, { shell: false });
|
|
86
|
+
if (result?.error?.code === "ENOENT") {
|
|
87
|
+
throw new Error(
|
|
88
|
+
"Tailscale CLI was not found. Install Tailscale, sign in to the tailnet, and ensure tailscale is available on PATH."
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
if (result?.error) {
|
|
92
|
+
throw new Error(`Could not run Tailscale CLI: ${result.error.message}`);
|
|
93
|
+
}
|
|
94
|
+
if (result?.status !== 0) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`Tailscale command failed (${args.join(" ")})${tailscaleFailure(result)}. Confirm Tailscale is running and this machine is connected to the tailnet.`
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
101
|
+
async function checkLocalHub(port, authToken, fetchImpl = fetch) {
|
|
102
|
+
const controller = new AbortController();
|
|
103
|
+
const timeout = setTimeout(() => controller.abort(), 5e3);
|
|
104
|
+
try {
|
|
105
|
+
const response = await fetchImpl(`http://127.0.0.1:${port}/healthz`, {
|
|
106
|
+
headers: authToken ? { authorization: `Bearer ${authToken}` } : {},
|
|
107
|
+
signal: controller.signal
|
|
108
|
+
});
|
|
109
|
+
if (!response.ok) {
|
|
110
|
+
throw new Error(`returned HTTP ${response.status}`);
|
|
111
|
+
}
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if (error?.name === "AbortError") {
|
|
114
|
+
throw new Error(`Hub health check timed out at http://127.0.0.1:${port}/healthz`);
|
|
115
|
+
}
|
|
116
|
+
throw new Error(
|
|
117
|
+
`Hub is not reachable at http://127.0.0.1:${port}. Start the Controller on loopback before enabling Tailscale Serve.`,
|
|
118
|
+
{ cause: error }
|
|
119
|
+
);
|
|
120
|
+
} finally {
|
|
121
|
+
clearTimeout(timeout);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
async function serveHub(args = [], dependencies = {}) {
|
|
125
|
+
const env = dependencies.env ?? process.env;
|
|
126
|
+
const config = parseHubOptions(args, env);
|
|
127
|
+
const runner = dependencies.runner ?? command;
|
|
128
|
+
runTailscale(["version"], runner);
|
|
129
|
+
runTailscale(["status"], runner);
|
|
130
|
+
await checkLocalHub(config.port, env.HOSTED_AGENTS_AUTH_TOKEN, dependencies.fetchImpl ?? fetch);
|
|
131
|
+
const target = `http://127.0.0.1:${config.port}`;
|
|
132
|
+
runTailscale(["serve", "--bg", target], runner);
|
|
133
|
+
console.log(`Serving the single Hosted Agents Hub privately at ${target} through Tailscale.`);
|
|
134
|
+
console.log("Codeman instances remain private; no per-instance public domains were created.");
|
|
135
|
+
return config;
|
|
136
|
+
}
|
|
137
|
+
function statusHub(args = [], dependencies = {}) {
|
|
138
|
+
const env = dependencies.env ?? process.env;
|
|
139
|
+
const config = parseHubOptions(args, env);
|
|
140
|
+
const runner = dependencies.runner ?? command;
|
|
141
|
+
runTailscale(["version"], runner);
|
|
142
|
+
runTailscale(["status"], runner);
|
|
143
|
+
const result = runTailscale(["serve", "status"], runner);
|
|
144
|
+
console.log(`Hub loopback target: http://127.0.0.1:${config.port}`);
|
|
145
|
+
console.log(result.stdout?.trim() || "Tailscale Serve returned no configured routes.");
|
|
146
|
+
return config;
|
|
147
|
+
}
|
|
148
|
+
function stopHub(args = [], dependencies = {}) {
|
|
149
|
+
const env = dependencies.env ?? process.env;
|
|
150
|
+
const config = parseHubOptions(args, env);
|
|
151
|
+
const runner = dependencies.runner ?? command;
|
|
152
|
+
runTailscale(["version"], runner);
|
|
153
|
+
runTailscale(["serve", "reset"], runner);
|
|
154
|
+
console.log("Tailscale Serve routes reset; the Hosted Agents Hub is no longer served.");
|
|
155
|
+
return config;
|
|
156
|
+
}
|
|
157
|
+
function wslCanRun(args = ["-e", "true"]) {
|
|
158
|
+
const probe = args.length === 2 && args[0] === "-e" && args[1] === "true" ? ["-e", "bash", "-lc", "printf hosted-agents-wsl-ready"] : args;
|
|
159
|
+
const result = process.platform === "win32" ? command("wsl.exe", ["-d", wslDistro, ...probe], { timeout: 5e3, shell: false }) : void 0;
|
|
160
|
+
return result?.status === 0 && (args.length === 2 ? result.stdout?.includes("hosted-agents-wsl-ready") === true : Boolean(result.stdout?.trim()));
|
|
161
|
+
}
|
|
162
|
+
function detectEnvironment() {
|
|
163
|
+
const windows = process.platform === "win32";
|
|
164
|
+
const wsl = Boolean(process.env.WSL_DISTRO_NAME) || wslCanRun();
|
|
165
|
+
const inWsl = Boolean(process.env.WSL_DISTRO_NAME);
|
|
166
|
+
const codemanLocal = canRun("codeman") || canRun("aicodeman");
|
|
167
|
+
const codemanWsl = !inWsl && wsl && wslCanRun(["-e", "bash", "-lc", "command -v codeman"]);
|
|
168
|
+
const cursorLocal = canRun("agent") || canRun("cursor-agent");
|
|
169
|
+
const cursorWsl = !inWsl && wsl && wslCanRun(["-e", "bash", "-lc", "command -v agent || command -v cursor-agent"]);
|
|
170
|
+
return {
|
|
171
|
+
platform: process.platform === "darwin" ? "macos" : windows ? "windows" : "linux",
|
|
172
|
+
wsl,
|
|
173
|
+
inWsl,
|
|
174
|
+
execution: windows && wsl && !inWsl ? "wsl" : "local",
|
|
175
|
+
node: true,
|
|
176
|
+
git: canRun("git"),
|
|
177
|
+
codeman: codemanLocal || codemanWsl,
|
|
178
|
+
codemanLocal,
|
|
179
|
+
codemanWsl,
|
|
180
|
+
cursorCli: cursorLocal || cursorWsl,
|
|
181
|
+
tmux: canRun("tmux") || wsl && wslCanRun(["-e", "bash", "-lc", "command -v tmux"]),
|
|
182
|
+
docker: canRun("docker"),
|
|
183
|
+
claudeCli: canRun("claude") || wsl && wslCanRun(["-e", "bash", "-lc", "command -v claude"])
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
function printEnvironment(environment) {
|
|
187
|
+
console.log(`Platform: ${environment.platform}${environment.inWsl ? " (WSL)" : ""}`);
|
|
188
|
+
console.log(`Execution target: ${environment.execution}`);
|
|
189
|
+
for (const [name, value] of Object.entries(environment)) {
|
|
190
|
+
if (["platform", "execution", "inWsl", "codemanLocal", "codemanWsl"].includes(name)) continue;
|
|
191
|
+
console.log(` ${value ? "[ok]" : "[--]"} ${name}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
async function ask(question, defaultValue = "") {
|
|
195
|
+
const answer = await readline().question(`${question}${defaultValue ? ` [${defaultValue}]` : ""}: `);
|
|
196
|
+
return answer.trim() || defaultValue;
|
|
197
|
+
}
|
|
198
|
+
var rl;
|
|
199
|
+
function readline() {
|
|
200
|
+
return rl ??= createInterface({ input: process.stdin, output: process.stdout });
|
|
201
|
+
}
|
|
202
|
+
async function confirm(question, defaultValue = false) {
|
|
203
|
+
const answer = (await ask(`${question} ${defaultValue ? "[Y/n]" : "[y/N]"}`)).toLowerCase();
|
|
204
|
+
return answer ? answer === "y" || answer === "yes" : defaultValue;
|
|
205
|
+
}
|
|
206
|
+
function listDirectories(root) {
|
|
207
|
+
try {
|
|
208
|
+
return readdirSync(root, { withFileTypes: true }).filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")).map((entry) => join(root, entry.name)).slice(0, 30);
|
|
209
|
+
} catch {
|
|
210
|
+
return [];
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
async function selectFolders() {
|
|
214
|
+
const cwd = process.cwd();
|
|
215
|
+
const choices = listDirectories(cwd);
|
|
216
|
+
console.log(`
|
|
217
|
+
Workspace roots (current directory: ${cwd})`);
|
|
218
|
+
choices.forEach((choice, index) => console.log(` ${index + 1}. ${choice}`));
|
|
219
|
+
console.log(" Type paths separated by ';', or press Enter for the current directory.");
|
|
220
|
+
const answer = await ask("Approved folder roots", cwd);
|
|
221
|
+
if (answer === "*") return { mode: "system", roots: [] };
|
|
222
|
+
const selected = answer.split(";").map((value) => value.trim()).filter(Boolean).map((value) => {
|
|
223
|
+
const numeric = Number(value);
|
|
224
|
+
return Number.isInteger(numeric) && numeric >= 1 && numeric <= choices.length ? choices[numeric - 1] : resolve(value);
|
|
225
|
+
});
|
|
226
|
+
return { mode: "folders", roots: [...new Set(selected)] };
|
|
227
|
+
}
|
|
228
|
+
async function selectOption(label, options) {
|
|
229
|
+
if (options.length === 0) throw new Error(`No ${label} available`);
|
|
230
|
+
console.log(`
|
|
231
|
+
${label}`);
|
|
232
|
+
options.forEach((option, index2) => console.log(` ${index2 + 1}. ${option.label}`));
|
|
233
|
+
const answer = await ask(`Select ${label.toLowerCase()}`, "1");
|
|
234
|
+
const index = Number(answer) - 1;
|
|
235
|
+
if (!Number.isInteger(index) || !options[index]) throw new Error(`Invalid ${label} selection`);
|
|
236
|
+
return options[index].value;
|
|
237
|
+
}
|
|
238
|
+
function discoverWorkspaces(policy, codemanReady, cursorReady = false, claudeReady = false) {
|
|
239
|
+
const roots = policy.mode === "system" ? [process.cwd()] : policy.roots;
|
|
240
|
+
const paths = /* @__PURE__ */ new Set();
|
|
241
|
+
for (const root of roots) {
|
|
242
|
+
paths.add(resolve(root));
|
|
243
|
+
for (const child of listDirectories(root)) paths.add(resolve(child));
|
|
244
|
+
}
|
|
245
|
+
return [...paths].map((canonicalPath) => ({
|
|
246
|
+
id: `ws_${createHash("sha256").update(canonicalPath).digest("hex").slice(0, 20)}`,
|
|
247
|
+
name: canonicalPath.split(/[\\/]/).filter(Boolean).at(-1) ?? "workspace",
|
|
248
|
+
canonicalPath,
|
|
249
|
+
health: "ready",
|
|
250
|
+
providers: {
|
|
251
|
+
codeman: codemanReady ? "ready" : "missing",
|
|
252
|
+
cursor: cursorReady ? "ready" : "missing",
|
|
253
|
+
claude: claudeReady ? "ready" : "missing"
|
|
254
|
+
}
|
|
255
|
+
}));
|
|
256
|
+
}
|
|
257
|
+
async function installCodeman(environment) {
|
|
258
|
+
if (environment.platform === "windows" && !environment.wsl) {
|
|
259
|
+
console.log("WSL is required for Codeman on Windows. Install it from an elevated PowerShell:");
|
|
260
|
+
console.log(" wsl --install");
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
const ok = await confirm("Install Codeman using its official installer?", true);
|
|
264
|
+
if (!ok) return false;
|
|
265
|
+
const args = environment.execution === "wsl" ? ["-d", wslDistro, "-e", "bash", "-lc", "curl -fsSL https://getcodeman.com/install | bash"] : ["-lc", "curl -fsSL https://getcodeman.com/install | bash"];
|
|
266
|
+
const result = command(environment.execution === "wsl" ? "wsl.exe" : "bash", args, {
|
|
267
|
+
timeout: 10 * 60 * 1e3,
|
|
268
|
+
capture: false,
|
|
269
|
+
shell: environment.execution !== "wsl"
|
|
270
|
+
});
|
|
271
|
+
return result.status === 0;
|
|
272
|
+
}
|
|
273
|
+
async function installCursorCli(environment) {
|
|
274
|
+
if (environment.platform === "windows" && !environment.wsl) {
|
|
275
|
+
console.log("Cursor Agent CLI requires WSL on Windows. Install WSL first, then rerun setup.");
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
const ok = await confirm("Install the official Cursor Agent CLI?", true);
|
|
279
|
+
if (!ok) return false;
|
|
280
|
+
const args = environment.execution === "wsl" ? ["-d", wslDistro, "-e", "bash", "-lc", "curl https://cursor.com/install -fsS | bash"] : ["-lc", "curl https://cursor.com/install -fsS | bash"];
|
|
281
|
+
const result = command(environment.execution === "wsl" ? "wsl.exe" : "bash", args, {
|
|
282
|
+
timeout: 10 * 60 * 1e3,
|
|
283
|
+
capture: false,
|
|
284
|
+
shell: environment.execution !== "wsl"
|
|
285
|
+
});
|
|
286
|
+
return result.status === 0;
|
|
287
|
+
}
|
|
288
|
+
function startCodeman(environment) {
|
|
289
|
+
const args = environment.execution === "wsl" ? ["-d", wslDistro, "-e", "bash", "-lc", "codeman web -d"] : ["-lc", "codeman web -d"];
|
|
290
|
+
const result = command(environment.execution === "wsl" ? "wsl.exe" : "bash", args, {
|
|
291
|
+
timeout: 3e4,
|
|
292
|
+
capture: false,
|
|
293
|
+
shell: environment.execution !== "wsl"
|
|
294
|
+
});
|
|
295
|
+
return result.status === 0;
|
|
296
|
+
}
|
|
297
|
+
function shellQuote(value) {
|
|
298
|
+
return `'${String(value).replaceAll("'", `'"'"'`)}'`;
|
|
299
|
+
}
|
|
300
|
+
function toWslPath(value) {
|
|
301
|
+
const match = value.match(/^([A-Za-z]):[\\/](.*)$/);
|
|
302
|
+
return match ? `/mnt/${match[1].toLowerCase()}/${match[2].replaceAll("\\", "/")}` : value;
|
|
303
|
+
}
|
|
304
|
+
function workerEnvironment(config) {
|
|
305
|
+
const workspaces = config.execution === "wsl" ? config.workspaces.map((workspace) => ({ ...workspace, canonicalPath: toWslPath(workspace.canonicalPath) })) : config.workspaces;
|
|
306
|
+
const roots = config.execution === "wsl" ? config.workspacePolicy.roots.map(toWslPath) : config.workspacePolicy.roots;
|
|
307
|
+
return {
|
|
308
|
+
HOSTED_AGENTS_NODE_ID: config.nodeId,
|
|
309
|
+
HOSTED_AGENTS_NODE_NAME: config.name,
|
|
310
|
+
HOSTED_AGENTS_CONTROLLER_URL: config.controllerUrl,
|
|
311
|
+
HOSTED_AGENTS_AUTH_TOKEN: config.authToken ?? "",
|
|
312
|
+
HOSTED_AGENTS_WORKSPACE_MODE: config.workspacePolicy.mode,
|
|
313
|
+
HOSTED_AGENTS_WORKSPACE_ROOTS: roots.join(";"),
|
|
314
|
+
HOSTED_AGENTS_WORKSPACES: JSON.stringify(workspaces),
|
|
315
|
+
HOSTED_AGENTS_CODEMAN_MODE: config.codemanMode,
|
|
316
|
+
CODEMAN_INSTANCE_ID: config.codemanInstanceId ?? config.nodeId,
|
|
317
|
+
CODEMAN_URL: config.codemanUrl
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
function runWorker(config) {
|
|
321
|
+
const env = workerEnvironment(config);
|
|
322
|
+
const workerScript = resolveWorkerScript();
|
|
323
|
+
const runtimeRoot = resolveRuntimeRoot(workerScript);
|
|
324
|
+
if (config.execution !== "wsl") {
|
|
325
|
+
return spawn(
|
|
326
|
+
process.execPath,
|
|
327
|
+
[workerScript.endsWith(".ts") ? "--experimental-strip-types" : "", workerScript].filter(Boolean),
|
|
328
|
+
{
|
|
329
|
+
cwd: runtimeRoot,
|
|
330
|
+
env: { ...process.env, ...env },
|
|
331
|
+
stdio: "inherit"
|
|
332
|
+
}
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
const commandLine = [
|
|
336
|
+
`cd ${shellQuote(toWslPath(runtimeRoot))}`,
|
|
337
|
+
...Object.entries(env).map(([key, value]) => `${key}=${shellQuote(value)}`),
|
|
338
|
+
`exec node ${workerScript.endsWith(".ts") ? "--experimental-strip-types " : ""}${shellQuote(toWslPath(workerScript))}`
|
|
339
|
+
].join(" && ");
|
|
340
|
+
return spawn("wsl.exe", ["-d", wslDistro, "-e", "bash", "-lc", commandLine], {
|
|
341
|
+
cwd: runtimeRoot,
|
|
342
|
+
stdio: "inherit"
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
async function setup() {
|
|
346
|
+
const environment = detectEnvironment();
|
|
347
|
+
console.log("Hosted Agents setup\n===================\n");
|
|
348
|
+
printEnvironment(environment);
|
|
349
|
+
if (!environment.node || !environment.git) throw new Error("Node.js and Git are required");
|
|
350
|
+
if (!environment.codeman) {
|
|
351
|
+
const installed = await installCodeman(environment);
|
|
352
|
+
if (installed) environment.codeman = true;
|
|
353
|
+
}
|
|
354
|
+
if (!environment.codeman) {
|
|
355
|
+
console.log("Codeman is not ready. Install it before starting the Worker.");
|
|
356
|
+
}
|
|
357
|
+
if (!environment.cursorCli) {
|
|
358
|
+
const installed = await installCursorCli(environment);
|
|
359
|
+
if (installed) environment.cursorCli = true;
|
|
360
|
+
}
|
|
361
|
+
const controllerUrl = await ask("\nController URL", "http://127.0.0.1:8787");
|
|
362
|
+
const nodeId = await ask("Worker ID", hostname().toLowerCase().replaceAll(/[^a-z0-9-]/g, "-"));
|
|
363
|
+
const name = await ask("Worker display name", hostname());
|
|
364
|
+
const workspacePolicy = await selectFolders();
|
|
365
|
+
const codemanMode = await ask(`Codeman mode (${modes.join(", ")})`, "claude");
|
|
366
|
+
if (!modes.includes(codemanMode)) throw new Error(`Unsupported Codeman mode: ${codemanMode}`);
|
|
367
|
+
const codemanUrl = await ask("Codeman URL", "http://127.0.0.1:3000");
|
|
368
|
+
const authToken = process.env.HOSTED_AGENTS_AUTH_TOKEN;
|
|
369
|
+
const config = {
|
|
370
|
+
nodeId,
|
|
371
|
+
name,
|
|
372
|
+
controllerUrl,
|
|
373
|
+
authToken,
|
|
374
|
+
execution: environment.execution,
|
|
375
|
+
workspacePolicy,
|
|
376
|
+
workspaces: discoverWorkspaces(
|
|
377
|
+
workspacePolicy,
|
|
378
|
+
environment.codeman,
|
|
379
|
+
environment.cursorCli,
|
|
380
|
+
environment.claudeCli
|
|
381
|
+
),
|
|
382
|
+
codemanMode,
|
|
383
|
+
codemanUrl
|
|
384
|
+
};
|
|
385
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
386
|
+
writeSafeJson(configPath, config);
|
|
387
|
+
console.log(`
|
|
388
|
+
Saved Worker configuration: ${configPath}`);
|
|
389
|
+
console.log(`Discovered ${config.workspaces.length} workspace(s).`);
|
|
390
|
+
if (environment.codeman && await confirm("Start Codeman now?", true)) {
|
|
391
|
+
if (!startCodeman(environment)) console.log("Codeman did not start; run `codeman web` manually.");
|
|
392
|
+
}
|
|
393
|
+
if (process.argv.includes("--run")) {
|
|
394
|
+
if (!environment.codeman) throw new Error("Cannot run Worker without Codeman");
|
|
395
|
+
runWorker(config);
|
|
396
|
+
} else {
|
|
397
|
+
console.log("Run `hosted-agents run` to start the real Worker.");
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
function writeSafeJson(path, value) {
|
|
401
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
402
|
+
const temporaryPath = `${path}.${process.pid}.tmp`;
|
|
403
|
+
const serialized = `${JSON.stringify(value, null, 2)}
|
|
404
|
+
`;
|
|
405
|
+
writeFileSync(temporaryPath, serialized, { mode: 384 });
|
|
406
|
+
try {
|
|
407
|
+
renameSync(temporaryPath, path);
|
|
408
|
+
} catch {
|
|
409
|
+
writeFileSync(path, serialized, { mode: 384 });
|
|
410
|
+
try {
|
|
411
|
+
unlinkSync(temporaryPath);
|
|
412
|
+
} catch {
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
chmodSync(path, 384);
|
|
416
|
+
}
|
|
417
|
+
function parseOptions(args) {
|
|
418
|
+
const values = /* @__PURE__ */ new Map();
|
|
419
|
+
const positionals = [];
|
|
420
|
+
const booleans = /* @__PURE__ */ new Set(["help", "no-health"]);
|
|
421
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
422
|
+
const argument = args[index];
|
|
423
|
+
if (!argument.startsWith("--")) {
|
|
424
|
+
positionals.push(argument);
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
const equals = argument.indexOf("=");
|
|
428
|
+
const name = (equals < 0 ? argument.slice(2) : argument.slice(2, equals)).trim();
|
|
429
|
+
const inlineValue = equals < 0 ? void 0 : argument.slice(equals + 1);
|
|
430
|
+
if (!name) throw new Error("Invalid empty option");
|
|
431
|
+
let value = inlineValue;
|
|
432
|
+
if (value === void 0 && !booleans.has(name)) {
|
|
433
|
+
value = args[index + 1];
|
|
434
|
+
if (!value || value.startsWith("--")) throw new Error(`Option --${name} requires a value`);
|
|
435
|
+
index += 1;
|
|
436
|
+
}
|
|
437
|
+
const entries = values.get(name) ?? [];
|
|
438
|
+
entries.push(value ?? "true");
|
|
439
|
+
values.set(name, entries);
|
|
440
|
+
}
|
|
441
|
+
return { positionals, values };
|
|
442
|
+
}
|
|
443
|
+
function optionValues(parsed, ...names) {
|
|
444
|
+
return names.flatMap((name) => parsed.values.get(name) ?? []);
|
|
445
|
+
}
|
|
446
|
+
function optionValue(parsed, ...names) {
|
|
447
|
+
return optionValues(parsed, ...names).at(-1);
|
|
448
|
+
}
|
|
449
|
+
function assertNonEmpty(value, field) {
|
|
450
|
+
const result = value?.trim();
|
|
451
|
+
if (!result) throw new Error(`${field} is required`);
|
|
452
|
+
return result;
|
|
453
|
+
}
|
|
454
|
+
function safeEndpoint(value, field, tailscale = false) {
|
|
455
|
+
const endpoint = assertNonEmpty(value, field);
|
|
456
|
+
let parsed;
|
|
457
|
+
try {
|
|
458
|
+
parsed = new URL(endpoint);
|
|
459
|
+
} catch {
|
|
460
|
+
throw new Error(`${field} must be a valid URL`);
|
|
461
|
+
}
|
|
462
|
+
if (parsed.username || parsed.password) {
|
|
463
|
+
throw new Error(`${field} must not contain embedded credentials`);
|
|
464
|
+
}
|
|
465
|
+
const loopback = ["localhost", "127.0.0.1", "::1", "[::1]"].includes(parsed.hostname);
|
|
466
|
+
if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && loopback)) {
|
|
467
|
+
throw new Error(`${field} must use HTTPS, or HTTP on loopback for local development`);
|
|
468
|
+
}
|
|
469
|
+
if (tailscale && parsed.protocol !== "https:") {
|
|
470
|
+
throw new Error(`${field} must use HTTPS in tailscale-url mode`);
|
|
471
|
+
}
|
|
472
|
+
if (tailscale && !isTailnetHostname(parsed.hostname)) {
|
|
473
|
+
throw new Error(`${field} must use a *.ts.net or Tailscale 100.64.0.0/10 hostname`);
|
|
474
|
+
}
|
|
475
|
+
return parsed.toString().replace(/\/+$/, "");
|
|
476
|
+
}
|
|
477
|
+
function isTailnetHostname(hostname2) {
|
|
478
|
+
if (hostname2.endsWith(".ts.net") || hostname2.endsWith(".ts.net.")) return true;
|
|
479
|
+
const parts = hostname2.split(".");
|
|
480
|
+
if (parts.length !== 4 || parts.some((part) => !/^\d+$/.test(part))) return false;
|
|
481
|
+
const [first, second] = parts.map(Number);
|
|
482
|
+
return first === 100 && second >= 64 && second <= 127;
|
|
483
|
+
}
|
|
484
|
+
function workspacePolicyFromOptions(parsed) {
|
|
485
|
+
const mode = optionValue(parsed, "workspace-mode") ?? "folders";
|
|
486
|
+
if (mode !== "folders" && mode !== "system") {
|
|
487
|
+
throw new Error("--workspace-mode must be folders or system");
|
|
488
|
+
}
|
|
489
|
+
const roots = optionValues(parsed, "workspace-root", "workspace-roots").flatMap((value) => value.split(";")).map((value) => value.trim()).filter(Boolean).map((value) => resolve(value));
|
|
490
|
+
if (mode === "folders" && roots.length === 0) {
|
|
491
|
+
throw new Error("At least one --workspace-root is required in folders mode");
|
|
492
|
+
}
|
|
493
|
+
return { mode, roots: [...new Set(roots)] };
|
|
494
|
+
}
|
|
495
|
+
function profileForMode(mode, name = mode, id = mode, ready = true) {
|
|
496
|
+
if (!modes.includes(mode)) throw new Error(`Unsupported agent mode: ${mode}`);
|
|
497
|
+
return { id: assertNonEmpty(id, "agent profile id"), name: assertNonEmpty(name, "agent profile name"), mode, ready };
|
|
498
|
+
}
|
|
499
|
+
function parseAgentProfiles(parsed) {
|
|
500
|
+
const profiles = [];
|
|
501
|
+
for (const value of optionValues(parsed, "agent-mode")) {
|
|
502
|
+
for (const mode of value.split(",").map((entry) => entry.trim()).filter(Boolean)) {
|
|
503
|
+
profiles.push(profileForMode(mode, defaultAgentNames[mode] ?? mode));
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
for (const value of optionValues(parsed, "agent-profile")) {
|
|
507
|
+
const [id, name, mode, ready = "true"] = value.split(":");
|
|
508
|
+
profiles.push(profileForMode(
|
|
509
|
+
assertNonEmpty(mode, "agent profile mode"),
|
|
510
|
+
name ?? id,
|
|
511
|
+
id,
|
|
512
|
+
ready !== "false"
|
|
513
|
+
));
|
|
514
|
+
}
|
|
515
|
+
return profiles.length > 0 ? profiles : void 0;
|
|
516
|
+
}
|
|
517
|
+
function parseCapabilities(parsed) {
|
|
518
|
+
const values = optionValues(parsed, "capability").flatMap((value) => value.split(","));
|
|
519
|
+
const capabilities = values.map((value) => value.trim()).filter(Boolean);
|
|
520
|
+
return [...new Set(capabilities.length > 0 ? capabilities : ["codeman"])];
|
|
521
|
+
}
|
|
522
|
+
async function jsonRequest(url, options, label) {
|
|
523
|
+
const controller = new AbortController();
|
|
524
|
+
const timeout = setTimeout(() => controller.abort(), 2e4);
|
|
525
|
+
try {
|
|
526
|
+
const response = await fetch(url, { ...options, signal: controller.signal });
|
|
527
|
+
const text = await response.text();
|
|
528
|
+
let body = {};
|
|
529
|
+
if (text) {
|
|
530
|
+
try {
|
|
531
|
+
body = JSON.parse(text);
|
|
532
|
+
} catch {
|
|
533
|
+
body = { error: text };
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return { response, body };
|
|
537
|
+
} catch (error) {
|
|
538
|
+
if (error?.name === "AbortError") throw new Error(`${label} timed out after 20 seconds`);
|
|
539
|
+
throw new Error(`${label} failed: ${error instanceof Error ? error.message : "network error"}`);
|
|
540
|
+
} finally {
|
|
541
|
+
clearTimeout(timeout);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
function authHeaders() {
|
|
545
|
+
const token = process.env.HOSTED_AGENTS_AUTH_TOKEN?.trim();
|
|
546
|
+
return token ? { authorization: `Bearer ${token}` } : {};
|
|
547
|
+
}
|
|
548
|
+
function controllerError(body, response) {
|
|
549
|
+
return typeof body?.error === "string" ? body.error : `HTTP ${response.status}`;
|
|
550
|
+
}
|
|
551
|
+
function loadSavedInstances() {
|
|
552
|
+
try {
|
|
553
|
+
const value = JSON.parse(readFileSync(instancesConfigPath, "utf8"));
|
|
554
|
+
return Array.isArray(value) ? value : [];
|
|
555
|
+
} catch {
|
|
556
|
+
return [];
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
function saveInstanceRegistration(registration) {
|
|
560
|
+
const instances = loadSavedInstances().filter((instance) => instance.id !== registration.id);
|
|
561
|
+
const { credentials: _credentials, ...safeRegistration } = registration;
|
|
562
|
+
instances.push({
|
|
563
|
+
...safeRegistration,
|
|
564
|
+
savedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
565
|
+
});
|
|
566
|
+
writeSafeJson(instancesConfigPath, instances);
|
|
567
|
+
}
|
|
568
|
+
function linkWorkerInstance(nodeId, instanceId) {
|
|
569
|
+
try {
|
|
570
|
+
const config = JSON.parse(readFileSync(configPath, "utf8"));
|
|
571
|
+
if (config.nodeId !== nodeId) return false;
|
|
572
|
+
config.codemanInstanceId = instanceId;
|
|
573
|
+
writeSafeJson(configPath, config);
|
|
574
|
+
return true;
|
|
575
|
+
} catch {
|
|
576
|
+
return false;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
function printHealth(instance, mode) {
|
|
580
|
+
if (instance.status !== "online") {
|
|
581
|
+
const hint = mode === "connector" ? `Start the Worker for node ${instance.nodeId ?? "configured in the registration"} and retry health verification.` : "Confirm the Tailscale URL is reachable and Codeman is running.";
|
|
582
|
+
throw new Error(`Health verification returned ${instance.status}. ${hint}`);
|
|
583
|
+
}
|
|
584
|
+
console.log(`Health verified: ${instance.label} is online.`);
|
|
585
|
+
console.log(` workspaces: ${instance.workspaces?.length ?? 0}`);
|
|
586
|
+
console.log(` agents: ${instance.agents?.length ?? 0}`);
|
|
587
|
+
}
|
|
588
|
+
async function enrollInstance(argv = process.argv.slice(3)) {
|
|
589
|
+
const parsed = parseOptions(argv);
|
|
590
|
+
if (parsed.values.has("help")) {
|
|
591
|
+
printEnrollmentHelp();
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
const selectedMode = optionValue(parsed, "mode") ?? parsed.positionals[0] ?? await ask("Connection mode (connector or tailscale-url)", "connector");
|
|
595
|
+
if (selectedMode !== "connector" && selectedMode !== "tailscale-url") {
|
|
596
|
+
throw new Error("--mode must be connector or tailscale-url");
|
|
597
|
+
}
|
|
598
|
+
const controllerUrl = safeEndpoint(
|
|
599
|
+
optionValue(parsed, "controller-url", "controller") ?? process.env.HOSTED_AGENTS_CONTROLLER_URL ?? await ask("Controller URL", "http://127.0.0.1:8787"),
|
|
600
|
+
"Controller URL"
|
|
601
|
+
);
|
|
602
|
+
const id = assertNonEmpty(
|
|
603
|
+
optionValue(parsed, "instance-id", "id") ?? await ask("Codeman instance ID"),
|
|
604
|
+
"Instance ID"
|
|
605
|
+
);
|
|
606
|
+
const label = assertNonEmpty(
|
|
607
|
+
optionValue(parsed, "name", "label") ?? await ask("Codeman instance name", id),
|
|
608
|
+
"Instance name"
|
|
609
|
+
);
|
|
610
|
+
const codemanUsername = optionValue(parsed, "username") ?? process.env.HOSTED_AGENTS_CODEMAN_USERNAME;
|
|
611
|
+
const codemanPassword = optionValue(parsed, "password") ?? process.env.HOSTED_AGENTS_CODEMAN_PASSWORD;
|
|
612
|
+
if (selectedMode !== "tailscale-url" && (codemanUsername !== void 0 || codemanPassword !== void 0)) {
|
|
613
|
+
throw new Error("Codeman credentials are only supported for tailscale-url mode");
|
|
614
|
+
}
|
|
615
|
+
if (selectedMode === "tailscale-url" && codemanUsername === void 0 !== (codemanPassword === void 0)) {
|
|
616
|
+
throw new Error("--username and --password must be provided together");
|
|
617
|
+
}
|
|
618
|
+
const agents = parseAgentProfiles(parsed);
|
|
619
|
+
const workspaceOptions = optionValues(
|
|
620
|
+
parsed,
|
|
621
|
+
"workspace-mode",
|
|
622
|
+
"workspace-root",
|
|
623
|
+
"workspace-roots"
|
|
624
|
+
);
|
|
625
|
+
const registration = {
|
|
626
|
+
id,
|
|
627
|
+
label,
|
|
628
|
+
connectionMode: selectedMode,
|
|
629
|
+
...selectedMode === "connector" ? {
|
|
630
|
+
nodeId: assertNonEmpty(
|
|
631
|
+
optionValue(parsed, "node-id", "worker-id") ?? process.env.HOSTED_AGENTS_NODE_ID ?? await ask("Enrolled Worker node ID"),
|
|
632
|
+
"Worker node ID"
|
|
633
|
+
)
|
|
634
|
+
} : {
|
|
635
|
+
endpoint: safeEndpoint(
|
|
636
|
+
optionValue(parsed, "url", "endpoint", "tailscale-url") ?? await ask("Tailscale Codeman URL"),
|
|
637
|
+
"Tailscale Codeman URL",
|
|
638
|
+
true
|
|
639
|
+
)
|
|
640
|
+
},
|
|
641
|
+
capabilities: parseCapabilities(parsed),
|
|
642
|
+
...agents ? { agents } : {},
|
|
643
|
+
...selectedMode === "connector" || workspaceOptions.length > 0 ? {
|
|
644
|
+
workspacePolicy: selectedMode === "connector" && workspaceOptions.length === 0 ? await selectFolders() : workspacePolicyFromOptions(parsed)
|
|
645
|
+
} : {},
|
|
646
|
+
...selectedMode === "tailscale-url" && codemanUsername !== void 0 ? {
|
|
647
|
+
credentials: {
|
|
648
|
+
username: codemanUsername,
|
|
649
|
+
password: codemanPassword ?? ""
|
|
650
|
+
}
|
|
651
|
+
} : {}
|
|
652
|
+
};
|
|
653
|
+
const headers = { ...authHeaders(), accept: "application/json", "content-type": "application/json" };
|
|
654
|
+
const registered = await jsonRequest(`${controllerUrl}/api/instances`, {
|
|
655
|
+
method: "POST",
|
|
656
|
+
headers,
|
|
657
|
+
body: JSON.stringify(registration)
|
|
658
|
+
}, "Instance registration");
|
|
659
|
+
if (!registered.response.ok) {
|
|
660
|
+
throw new Error(`Instance registration failed: ${controllerError(registered.body, registered.response)}`);
|
|
661
|
+
}
|
|
662
|
+
const instance = registered.body?.instance;
|
|
663
|
+
if (!instance) throw new Error("Instance registration returned no instance");
|
|
664
|
+
saveInstanceRegistration(registration);
|
|
665
|
+
const linked = selectedMode === "connector" ? linkWorkerInstance(registration.nodeId, registration.id) : false;
|
|
666
|
+
console.log(`Registered Codeman instance ${registration.label} (${registration.id}) in ${selectedMode} mode.`);
|
|
667
|
+
if (linked) {
|
|
668
|
+
console.log(`Updated Worker configuration with CODEMAN_INSTANCE_ID=${registration.id}. Restart the Worker to apply it.`);
|
|
669
|
+
}
|
|
670
|
+
if (parsed.values.has("no-health")) {
|
|
671
|
+
console.log("Health verification skipped by --no-health.");
|
|
672
|
+
return instance;
|
|
673
|
+
}
|
|
674
|
+
const health = await jsonRequest(
|
|
675
|
+
`${controllerUrl}/api/instances/${encodeURIComponent(registration.id)}/health`,
|
|
676
|
+
{ method: "POST", headers: { ...authHeaders(), accept: "application/json" } },
|
|
677
|
+
"Instance health verification"
|
|
678
|
+
);
|
|
679
|
+
if (!health.response.ok) {
|
|
680
|
+
const hint = selectedMode === "connector" ? "The Worker must be online and configured with the same CODEMAN_INSTANCE_ID." : "The Tailscale URL must be reachable and expose Codeman's /api/v1/status endpoint.";
|
|
681
|
+
throw new Error(
|
|
682
|
+
`Instance registered, but health verification failed: ${controllerError(health.body, health.response)}. ${hint}`
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
if (!health.body?.instance) throw new Error("Health verification returned no instance");
|
|
686
|
+
printHealth(health.body?.instance, selectedMode);
|
|
687
|
+
return health.body.instance;
|
|
688
|
+
}
|
|
689
|
+
function printEnrollmentHelp() {
|
|
690
|
+
console.log([
|
|
691
|
+
"Register a Codeman instance with the Controller.",
|
|
692
|
+
"",
|
|
693
|
+
"Usage:",
|
|
694
|
+
" hosted-agents enroll [connector|tailscale-url] [options]",
|
|
695
|
+
"",
|
|
696
|
+
"Options:",
|
|
697
|
+
" --mode <connector|tailscale-url>",
|
|
698
|
+
" --instance-id <id> --name <name>",
|
|
699
|
+
" --node-id <enrolled-worker-id> connector mode",
|
|
700
|
+
" --url <https-url> tailscale-url mode",
|
|
701
|
+
" --username <name> --password <secret> direct Codeman auth",
|
|
702
|
+
" --workspace-mode <folders|system>",
|
|
703
|
+
" --workspace-root <path> repeatable; use ';' to separate",
|
|
704
|
+
" --agent-mode <mode[,mode...]> repeatable",
|
|
705
|
+
" --agent-profile <id:name:mode[:ready]> repeatable",
|
|
706
|
+
" --capability <name[,name...]> repeatable",
|
|
707
|
+
" --no-health register without verification",
|
|
708
|
+
"",
|
|
709
|
+
"Credentials are read from HOSTED_AGENTS_AUTH_TOKEN and are never saved in the instance file."
|
|
710
|
+
].join("\n"));
|
|
711
|
+
}
|
|
712
|
+
function printHubHelp() {
|
|
713
|
+
console.log([
|
|
714
|
+
"Serve the single Hosted Agents Hub privately through Tailscale Serve.",
|
|
715
|
+
"",
|
|
716
|
+
"Usage:",
|
|
717
|
+
" hosted-agents hub serve [--port <1-65535>]",
|
|
718
|
+
" hosted-agents hub status [--port <1-65535>]",
|
|
719
|
+
" hosted-agents hub stop [--port <1-65535>]",
|
|
720
|
+
"",
|
|
721
|
+
"The Controller must already be running on 127.0.0.1 (default port 8787).",
|
|
722
|
+
"Serve uses one tailnet origin for the Hub and never publishes Codeman instances."
|
|
723
|
+
].join("\n"));
|
|
724
|
+
}
|
|
725
|
+
async function hubCommand(args) {
|
|
726
|
+
const action = args[0] ?? "help";
|
|
727
|
+
if (action === "help" || args.includes("--help")) {
|
|
728
|
+
printHubHelp();
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
if (action === "serve") {
|
|
732
|
+
await serveHub(args.slice(1));
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
if (action === "status") {
|
|
736
|
+
statusHub(args.slice(1));
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
if (action === "stop") {
|
|
740
|
+
stopHub(args.slice(1));
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
throw new Error(`Unknown Hub command: ${action}`);
|
|
744
|
+
}
|
|
745
|
+
function runSavedWorker() {
|
|
746
|
+
const config = JSON.parse(readFileSync(configPath, "utf8"));
|
|
747
|
+
runWorker(config);
|
|
748
|
+
}
|
|
749
|
+
function workerServicePaths() {
|
|
750
|
+
if (process.platform === "linux") {
|
|
751
|
+
return {
|
|
752
|
+
kind: "systemd",
|
|
753
|
+
path: join(homedir(), ".config", "systemd", "user", "hosted-agents-worker.service"),
|
|
754
|
+
name: "hosted-agents-worker.service"
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
if (process.platform === "darwin") {
|
|
758
|
+
return {
|
|
759
|
+
kind: "launchd",
|
|
760
|
+
path: join(homedir(), "Library", "LaunchAgents", "com.hosted-agents.worker.plist"),
|
|
761
|
+
name: "com.hosted-agents.worker"
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
return { kind: "schtasks", path: null, name: "Hosted Agents Worker" };
|
|
765
|
+
}
|
|
766
|
+
function workerServiceCommand() {
|
|
767
|
+
return [process.execPath, fileURLToPath(import.meta.url), "run"];
|
|
768
|
+
}
|
|
769
|
+
function installWorkerService() {
|
|
770
|
+
if (!existsSync(configPath)) throw new Error(`Worker configuration not found: ${configPath}`);
|
|
771
|
+
const service = workerServicePaths();
|
|
772
|
+
const [executable, script, action] = workerServiceCommand();
|
|
773
|
+
if (service.kind === "systemd") {
|
|
774
|
+
mkdirSync(dirname(service.path), { recursive: true });
|
|
775
|
+
writeFileSync(service.path, [
|
|
776
|
+
"[Unit]",
|
|
777
|
+
"Description=Hosted Agents Worker",
|
|
778
|
+
"After=network-online.target",
|
|
779
|
+
"",
|
|
780
|
+
"[Service]",
|
|
781
|
+
`ExecStart=${shellQuote(executable)} ${shellQuote(script)} ${action}`,
|
|
782
|
+
"Restart=on-failure",
|
|
783
|
+
"RestartSec=5",
|
|
784
|
+
"",
|
|
785
|
+
"[Install]",
|
|
786
|
+
"WantedBy=default.target",
|
|
787
|
+
""
|
|
788
|
+
].join("\n"), { mode: 384 });
|
|
789
|
+
command("systemctl", ["--user", "daemon-reload"], { capture: false });
|
|
790
|
+
runServiceCommand(service, "enable");
|
|
791
|
+
runServiceCommand(service, "start");
|
|
792
|
+
} else if (service.kind === "launchd") {
|
|
793
|
+
mkdirSync(dirname(service.path), { recursive: true });
|
|
794
|
+
const xml = (value) => String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
|
795
|
+
writeFileSync(service.path, [
|
|
796
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
797
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
798
|
+
'<plist version="1.0"><dict>',
|
|
799
|
+
"<key>Label</key><string>com.hosted-agents.worker</string>",
|
|
800
|
+
`<key>ProgramArguments</key><array><string>${xml(executable)}</string><string>${xml(script)}</string><string>${action}</string></array>`,
|
|
801
|
+
"<key>RunAtLoad</key><true/><key>KeepAlive</key><true/>",
|
|
802
|
+
"</dict></plist>",
|
|
803
|
+
""
|
|
804
|
+
].join("\n"), { mode: 384 });
|
|
805
|
+
runServiceCommand(service, "load");
|
|
806
|
+
} else {
|
|
807
|
+
const taskCommand = `"${executable.replaceAll('"', '""')}" "${script.replaceAll('"', '""')}" ${action}`;
|
|
808
|
+
runServiceCommand(service, "create", taskCommand);
|
|
809
|
+
}
|
|
810
|
+
console.log(`Installed user-level Worker service: ${service.name}`);
|
|
811
|
+
}
|
|
812
|
+
function runServiceCommand(service, action, extra) {
|
|
813
|
+
if (service.kind === "systemd") {
|
|
814
|
+
const result2 = command("systemctl", ["--user", action, service.name], { capture: false });
|
|
815
|
+
if (result2.status !== 0) throw new Error(`systemd ${action} failed`);
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
if (service.kind === "launchd") {
|
|
819
|
+
const result2 = command("launchctl", [action, service.path], { capture: false });
|
|
820
|
+
if (result2.status !== 0) throw new Error(`launchd ${action} failed`);
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
const args = action === "create" ? ["/Create", "/TN", service.name, "/TR", extra, "/SC", "ONLOGON", "/RL", "LIMITED", "/F"] : action === "delete" ? ["/Delete", "/TN", service.name, "/F"] : ["/" + action[0].toUpperCase() + action.slice(1), "/TN", service.name];
|
|
824
|
+
const result = command("schtasks.exe", args, { capture: false });
|
|
825
|
+
if (result.status !== 0) throw new Error(`Task Scheduler ${action} failed`);
|
|
826
|
+
}
|
|
827
|
+
function workerServiceAction(action) {
|
|
828
|
+
const service = workerServicePaths();
|
|
829
|
+
if (action === "install") {
|
|
830
|
+
installWorkerService();
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
if (service.kind === "systemd") {
|
|
834
|
+
runServiceCommand(service, action);
|
|
835
|
+
} else if (service.kind === "launchd") {
|
|
836
|
+
if (action === "stop") runServiceCommand(service, "unload");
|
|
837
|
+
else if (action === "start") runServiceCommand(service, "load");
|
|
838
|
+
else if (action === "remove") runServiceCommand(service, "unload");
|
|
839
|
+
else runServiceCommand(service, "list");
|
|
840
|
+
} else if (action === "remove") {
|
|
841
|
+
runServiceCommand(service, "delete");
|
|
842
|
+
} else {
|
|
843
|
+
runServiceCommand(service, action);
|
|
844
|
+
}
|
|
845
|
+
if (action === "remove" && service.path && existsSync(service.path)) unlinkSync(service.path);
|
|
846
|
+
console.log(`Worker service ${action} complete.`);
|
|
847
|
+
}
|
|
848
|
+
async function startJob() {
|
|
849
|
+
const config = JSON.parse(readFileSync(configPath, "utf8"));
|
|
850
|
+
const headers = {
|
|
851
|
+
accept: "application/json",
|
|
852
|
+
"content-type": "application/json",
|
|
853
|
+
...config.authToken ? { authorization: `Bearer ${config.authToken}` } : process.env.HOSTED_AGENTS_AUTH_TOKEN ? { authorization: `Bearer ${process.env.HOSTED_AGENTS_AUTH_TOKEN}` } : {}
|
|
854
|
+
};
|
|
855
|
+
const baseUrl = config.controllerUrl.replace(/\/+$/, "");
|
|
856
|
+
const workersResponse = await fetch(`${baseUrl}/api/workers`, { headers });
|
|
857
|
+
if (!workersResponse.ok) throw new Error(`Controller worker list failed with HTTP ${workersResponse.status}`);
|
|
858
|
+
const workersBody = await workersResponse.json();
|
|
859
|
+
const workers = (workersBody.workers ?? []).filter((worker) => worker.status === "online");
|
|
860
|
+
const nodeId = await selectOption(
|
|
861
|
+
"Online Workers",
|
|
862
|
+
workers.map((worker) => ({ label: `${worker.name} (${worker.id})`, value: worker }))
|
|
863
|
+
);
|
|
864
|
+
const workspaceId = await selectOption(
|
|
865
|
+
"Workspaces",
|
|
866
|
+
nodeId.workspaces.filter((workspace) => workspace.health === "ready").map((workspace) => ({ label: `${workspace.name} \u2014 ${workspace.canonicalPath}`, value: workspace }))
|
|
867
|
+
);
|
|
868
|
+
const provider = await selectOption(
|
|
869
|
+
"Agents",
|
|
870
|
+
Object.entries(workspaceId.providers).filter(([, readiness]) => readiness === "ready").map(([value]) => ({ label: value, value }))
|
|
871
|
+
);
|
|
872
|
+
const prompt = await ask("Prompt");
|
|
873
|
+
if (!prompt) throw new Error("Prompt is required");
|
|
874
|
+
const response = await fetch(`${baseUrl}/api/jobs`, {
|
|
875
|
+
method: "POST",
|
|
876
|
+
headers,
|
|
877
|
+
body: JSON.stringify({
|
|
878
|
+
provider,
|
|
879
|
+
nodeId: nodeId.id,
|
|
880
|
+
workspaceId: workspaceId.id,
|
|
881
|
+
prompt,
|
|
882
|
+
idempotencyKey: randomUUID()
|
|
883
|
+
})
|
|
884
|
+
});
|
|
885
|
+
const body = await response.json();
|
|
886
|
+
if (!response.ok) throw new Error(body.error ?? `Job creation failed with HTTP ${response.status}`);
|
|
887
|
+
console.log(`Started job ${body.job.id} on ${nodeId.name} / ${workspaceId.name} using ${provider}.`);
|
|
888
|
+
}
|
|
889
|
+
function help() {
|
|
890
|
+
console.log([
|
|
891
|
+
"Cloud Agent Fleet CLI",
|
|
892
|
+
"",
|
|
893
|
+
"Usage:",
|
|
894
|
+
" cloudagentfleet setup [--run] Detect, select, configure, and optionally run",
|
|
895
|
+
" cloudagentfleet run Run the saved Codeman-backed Worker",
|
|
896
|
+
" cloudagentfleet worker <install|start|status|stop|remove> Manage the user service",
|
|
897
|
+
" cloudagentfleet start Select Worker, workspace, agent, and prompt",
|
|
898
|
+
" cloudagentfleet enroll Register a Codeman instance and verify health",
|
|
899
|
+
" cloudagentfleet hub <serve|status|stop> Manage private Tailscale Hub serving",
|
|
900
|
+
" cloudagentfleet doctor Print local prerequisites",
|
|
901
|
+
"",
|
|
902
|
+
"Compatibility alias: hosted-agents",
|
|
903
|
+
"The setup flow asks before installing Codeman and never performs provider login."
|
|
904
|
+
].join("\n"));
|
|
905
|
+
}
|
|
906
|
+
if (process.argv[1] && (resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)) || process.argv[1].endsWith("hosted-agents.mjs"))) {
|
|
907
|
+
try {
|
|
908
|
+
const commandName = process.argv[2] ?? "help";
|
|
909
|
+
if (commandName === "setup") await setup();
|
|
910
|
+
else if (commandName === "run") runSavedWorker();
|
|
911
|
+
else if (commandName === "worker") workerServiceAction(process.argv[3] ?? "status");
|
|
912
|
+
else if (commandName === "doctor" || commandName === "check") printEnvironment(detectEnvironment());
|
|
913
|
+
else if (commandName === "start") await startJob();
|
|
914
|
+
else if (commandName === "enroll" || commandName === "register") await enrollInstance();
|
|
915
|
+
else if (commandName === "hub") await hubCommand(process.argv.slice(3));
|
|
916
|
+
else help();
|
|
917
|
+
} catch (error) {
|
|
918
|
+
console.error(error instanceof Error ? error.message : error);
|
|
919
|
+
process.exitCode = 1;
|
|
920
|
+
} finally {
|
|
921
|
+
rl?.close();
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
export {
|
|
925
|
+
discoverWorkspaces,
|
|
926
|
+
enrollInstance,
|
|
927
|
+
parseAgentProfiles,
|
|
928
|
+
parseHubOptions,
|
|
929
|
+
parseOptions,
|
|
930
|
+
runTailscale,
|
|
931
|
+
safeEndpoint,
|
|
932
|
+
serveHub,
|
|
933
|
+
statusHub,
|
|
934
|
+
stopHub,
|
|
935
|
+
workspacePolicyFromOptions
|
|
936
|
+
};
|