@frumu/tandem 0.4.15 → 0.4.16

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 +46 -5
  2. package/bin/tandem.js +862 -0
  3. package/package.json +3 -2
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # Tandem Engine CLI (npm Wrapper)
1
+ # Tandem Master CLI and Engine Wrapper
2
2
 
3
3
  ```text
4
4
  TTTTT A N N DDDD EEEEE M M
@@ -10,8 +10,10 @@ TTTTT A N N DDDD EEEEE M M
10
10
 
11
11
  ## What This Is
12
12
 
13
- Prebuilt npm distribution of the Tandem engine for macOS, Linux, and Windows.
14
- Installing this package gives you the `tandem-engine` CLI binary without compiling Rust locally.
13
+ Prebuilt npm distribution of Tandem for macOS, Linux, and Windows.
14
+
15
+ Installing this package gives you the master `tandem` CLI plus the direct
16
+ `tandem-engine` runtime binary without compiling Rust locally.
15
17
 
16
18
  If you want to build from Rust source instead, use the crate docs in `engine/README.md`.
17
19
 
@@ -21,11 +23,30 @@ If you want to build from Rust source instead, use the crate docs in `engine/REA
21
23
  npm install -g @frumu/tandem
22
24
  ```
23
25
 
24
- The installer downloads the release asset that matches this package version. Tags and package versions are expected to match (for example, `v0.3.3`).
26
+ The installer downloads the release asset that matches this package version. Tags and package versions are expected to match (for example, `v0.4.16`).
25
27
 
26
28
  ## Quick Start
27
29
 
28
- Start the engine server:
30
+ Inspect the installation:
31
+
32
+ ```bash
33
+ tandem doctor
34
+ ```
35
+
36
+ Install the optional web control panel add-on:
37
+
38
+ ```bash
39
+ tandem install panel
40
+ tandem panel init
41
+ ```
42
+
43
+ Open the panel once it is installed:
44
+
45
+ ```bash
46
+ tandem panel open
47
+ ```
48
+
49
+ Start the engine server directly when you want a foreground runtime:
29
50
 
30
51
  ```bash
31
52
  tandem-engine serve --hostname 127.0.0.1 --port 39731
@@ -33,6 +54,26 @@ tandem-engine serve --hostname 127.0.0.1 --port 39731
33
54
 
34
55
  ## Commands
35
56
 
57
+ ### Master CLI
58
+
59
+ ```bash
60
+ tandem doctor
61
+ tandem status
62
+ tandem service install
63
+ tandem service status
64
+ tandem service restart
65
+ tandem update
66
+ ```
67
+
68
+ Panel and add-on management:
69
+
70
+ ```bash
71
+ tandem install panel
72
+ tandem panel status
73
+ tandem panel open
74
+ tandem addon list
75
+ ```
76
+
36
77
  ### Serve
37
78
 
