@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.
- package/README.md +46 -5
- package/bin/tandem.js +862 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Tandem
|
|
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
|
|
14
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
4
|
-
"description": "Tandem
|
|
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": {
|