38
79
  ```bash
package/bin/tandem.js ADDED
@@ -0,0 +1,862 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require("fs");
4
+ const os = require("os");
5
+ const path = require("path");
6
+ const { spawn } = require("child_process");
7
+
8
+ const packageInfo = require("../package.json");
9
+
10
+ const ENGINE_ENTRYPOINT = path.join(__dirname, "tandem-engine.js");
11
+ const ENGINE_PACKAGE = "@frumu/tandem";
12
+ const PANEL_PACKAGE = "@frumu/tandem-panel";
13
+ const PANEL_COMMANDS = ["tandem-setup", "tandem-control-panel"];
14
+ const DEFAULT_ENGINE_HOST = "127.0.0.1";
15
+ const DEFAULT_ENGINE_PORT = 39731;
16
+ const DEFAULT_PANEL_HOST = "127.0.0.1";
17
+ const DEFAULT_PANEL_PORT = 39732;
18
+ const ENGINE_UNIT_NAME = "tandem-engine.service";
19
+ const ENGINE_LAUNCHD_LABEL = "ai.frumu.tandem.engine";
20
+ const WINDOWS_TASK_NAME = "TandemEngine";
21
+
22
+ function parseArgs(argv) {
23
+ const flags = new Set();
24
+ const values = new Map();
25
+ for (let i = 0; i < argv.length; i += 1) {
26
+ const raw = String(argv[i] || "").trim();
27
+ if (!raw) continue;
28
+ if (!raw.startsWith("--")) {
29
+ flags.add(raw);
30
+ continue;
31
+ }
32
+ const eq = raw.indexOf("=");
33
+ if (eq > 2) {
34
+ values.set(raw.slice(2, eq), raw.slice(eq + 1));
35
+ continue;
36
+ }
37
+ const key = raw.slice(2);
38
+ const next = String(argv[i + 1] || "").trim();
39
+ if (next && !next.startsWith("-")) {
40
+ values.set(key, next);
41
+ i += 1;
42
+ continue;
43
+ }
44
+ flags.add(raw);
45
+ }
46
+ return {
47
+ flags,
48
+ values,
49
+ has(flag) {
50
+ return flags.has(flag) || flags.has(`--${flag}`) || values.has(flag);
51
+ },
52
+ value(key) {
53
+ return values.get(key);
54
+ },
55
+ };
56
+ }
57
+
58
+ function findCommandOnPath(command, env = process.env) {
59
+ const pathValue = String(env.PATH || env.Path || "").trim();
60
+ if (!pathValue) return "";
61
+ const dirs = pathValue.split(path.delimiter).filter(Boolean);
62
+ const candidates = [command];
63
+ if (process.platform === "win32" && !path.extname(command)) {
64
+ const exts = String(env.PATHEXT || ".EXE;.CMD;.BAT;.COM")
65
+ .split(";")
66
+ .map((ext) => ext.trim())
67
+ .filter(Boolean);
68
+ for (const ext of exts) candidates.push(`${command}${ext.toLowerCase()}`);
69
+ }
70
+ for (const dir of dirs) {
71
+ for (const candidate of candidates) {
72
+ const full = path.join(dir, candidate);
73
+ try {
74
+ const stat = fs.statSync(full);
75
+ if (stat.isFile()) return full;
76
+ } catch {}
77
+ }
78
+ }
79
+ return "";
80
+ }
81
+
82
+ function detectPackageManager(env = process.env) {
83
+ const agent = String(env.npm_config_user_agent || "").trim();
84
+ if (agent.startsWith("pnpm/")) {
85
+ return {
86
+ name: "pnpm",
87
+ installArgs: ["add", "-g"],
88
+ updateArgs: ["add", "-g"],
89
+ removeArgs: ["remove", "-g"],
90
+ };
91
+ }
92
+ if (agent.startsWith("yarn/")) {
93
+ return {
94
+ name: "yarn",
95
+ installArgs: ["global", "add"],
96
+ updateArgs: ["global", "add"],
97
+ removeArgs: ["global", "remove"],
98
+ };
99
+ }
100
+ if (agent.startsWith("bun/")) {
101
+ return {
102
+ name: "bun",
103
+ installArgs: ["add", "-g"],
104
+ updateArgs: ["add", "-g"],
105
+ removeArgs: ["remove", "-g"],
106
+ };
107
+ }
108
+ return {
109
+ name: "npm",
110
+ installArgs: ["install", "-g"],
111
+ updateArgs: ["install", "-g"],
112
+ removeArgs: ["uninstall", "-g"],
113
+ };
114
+ }
115
+
116
+ function resolveTandemHomeDir(env = process.env, platform = process.platform) {
117
+ const override = String(env.TANDEM_HOME || env.TANDEM_STATE_DIR || "").trim();
118
+ if (override) return path.resolve(override);
119
+ if (platform === "darwin") {
120
+ return path.join(os.homedir(), "Library", "Application Support", "tandem");
121
+ }
122
+ if (platform === "win32") {
123
+ const base = String(env.APPDATA || "").trim() || path.join(os.homedir(), "AppData", "Roaming");
124
+ return path.join(base, "tandem");
125
+ }
126
+ const base =
127
+ String(env.XDG_DATA_HOME || "").trim() || path.join(os.homedir(), ".local", "share");
128
+ return path.join(base, "tandem");
129
+ }
130
+
131
+ function resolveTandemPaths(env = process.env, platform = process.platform) {
132
+ const home = resolveTandemHomeDir(env, platform);
133
+ return {
134
+ home,
135
+ logsDir: path.join(home, "logs"),
136
+ configPath: path.join(home, "config.json"),
137
+ stateDir: home,
138
+ panelPort: Number.parseInt(String(env.TANDEM_CONTROL_PANEL_PORT || DEFAULT_PANEL_PORT), 10) || DEFAULT_PANEL_PORT,
139
+ panelHost: String(env.TANDEM_CONTROL_PANEL_HOST || DEFAULT_PANEL_HOST).trim() || DEFAULT_PANEL_HOST,
140
+ enginePort: Number.parseInt(String(env.TANDEM_ENGINE_PORT || DEFAULT_ENGINE_PORT), 10) || DEFAULT_ENGINE_PORT,
141
+ engineHost: String(env.TANDEM_ENGINE_HOST || DEFAULT_ENGINE_HOST).trim() || DEFAULT_ENGINE_HOST,
142
+ panelPublicUrl: String(env.TANDEM_CONTROL_PANEL_PUBLIC_URL || "").trim(),
143
+ };
144
+ }
145
+
146
+ function runCommand(bin, args = [], options = {}) {
147
+ return new Promise((resolvePromise, rejectPromise) => {
148
+ const child = spawn(bin, args, {
149
+ env: options.env || process.env,
150
+ cwd: options.cwd || process.cwd(),
151
+ stdio: options.stdio || "inherit",
152
+ shell: Boolean(options.shell),
153
+ });
154
+ let stdout = "";
155
+ let stderr = "";
156
+ let timedOut = false;
157
+ let timer = null;
158
+ if (Number.isFinite(options.timeoutMs) && options.timeoutMs > 0) {
159
+ timer = setTimeout(() => {
160
+ timedOut = true;
161
+ try {
162
+ child.kill("SIGKILL");
163
+ } catch {}
164
+ }, options.timeoutMs);
165
+ }
166
+ if (options.capture && child.stdout) {
167
+ child.stdout.on("data", (chunk) => {
168
+ stdout += chunk.toString("utf8");
169
+ });
170
+ }
171
+ if (options.capture && child.stderr) {
172
+ child.stderr.on("data", (chunk) => {
173
+ stderr += chunk.toString("utf8");
174
+ });
175
+ }
176
+ child.on("error", rejectPromise);
177
+ child.on("close", (code) => {
178
+ if (timer) clearTimeout(timer);
179
+ if (timedOut) {
180
+ const error = new Error(`${bin} ${args.join(" ")} timed out after ${options.timeoutMs}ms`);
181
+ error.code = "ETIMEDOUT";
182
+ error.stdout = stdout;
183
+ error.stderr = stderr;
184
+ rejectPromise(error);
185
+ return;
186
+ }
187
+ if (code === 0) {
188
+ resolvePromise({ code: 0, stdout, stderr });
189
+ return;
190
+ }
191
+ const error = new Error(`${bin} ${args.join(" ")} exited ${code}${stderr ? `: ${stderr}` : ""}`);
192
+ error.code = code;
193
+ error.stdout = stdout;
194
+ error.stderr = stderr;
195
+ rejectPromise(error);
196
+ });
197
+ });
198
+ }
199
+
200
+ async function captureCommand(bin, args = [], options = {}) {
201
+ return runCommand(bin, args, { ...options, capture: true, stdio: "pipe" });
202
+ }
203
+
204
+ function quoteShell(value) {
205
+ const text = String(value || "");
206
+ if (/^[A-Za-z0-9_./:@=+-]+$/.test(text)) return text;
207
+ return `"${text.replace(/(["\\$`])/g, "\\$1")}"`;
208
+ }
209
+
210
+ function buildEngineServiceDefinition(paths, env = process.env) {
211
+ const nodePath = process.execPath;
212
+ const serviceUser = String(env.SUDO_USER || env.USER || os.userInfo().username || "").trim() || "root";
213
+ const host = paths.engineHost;
214
+ const port = String(paths.enginePort);
215
+ const stateDir = paths.stateDir;
216
+ const logPath = path.join(paths.logsDir, "engine.log");
217
+
218
+ if (process.platform === "linux") {
219
+ return {
220
+ manager: "systemd",
221
+ unitName: ENGINE_UNIT_NAME,
222
+ unitPath: `/etc/systemd/system/${ENGINE_UNIT_NAME}`,
223
+ logPath,
224
+ content: `[Unit]
225
+ Description=Tandem Engine
226
+ After=network-online.target
227
+ Wants=network-online.target
228
+
229
+ [Service]
230
+ Type=simple
231
+ User=${serviceUser}
232
+ WorkingDirectory=${stateDir}
233
+ Environment=TANDEM_STATE_DIR=${stateDir}
234
+ ExecStart=${quoteShell(nodePath)} ${quoteShell(ENGINE_ENTRYPOINT)} serve --hostname ${quoteShell(host)} --port ${quoteShell(port)} --state-dir ${quoteShell(stateDir)}
235
+ Restart=on-failure
236
+ RestartSec=5
237
+ StandardOutput=append:${logPath}
238
+ StandardError=append:${logPath}
239
+ NoNewPrivileges=true
240
+ PrivateTmp=true
241
+
242
+ [Install]
243
+ WantedBy=multi-user.target
244
+ `,
245
+ };
246
+ }
247
+
248
+ if (process.platform === "darwin") {
249
+ return {
250
+ manager: "launchd",
251
+ label: ENGINE_LAUNCHD_LABEL,
252
+ plistPath: `/Library/LaunchDaemons/${ENGINE_LAUNCHD_LABEL}.plist`,
253
+ logPath,
254
+ content: `<?xml version="1.0" encoding="UTF-8"?>
255
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
256
+ <plist version="1.0">
257
+ <dict>
258
+ <key>Label</key><string>${ENGINE_LAUNCHD_LABEL}</string>
259
+ <key>UserName</key><string>${serviceUser}</string>
260
+ <key>WorkingDirectory</key><string>${stateDir}</string>
261
+ <key>EnvironmentVariables</key>
262
+ <dict>
263
+ <key>TANDEM_STATE_DIR</key><string>${stateDir}</string>
264
+ </dict>
265
+ <key>ProgramArguments</key>
266
+ <array>
267
+ <string>${nodePath}</string>
268
+ <string>${ENGINE_ENTRYPOINT}</string>
269
+ <string>serve</string>
270
+ <string>--hostname</string>
271
+ <string>${host}</string>
272
+ <string>--port</string>
273
+ <string>${port}</string>
274
+ <string>--state-dir</string>
275
+ <string>${stateDir}</string>
276
+ </array>
277
+ <key>RunAtLoad</key><true/>
278
+ <key>KeepAlive</key><true/>
279
+ <key>ThrottleInterval</key><integer>5</integer>
280
+ <key>StandardOutPath</key><string>${logPath}</string>
281
+ <key>StandardErrorPath</key><string>${logPath}</string>
282
+ </dict>
283
+ </plist>
284
+ `,
285
+ };
286
+ }
287
+
288
+ if (process.platform === "win32") {
289
+ const scriptsDir = path.join(stateDir, "scripts");
290
+ const scriptPath = path.join(scriptsDir, "tandem-engine.ps1");
291
+ const logPathWin = path.join(paths.logsDir, "engine.log");
292
+ const ps1 = String.raw`$ErrorActionPreference = "Continue"
293
+ $node = "${process.execPath.replace(/\\/g, "\\\\")}"
294
+ $entry = "${ENGINE_ENTRYPOINT.replace(/\\/g, "\\\\")}"
295
+ $host = "${host}"
296
+ $port = "${port}"
297
+ $stateDir = "${stateDir.replace(/\\/g, "\\\\")}"
298
+ $logPath = "${logPathWin.replace(/\\/g, "\\\\")}"
299
+ New-Item -ItemType Directory -Force -Path (Split-Path $logPath) | Out-Null
300
+ New-Item -ItemType Directory -Force -Path $stateDir | Out-Null
301
+ while ($true) {
302
+ Start-Process -FilePath $node -ArgumentList @($entry, "serve", "--hostname", $host, "--port", $port, "--state-dir", $stateDir) -NoNewWindow -Wait -RedirectStandardOutput $logPath -RedirectStandardError $logPath
303
+ Start-Sleep -Seconds 5
304
+ }
305
+ `;
306
+ return {
307
+ manager: "scheduled-task",
308
+ taskName: WINDOWS_TASK_NAME,
309
+ scriptPath,
310
+ logPath: logPathWin,
311
+ content: ps1,
312
+ };
313
+ }
314
+
315
+ throw new Error(`Unsupported platform: ${process.platform}`);
316
+ }
317
+
318
+ async function ensureEngineServiceInstalled(paths, env = process.env) {
319
+ const def = buildEngineServiceDefinition(paths, env);
320
+ if (process.platform === "linux") {
321
+ if (typeof process.getuid === "function" && process.getuid() !== 0) {
322
+ throw new Error("Installing the Tandem engine service on Linux requires root.");
323
+ }
324
+ fs.mkdirSync(paths.logsDir, { recursive: true });
325
+ fs.writeFileSync(def.unitPath, def.content, "utf8");
326
+ await runCommand("systemctl", ["daemon-reload"]);
327
+ await runCommand("systemctl", ["enable", "--now", def.unitName]);
328
+ return def;
329
+ }
330
+ if (process.platform === "darwin") {
331
+ if (typeof process.getuid === "function" && process.getuid() !== 0) {
332
+ throw new Error("Installing the Tandem engine service on macOS requires root.");
333
+ }
334
+ fs.mkdirSync(paths.logsDir, { recursive: true });
335
+ fs.writeFileSync(def.plistPath, def.content, "utf8");
336
+ await runCommand("launchctl", ["bootout", "system", def.plistPath]).catch(() => null);
337
+ await runCommand("launchctl", ["bootstrap", "system", def.plistPath]);
338
+ await runCommand("launchctl", ["kickstart", "-k", `system/${def.label}`]);
339
+ return def;
340
+ }
341
+ if (process.platform === "win32") {
342
+ fs.mkdirSync(path.dirname(def.scriptPath), { recursive: true });
343
+ fs.mkdirSync(paths.logsDir, { recursive: true });
344
+ fs.writeFileSync(def.scriptPath, def.content, "utf8");
345
+ await runCommand("schtasks", [
346
+ "/Create",
347
+ "/TN",
348
+ def.taskName,
349
+ "/SC",
350
+ "ONLOGON",
351
+ "/RL",
352
+ "HIGHEST",
353
+ "/F",
354
+ "/TR",
355
+ `powershell -NoProfile -ExecutionPolicy Bypass -File "${def.scriptPath}"`,
356
+ ]);
357
+ await runCommand("schtasks", ["/Run", "/TN", def.taskName]).catch(() => null);
358
+ return def;
359
+ }
360
+ throw new Error(`Unsupported platform: ${process.platform}`);
361
+ }
362
+
363
+ async function queryEngineServiceState(paths) {
364
+ if (process.platform === "linux") {
365
+ const result = {
366
+ manager: "systemd",
367
+ unitName: ENGINE_UNIT_NAME,
368
+ installed: fs.existsSync(`/etc/systemd/system/${ENGINE_UNIT_NAME}`),
369
+ active: false,
370
+ enabled: false,
371
+ };
372
+ try {
373
+ const active = await captureCommand("systemctl", ["is-active", ENGINE_UNIT_NAME]);
374
+ result.active = String(active.stdout || "").trim() === "active";
375
+ } catch {}
376
+ try {
377
+ const enabled = await captureCommand("systemctl", ["is-enabled", ENGINE_UNIT_NAME]);
378
+ result.enabled = String(enabled.stdout || "").trim() === "enabled";
379
+ } catch {}
380
+ return result;
381
+ }
382
+ if (process.platform === "darwin") {
383
+ const plistPath = `/Library/LaunchDaemons/${ENGINE_LAUNCHD_LABEL}.plist`;
384
+ const result = {
385
+ manager: "launchd",
386
+ label: ENGINE_LAUNCHD_LABEL,
387
+ installed: fs.existsSync(plistPath),
388
+ active: false,
389
+ enabled: fs.existsSync(plistPath),
390
+ };
391
+ try {
392
+ const printed = await captureCommand("launchctl", ["print", `system/${ENGINE_LAUNCHD_LABEL}`]);
393
+ result.active = /state\s*=\s*running/i.test(printed.stdout || "") || /pid = \d+/i.test(printed.stdout || "");
394
+ } catch {}
395
+ return result;
396
+ }
397
+ if (process.platform === "win32") {
398
+ const result = {
399
+ manager: "scheduled-task",
400
+ taskName: WINDOWS_TASK_NAME,
401
+ installed: true,
402
+ active: false,
403
+ enabled: true,
404
+ };
405
+ try {
406
+ const printed = await captureCommand("schtasks", ["/Query", "/TN", WINDOWS_TASK_NAME, "/FO", "LIST", "/V"]);
407
+ const statusLine = String(printed.stdout || "")
408
+ .split(/\r?\n/)
409
+ .find((line) => /^Status:/i.test(line));
410
+ result.active = /Running/i.test(statusLine || "");
411
+ result.installed = true;
412
+ } catch {
413
+ result.installed = false;
414
+ }
415
+ return result;
416
+ }
417
+ return {
418
+ manager: "unknown",
419
+ installed: false,
420
+ active: false,
421
+ enabled: false,
422
+ };
423
+ }
424
+
425
+ async function probeUrl(url, timeoutMs = 1500) {
426
+ try {
427
+ const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
428
+ if (!response.ok) return null;
429
+ return await response.json();
430
+ } catch {
431
+ return null;
432
+ }
433
+ }
434
+
435
+ function addonInfo(name) {
436
+ if (name !== "panel") return null;
437
+ return {
438
+ name: "panel",
439
+ packageName: PANEL_PACKAGE,
440
+ title: "Tandem Control Panel",
441
+ commands: PANEL_COMMANDS,
442
+ installHint: `npm i -g ${PANEL_PACKAGE}`,
443
+ };
444
+ }
445
+
446
+ function getAddonCommand() {
447
+ for (const command of PANEL_COMMANDS) {
448
+ const resolved = findCommandOnPath(command);
449
+ if (resolved) return { command, path: resolved };
450
+ }
451
+ return null;
452
+ }
453
+
454
+ function formatBadge(ok, textOk, textBad) {
455
+ return ok ? textOk : textBad;
456
+ }
457
+
458
+ function printLines(lines) {
459
+ for (const line of lines) console.log(line);
460
+ }
461
+
462
+ async function installPackage(pkgName, env = process.env) {
463
+ const manager = detectPackageManager(env);
464
+ const command = manager.name;
465
+ const args = [...manager.installArgs, `${pkgName}@latest`];
466
+ return runCommand(command, args);
467
+ }
468
+
469
+ async function updatePackage(pkgName, env = process.env) {
470
+ const manager = detectPackageManager(env);
471
+ const command = manager.name;
472
+ const args = [...manager.updateArgs, `${pkgName}@latest`];
473
+ return runCommand(command, args);
474
+ }
475
+
476
+ async function removePackage(pkgName, env = process.env) {
477
+ const manager = detectPackageManager(env);
478
+ const command = manager.name;
479
+ const args = [...manager.removeArgs, pkgName];
480
+ return runCommand(command, args);
481
+ }
482
+
483
+ async function runAddonCli(args, options = {}) {
484
+ const addon = getAddonCommand();
485
+ if (!addon) return null;
486
+ return runCommand(addon.command, args, options);
487
+ }
488
+
489
+ async function runAddonDoctorJson() {
490
+ const addon = getAddonCommand();
491
+ if (!addon) return null;
492
+ try {
493
+ const res = await captureCommand(addon.command, ["doctor", "--json"], { timeoutMs: 2500 });
494
+ return JSON.parse(String(res.stdout || "{}"));
495
+ } catch {
496
+ return null;
497
+ }
498
+ }
499
+
500
+ async function buildDiagnostics(env = process.env) {
501
+ const paths = resolveTandemPaths(env);
502
+ const service = await queryEngineServiceState(paths);
503
+ const engineHealth = await probeUrl(`http://${paths.engineHost}:${paths.enginePort}/global/health`);
504
+ const addon = getAddonCommand();
505
+
506
+ return {
507
+ package: packageInfo.name,
508
+ version: packageInfo.version,
509
+ paths,
510
+ service,
511
+ engine: {
512
+ url: `http://${paths.engineHost}:${paths.enginePort}`,
513
+ health: engineHealth,
514
+ reachable: Boolean(engineHealth),
515
+ },
516
+ addon: addon
517
+ ? {
518
+ installed: true,
519
+ command: addon.command,
520
+ }
521
+ : {
522
+ installed: false,
523
+ installHint: addonInfo("panel").installHint,
524
+ },
525
+ };
526
+ }
527
+
528
+ async function printDiagnostics(report, json = false) {
529
+ if (json) {
530
+ console.log(JSON.stringify(report, null, 2));
531
+ return;
532
+ }
533
+ printLines([
534
+ `[Tandem] workflow engine: ${formatBadge(report.engine.reachable, "online", "offline")}`,
535
+ `[Tandem] engine url: ${report.engine.url}`,
536
+ `[Tandem] service manager: ${report.service.manager}${report.service.installed ? "" : " (not installed)"}`,
537
+ `[Tandem] service state: ${formatBadge(report.service.active, "running", "stopped")}`,
538
+ report.addon.installed
539
+ ? `[Tandem] panel add-on: installed${report.addon.command ? ` (${report.addon.command})` : ""}`
540
+ : `[Tandem] panel add-on: missing. install with: ${report.addon.installHint}`,
541
+ ]);
542
+ if (report.engine.health) {
543
+ printLines([
544
+ `[Tandem] build: ${report.engine.health.buildVersion || report.engine.health.version || "unknown"}`,
545
+ `[Tandem] ready: ${String(report.engine.health.ready === true)}`,
546
+ ]);
547
+ }
548
+ }
549
+
550
+ async function printStatus(report, json = false) {
551
+ if (json) {
552
+ console.log(JSON.stringify(report, null, 2));
553
+ return;
554
+ }
555
+ const panelLine = report.addon.installed ? report.addon.command : `install with ${report.addon.installHint}`;
556
+ printLines([
557
+ `[Tandem] engine: ${formatBadge(report.engine.reachable, "online", "offline")} (${report.engine.url})`,
558
+ `[Tandem] service: ${report.service.manager} ${formatBadge(report.service.active, "running", "stopped")}`,
559
+ `[Tandem] panel: ${report.addon.installed ? panelLine : `missing, ${panelLine}`}`,
560
+ ]);
561
+ }
562
+
563
+ async function handleServiceCommand(subcommand, cli, env = process.env) {
564
+ const paths = resolveTandemPaths(env);
565
+ if (subcommand === "install") {
566
+ const service = await ensureEngineServiceInstalled(paths, env);
567
+ console.log(`[Tandem] installed engine service via ${service.manager}.`);
568
+ console.log(`[Tandem] logs: ${service.logPath}`);
569
+ return 0;
570
+ }
571
+ if (subcommand === "status") {
572
+ const service = await queryEngineServiceState(paths);
573
+ console.log(`[Tandem] engine service: ${service.manager} ${formatBadge(service.active, "running", "stopped")}`);
574
+ console.log(`[Tandem] installed: ${service.installed ? "yes" : "no"}`);
575
+ return 0;
576
+ }
577
+ if (subcommand === "logs") {
578
+ const logPath = path.join(paths.logsDir, "engine.log");
579
+ if (!fs.existsSync(logPath)) {
580
+ console.log(`[Tandem] no log file yet at ${logPath}`);
581
+ return 0;
582
+ }
583
+ const text = fs.readFileSync(logPath, "utf8");
584
+ const lines = text.split(/\r?\n/).filter(Boolean).slice(-200);
585
+ for (const line of lines) console.log(line);
586
+ return 0;
587
+ }
588
+ if (!["start", "stop", "restart", "uninstall"].includes(subcommand)) {
589
+ throw new Error(`Unknown service command: ${subcommand}`);
590
+ }
591
+ if (process.platform === "linux") {
592
+ await runCommand("systemctl", [subcommand, ENGINE_UNIT_NAME]);
593
+ return 0;
594
+ }
595
+ if (process.platform === "darwin") {
596
+ if (subcommand === "uninstall") {
597
+ await runCommand("launchctl", ["bootout", "system", `/Library/LaunchDaemons/${ENGINE_LAUNCHD_LABEL}.plist`]).catch(() => null);
598
+ return 0;
599
+ }
600
+ if (subcommand === "restart") {
601
+ await runCommand("launchctl", ["kickstart", "-k", `system/${ENGINE_LAUNCHD_LABEL}`]);
602
+ return 0;
603
+ }
604
+ if (subcommand === "start") {
605
+ await runCommand("launchctl", ["kickstart", `system/${ENGINE_LAUNCHD_LABEL}`]);
606
+ return 0;
607
+ }
608
+ if (subcommand === "stop") {
609
+ await runCommand("launchctl", ["bootout", "system", `/Library/LaunchDaemons/${ENGINE_LAUNCHD_LABEL}.plist`]).catch(() => null);
610
+ return 0;
611
+ }
612
+ }
613
+ if (process.platform === "win32") {
614
+ if (subcommand === "start") {
615
+ await runCommand("schtasks", ["/Run", "/TN", WINDOWS_TASK_NAME]);
616
+ return 0;
617
+ }
618
+ if (subcommand === "stop") {
619
+ await runCommand("schtasks", ["/End", "/TN", WINDOWS_TASK_NAME]).catch(() => null);
620
+ return 0;
621
+ }
622
+ if (subcommand === "restart") {
623
+ await runCommand("schtasks", ["/End", "/TN", WINDOWS_TASK_NAME]).catch(() => null);
624
+ await runCommand("schtasks", ["/Run", "/TN", WINDOWS_TASK_NAME]);
625
+ return 0;
626
+ }
627
+ if (subcommand === "uninstall") {
628
+ await runCommand("schtasks", ["/Delete", "/TN", WINDOWS_TASK_NAME, "/F"]).catch(() => null);
629
+ return 0;
630
+ }
631
+ }
632
+ throw new Error(`Service command is not supported on ${process.platform}.`);
633
+ }
634
+
635
+ async function handlePanelCommand(subcommand, cli, env = process.env) {
636
+ const addon = getAddonCommand();
637
+ if (subcommand === "install") {
638
+ return handleAddonCommand("install", "panel", cli, env);
639
+ }
640
+ if (!addon) {
641
+ console.log(`[Tandem] panel add-on is not installed. Run: tandem install panel`);
642
+ return 0;
643
+ }
644
+ if (subcommand === "open") {
645
+ const report = await runAddonDoctorJson();
646
+ const url = report?.panelPublicUrl
647
+ || (report?.panelHost && report?.panelPort ? `http://${report.panelHost}:${report.panelPort}` : `http://${DEFAULT_PANEL_HOST}:${DEFAULT_PANEL_PORT}`);
648
+ await openUrl(url);
649
+ console.log(`[Tandem] opening ${url}`);
650
+ return 0;
651
+ }
652
+ if (subcommand === "status" || subcommand === "doctor" || subcommand === "init" || subcommand === "run" || subcommand === "service") {
653
+ const args = [subcommand, ...cli.argv.slice(1)];
654
+ if (subcommand === "status") {
655
+ const report = await runAddonDoctorJson();
656
+ if (report) {
657
+ console.log(`[Tandem] panel: http://${report.panelHost}:${report.panelPort}`);
658
+ console.log(`[Tandem] engine: ${report.engineUrl}`);
659
+ return 0;
660
+ }
661
+ console.log(`[Tandem] panel add-on is installed but did not return a quick status response.`);
662
+ console.log(`[Tandem] try: tandem panel doctor`);
663
+ return 0;
664
+ }
665
+ await runCommand(addon.command, args.slice(1), { stdio: "inherit" });
666
+ return 0;
667
+ }
668
+ await runCommand(addon.command, [subcommand, ...cli.argv.slice(1)], { stdio: "inherit" });
669
+ return 0;
670
+ }
671
+
672
+ async function handleAddonCommand(action, maybeName, cli, env = process.env) {
673
+ const name = maybeName || String(cli.argv[0] || "").trim();
674
+ const addon = addonInfo(name);
675
+ if (!addon) {
676
+ throw new Error(`Unknown add-on: ${name}`);
677
+ }
678
+ if (action === "list") {
679
+ const installed = Boolean(getAddonCommand());
680
+ console.log(`[Tandem] ${addon.name}: ${installed ? "installed" : "missing"}`);
681
+ console.log(`[Tandem] package: ${addon.packageName}`);
682
+ return 0;
683
+ }
684
+ if (action === "install") {
685
+ await installPackage(addon.packageName, env);
686
+ console.log(`[Tandem] installed ${addon.packageName}.`);
687
+ console.log(`[Tandem] next: tandem panel init`);
688
+ return 0;
689
+ }
690
+ if (action === "update") {
691
+ await updatePackage(addon.packageName, env);
692
+ console.log(`[Tandem] updated ${addon.packageName}.`);
693
+ return 0;
694
+ }
695
+ if (action === "remove") {
696
+ const addonCli = getAddonCommand();
697
+ if (addonCli) {
698
+ await runCommand(addonCli.command, ["service", "uninstall"]).catch(() => null);
699
+ }
700
+ await removePackage(addon.packageName, env);
701
+ console.log(`[Tandem] removed ${addon.packageName}.`);
702
+ return 0;
703
+ }
704
+ throw new Error(`Unknown add-on action: ${action}`);
705
+ }
706
+
707
+ async function openUrl(url) {
708
+ if (process.platform === "darwin") {
709
+ await runCommand("open", [url]);
710
+ return;
711
+ }
712
+ if (process.platform === "linux") {
713
+ await runCommand("xdg-open", [url]);
714
+ return;
715
+ }
716
+ if (process.platform === "win32") {
717
+ await runCommand("cmd", ["/c", "start", "", url]);
718
+ return;
719
+ }
720
+ throw new Error(`Unsupported platform for browser open: ${process.platform}`);
721
+ }
722
+
723
+ async function handleInstallCommand(subcommand, cli, env = process.env) {
724
+ if (subcommand === "panel") {
725
+ return handleAddonCommand("install", "panel", cli, env);
726
+ }
727
+ throw new Error(`Unknown install target: ${subcommand}`);
728
+ }
729
+
730
+ async function handleUpdateCommand(cli, env = process.env) {
731
+ await updatePackage(ENGINE_PACKAGE, env);
732
+ console.log(`[Tandem] updated ${ENGINE_PACKAGE}.`);
733
+ if (getAddonCommand()) {
734
+ await updatePackage(PANEL_PACKAGE, env).catch(() => null);
735
+ console.log(`[Tandem] updated ${PANEL_PACKAGE}.`);
736
+ }
737
+ console.log("[Tandem] restart the running service to pick up the new binaries.");
738
+ return 0;
739
+ }
740
+
741
+ async function main(argv = process.argv.slice(2), env = process.env) {
742
+ const cli = parseArgs(argv);
743
+ const command = String(argv[0] || "").trim().toLowerCase();
744
+
745
+ if (!command) {
746
+ console.log(`[Tandem] ${packageInfo.name} ${packageInfo.version}`);
747
+ console.log("[Tandem] Use: tandem doctor | tandem status | tandem service install | tandem install panel");
748
+ return 0;
749
+ }
750
+
751
+ if (command === "--help" || command === "-h" || command === "help") {
752
+ console.log([
753
+ "Tandem master CLI",
754
+ "",
755
+ "Commands:",
756
+ " tandem doctor",
757
+ " tandem status",
758
+ " tandem service install|start|stop|restart|status|logs",
759
+ " tandem install panel",
760
+ " tandem update",
761
+ " tandem panel status|open|init|service ...",
762
+ " tandem addon list|install|update|remove panel",
763
+ " tandem run|serve|tool|parallel|providers|browser|memory ...",
764
+ " tandem-engine serve --hostname 127.0.0.1 --port 39731",
765
+ ].join("\n"));
766
+ return 0;
767
+ }
768
+
769
+ if (command === "doctor") {
770
+ const report = await buildDiagnostics(env);
771
+ await printDiagnostics(report, cli.has("json"));
772
+ return report.engine.reachable ? 0 : 1;
773
+ }
774
+
775
+ if (command === "status") {
776
+ const report = await buildDiagnostics(env);
777
+ await printStatus(report, cli.has("json"));
778
+ return 0;
779
+ }
780
+
781
+ if (command === "service") {
782
+ const subcommand = String(argv[1] || "status").trim().toLowerCase();
783
+ return handleServiceCommand(subcommand, cli, env);
784
+ }
785
+
786
+ if (command === "install") {
787
+ const subcommand = String(argv[1] || "").trim().toLowerCase();
788
+ return handleInstallCommand(subcommand, cli, env);
789
+ }
790
+
791
+ if (command === "update") {
792
+ const subcommand = String(argv[1] || "").trim().toLowerCase();
793
+ if (subcommand === "panel") {
794
+ return handleAddonCommand("update", "panel", cli, env);
795
+ }
796
+ return handleUpdateCommand(cli, env);
797
+ }
798
+
799
+ if (command === "addon" || command === "addons") {
800
+ const action = String(argv[1] || "list").trim().toLowerCase();
801
+ const name = String(argv[2] || "panel").trim().toLowerCase();
802
+ if (action === "list") return handleAddonCommand("list", name, cli, env);
803
+ if (action === "install") return handleAddonCommand("install", name, cli, env);
804
+ if (action === "update") return handleAddonCommand("update", name, cli, env);
805
+ if (action === "remove") return handleAddonCommand("remove", name, cli, env);
806
+ throw new Error(`Unknown add-on action: ${action}`);
807
+ }
808
+
809
+ if (command === "panel") {
810
+ const subcommand = String(argv[1] || "status").trim().toLowerCase();
811
+ return handlePanelCommand(subcommand, { argv: argv.slice(1) }, env);
812
+ }
813
+
814
+ if (command === "run") {
815
+ await runCommand(process.execPath, [ENGINE_ENTRYPOINT, ...argv], { stdio: "inherit" });
816
+ return 0;
817
+ }
818
+
819
+ if (command === "tandem-engine" || command === "engine") {
820
+ await runCommand(process.execPath, [ENGINE_ENTRYPOINT, ...argv.slice(1)], {
821
+ stdio: "inherit",
822
+ });
823
+ return 0;
824
+ }
825
+
826
+ await runCommand(process.execPath, [ENGINE_ENTRYPOINT, ...argv], { stdio: "inherit" });
827
+ return 0;
828
+ }
829
+
830
+ if (require.main === module) {
831
+ main().then((code) => {
832
+ if (typeof code === "number") process.exit(code);
833
+ process.exit(0);
834
+ }).catch((error) => {
835
+ console.error(`[Tandem] ERROR: ${error instanceof Error ? error.message : String(error)}`);
836
+ process.exit(1);
837
+ });
838
+ }
839
+
840
+ module.exports = {
841
+ addonInfo,
842
+ buildEngineServiceDefinition,
843
+ buildDiagnostics,
844
+ detectPackageManager,
845
+ findCommandOnPath,
846
+ handleAddonCommand,
847
+ handleInstallCommand,
848
+ handlePanelCommand,
849
+ handleServiceCommand,
850
+ handleUpdateCommand,
851
+ main,
852
+ openUrl,
853
+ parseArgs,
854
+ printDiagnostics,
855
+ printStatus,
856
+ queryEngineServiceState,
857
+ resolveTandemHomeDir,
858
+ resolveTandemPaths,
859
+ runAddonCli,
860
+ runCommand,
861
+ updatePackage,
862
+ };
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "@frumu/tandem",
3
- "version": "0.4.15",
4
- "description": "Tandem Engine binary distribution",
3
+ "version": "0.4.16",
4
+ "description": "Tandem master CLI and engine binary distribution",
5
5
  "homepage": "https://tandem.frumu.ai",
6
6
  "bin": {
7
+ "tandem": "bin/tandem.js",
7
8
  "tandem-engine": "bin/tandem-engine.js"
8
9
  },
9
10
  "scripts": {