@frumu/tandem-panel 0.4.9 → 0.4.11
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 +23 -0
- package/bin/docker-token.js +54 -0
- package/bin/setup.js +3 -3
- package/dist/assets/{index-CCT37xUj.css → index-46K5pYDz.css} +1 -1
- package/dist/assets/index-ekgYKmmV.js +2460 -0
- package/dist/assets/{react-query-CeeFMKtE.js → react-query-wD0mx2Xi.js} +1 -1
- package/dist/assets/{vendor-UXzYZoAT.js → vendor-BB3fzNns.js} +2 -2
- package/dist/index.html +4 -4
- package/lib/setup/bootstrap.js +50 -0
- package/lib/setup/common.js +97 -0
- package/lib/setup/doctor.js +74 -0
- package/lib/setup/env.js +190 -0
- package/lib/setup/paths.js +65 -0
- package/lib/setup/services/common.js +45 -0
- package/lib/setup/services/launchd.js +109 -0
- package/lib/setup/services/systemd.js +104 -0
- package/package.json +11 -3
- package/server/routes/swarm.js +949 -0
- package/server/services/orchestratorService.js +147 -0
- package/dist/assets/index-VDmCg_3Z.js +0 -2460
package/lib/setup/env.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
2
|
+
import { mkdir } from "fs/promises";
|
|
3
|
+
import { randomBytes } from "crypto";
|
|
4
|
+
import { dirname, resolve } from "path";
|
|
5
|
+
|
|
6
|
+
import { resolveSetupPaths } from "./paths.js";
|
|
7
|
+
|
|
8
|
+
function parseDotEnv(content) {
|
|
9
|
+
const out = {};
|
|
10
|
+
for (const raw of String(content || "").split(/\r?\n/)) {
|
|
11
|
+
const line = raw.trim();
|
|
12
|
+
if (!line || line.startsWith("#")) continue;
|
|
13
|
+
const idx = line.indexOf("=");
|
|
14
|
+
if (idx <= 0) continue;
|
|
15
|
+
const key = line.slice(0, idx).trim();
|
|
16
|
+
let value = line.slice(idx + 1).trim();
|
|
17
|
+
if (
|
|
18
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
19
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
20
|
+
) {
|
|
21
|
+
value = value.slice(1, -1);
|
|
22
|
+
}
|
|
23
|
+
out[key] = value;
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function serializeEnv(entries) {
|
|
29
|
+
return `${entries.map(([k, v]) => `${k}=${v}`).join("\n")}\n`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function loadDotEnvFile(pathname, targetEnv = process.env) {
|
|
33
|
+
if (!pathname || !existsSync(pathname)) return false;
|
|
34
|
+
const parsed = parseDotEnv(readFileSync(pathname, "utf8"));
|
|
35
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
36
|
+
if (targetEnv[key] === undefined) targetEnv[key] = value;
|
|
37
|
+
}
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function resolveEnvLoadOrder(options = {}) {
|
|
42
|
+
const env = options.env || process.env;
|
|
43
|
+
const cwd = resolve(options.cwd || process.cwd());
|
|
44
|
+
const paths = resolveSetupPaths({
|
|
45
|
+
env,
|
|
46
|
+
platform: options.platform,
|
|
47
|
+
home: options.home,
|
|
48
|
+
allowAmbientStateEnv: options.allowAmbientStateEnv,
|
|
49
|
+
});
|
|
50
|
+
const explicit = String(options.explicitEnvFile || env.TANDEM_CONTROL_PANEL_ENV_FILE || "").trim();
|
|
51
|
+
const order = [];
|
|
52
|
+
if (explicit) order.push(resolve(explicit));
|
|
53
|
+
order.push(paths.envFile);
|
|
54
|
+
order.push(resolve(cwd, ".env"));
|
|
55
|
+
return [...new Set(order.filter(Boolean))];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function bootstrapDefaults(paths) {
|
|
59
|
+
return {
|
|
60
|
+
TANDEM_CONTROL_PANEL_PORT: "39732",
|
|
61
|
+
TANDEM_CONTROL_PANEL_HOST: "127.0.0.1",
|
|
62
|
+
TANDEM_CONTROL_PANEL_PUBLIC_URL: "",
|
|
63
|
+
TANDEM_ENGINE_URL: "http://127.0.0.1:39731",
|
|
64
|
+
TANDEM_ENGINE_HOST: "127.0.0.1",
|
|
65
|
+
TANDEM_ENGINE_PORT: "39731",
|
|
66
|
+
TANDEM_CONTROL_PANEL_AUTO_START_ENGINE: "1",
|
|
67
|
+
TANDEM_CONTROL_PANEL_STATE_DIR: paths.controlPanelStateDir,
|
|
68
|
+
TANDEM_STATE_DIR: paths.engineStateDir,
|
|
69
|
+
TANDEM_CONTROL_PANEL_ENGINE_TOKEN: "tk_change_me",
|
|
70
|
+
TANDEM_DISABLE_TOOL_GUARD_BUDGETS: "1",
|
|
71
|
+
TANDEM_TOOL_ROUTER_ENABLED: "0",
|
|
72
|
+
TANDEM_PROMPT_CONTEXT_HOOK_TIMEOUT_MS: "5000",
|
|
73
|
+
TANDEM_PROVIDER_STREAM_CONNECT_TIMEOUT_MS: "30000",
|
|
74
|
+
TANDEM_PROVIDER_STREAM_IDLE_TIMEOUT_MS: "90000",
|
|
75
|
+
TANDEM_PERMISSION_WAIT_TIMEOUT_MS: "15000",
|
|
76
|
+
TANDEM_TOOL_EXEC_TIMEOUT_MS: "45000",
|
|
77
|
+
TANDEM_BASH_TIMEOUT_MS: "30000",
|
|
78
|
+
TANDEM_CONTROL_PANEL_SESSION_TTL_MINUTES: "1440",
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function ensureBootstrapEnv(options = {}) {
|
|
83
|
+
const cwd = resolve(options.cwd || process.cwd());
|
|
84
|
+
const paths = resolveSetupPaths({
|
|
85
|
+
env: options.env || process.env,
|
|
86
|
+
platform: options.platform,
|
|
87
|
+
allowAmbientStateEnv: options.allowAmbientStateEnv,
|
|
88
|
+
});
|
|
89
|
+
const envPath = resolve(options.envPath || paths.envFile);
|
|
90
|
+
const overwrite = options.overwrite === true;
|
|
91
|
+
const cwdEnvPath = resolve(cwd, ".env");
|
|
92
|
+
const sourceExamplePath = resolve(cwd, ".env.example");
|
|
93
|
+
const existing = existsSync(envPath) ? parseDotEnv(readFileSync(envPath, "utf8")) : {};
|
|
94
|
+
const cwdEnv =
|
|
95
|
+
options.allowCwdEnvMerge !== false && envPath !== cwdEnvPath && existsSync(cwdEnvPath)
|
|
96
|
+
? parseDotEnv(readFileSync(cwdEnvPath, "utf8"))
|
|
97
|
+
: {};
|
|
98
|
+
const example = options.allowCwdEnvMerge !== false && existsSync(sourceExamplePath)
|
|
99
|
+
? parseDotEnv(readFileSync(sourceExamplePath, "utf8"))
|
|
100
|
+
: {};
|
|
101
|
+
const defaults = { ...bootstrapDefaults(paths), ...example };
|
|
102
|
+
const merged = { ...defaults, ...cwdEnv, ...existing };
|
|
103
|
+
|
|
104
|
+
if (
|
|
105
|
+
overwrite ||
|
|
106
|
+
!merged.TANDEM_CONTROL_PANEL_ENGINE_TOKEN ||
|
|
107
|
+
merged.TANDEM_CONTROL_PANEL_ENGINE_TOKEN === "tk_change_me"
|
|
108
|
+
) {
|
|
109
|
+
merged.TANDEM_CONTROL_PANEL_ENGINE_TOKEN = `tk_${randomBytes(16).toString("hex")}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
merged.TANDEM_CONTROL_PANEL_STATE_DIR =
|
|
113
|
+
merged.TANDEM_CONTROL_PANEL_STATE_DIR || paths.controlPanelStateDir;
|
|
114
|
+
merged.TANDEM_STATE_DIR = merged.TANDEM_STATE_DIR || paths.engineStateDir;
|
|
115
|
+
merged.TANDEM_CONTROL_PANEL_HOST = merged.TANDEM_CONTROL_PANEL_HOST || "127.0.0.1";
|
|
116
|
+
|
|
117
|
+
const preferredOrder = [
|
|
118
|
+
"TANDEM_CONTROL_PANEL_PORT",
|
|
119
|
+
"TANDEM_CONTROL_PANEL_HOST",
|
|
120
|
+
"TANDEM_CONTROL_PANEL_PUBLIC_URL",
|
|
121
|
+
"TANDEM_ENGINE_URL",
|
|
122
|
+
"TANDEM_ENGINE_HOST",
|
|
123
|
+
"TANDEM_ENGINE_PORT",
|
|
124
|
+
"TANDEM_STATE_DIR",
|
|
125
|
+
"TANDEM_CONTROL_PANEL_STATE_DIR",
|
|
126
|
+
"TANDEM_CONTROL_PANEL_AUTO_START_ENGINE",
|
|
127
|
+
"TANDEM_CONTROL_PANEL_ENGINE_TOKEN",
|
|
128
|
+
"TANDEM_CONTROL_PANEL_SESSION_TTL_MINUTES",
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
const ordered = [];
|
|
132
|
+
for (const key of preferredOrder) {
|
|
133
|
+
if (merged[key] !== undefined) ordered.push([key, merged[key]]);
|
|
134
|
+
}
|
|
135
|
+
for (const [key, value] of Object.entries(merged)) {
|
|
136
|
+
if (!preferredOrder.includes(key)) ordered.push([key, value]);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
await mkdir(dirname(envPath), { recursive: true });
|
|
140
|
+
await mkdir(paths.logsDir, { recursive: true });
|
|
141
|
+
await mkdir(paths.engineStateDir, { recursive: true });
|
|
142
|
+
await mkdir(paths.controlPanelStateDir, { recursive: true });
|
|
143
|
+
writeFileSync(envPath, serializeEnv(ordered), "utf8");
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
envPath,
|
|
147
|
+
token: merged.TANDEM_CONTROL_PANEL_ENGINE_TOKEN,
|
|
148
|
+
engineUrl:
|
|
149
|
+
merged.TANDEM_ENGINE_URL ||
|
|
150
|
+
`http://${merged.TANDEM_ENGINE_HOST || "127.0.0.1"}:${merged.TANDEM_ENGINE_PORT || "39731"}`,
|
|
151
|
+
panelHost: merged.TANDEM_CONTROL_PANEL_HOST || "127.0.0.1",
|
|
152
|
+
panelPort: merged.TANDEM_CONTROL_PANEL_PORT || "39732",
|
|
153
|
+
paths,
|
|
154
|
+
env: merged,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function bootstrapEngineConfig(options = {}) {
|
|
159
|
+
const env = options.env || {};
|
|
160
|
+
const stateDir = resolve(String(env.TANDEM_STATE_DIR || options.stateDir || "").trim() || ".");
|
|
161
|
+
const configPath = resolve(stateDir, "config.json");
|
|
162
|
+
if (existsSync(configPath)) return { configPath, created: false };
|
|
163
|
+
await mkdir(dirname(configPath), { recursive: true });
|
|
164
|
+
writeFileSync(
|
|
165
|
+
configPath,
|
|
166
|
+
JSON.stringify(
|
|
167
|
+
{
|
|
168
|
+
default_provider: "openrouter",
|
|
169
|
+
providers: {
|
|
170
|
+
openrouter: { default_model: "google/gemini-2.5-pro-preview" },
|
|
171
|
+
openai: { default_model: "gpt-4o-mini" },
|
|
172
|
+
anthropic: { default_model: "claude-sonnet-4-5-latest" },
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
null,
|
|
176
|
+
2
|
|
177
|
+
),
|
|
178
|
+
"utf8"
|
|
179
|
+
);
|
|
180
|
+
return { configPath, created: true };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export {
|
|
184
|
+
bootstrapEngineConfig,
|
|
185
|
+
ensureBootstrapEnv,
|
|
186
|
+
loadDotEnvFile,
|
|
187
|
+
parseDotEnv,
|
|
188
|
+
resolveEnvLoadOrder,
|
|
189
|
+
serializeEnv,
|
|
190
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { homedir } from "os";
|
|
2
|
+
import { join, resolve } from "path";
|
|
3
|
+
|
|
4
|
+
function defaultConfigBase(platform, home, env) {
|
|
5
|
+
if (platform === "darwin") {
|
|
6
|
+
return env.XDG_CONFIG_HOME || join(home, "Library", "Application Support");
|
|
7
|
+
}
|
|
8
|
+
return env.XDG_CONFIG_HOME || join(home, ".config");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function defaultDataBase(platform, home, env) {
|
|
12
|
+
if (platform === "darwin") {
|
|
13
|
+
return env.XDG_DATA_HOME || join(home, "Library", "Application Support");
|
|
14
|
+
}
|
|
15
|
+
return env.XDG_DATA_HOME || join(home, ".local", "share");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function normalizeDir(value, fallback) {
|
|
19
|
+
const text = String(value || "").trim();
|
|
20
|
+
return resolve(text || fallback);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function resolveSetupPaths(options = {}) {
|
|
24
|
+
const env = options.env || process.env;
|
|
25
|
+
const platform = options.platform || process.platform;
|
|
26
|
+
const home = options.home || env.HOME || homedir();
|
|
27
|
+
const allowAmbientStateEnv = options.allowAmbientStateEnv !== false;
|
|
28
|
+
|
|
29
|
+
const configBase = normalizeDir(
|
|
30
|
+
env.TANDEM_CONFIG_HOME,
|
|
31
|
+
defaultConfigBase(platform, home, env)
|
|
32
|
+
);
|
|
33
|
+
const dataBase = normalizeDir(env.TANDEM_DATA_HOME, defaultDataBase(platform, home, env));
|
|
34
|
+
const configDir = resolve(configBase, "tandem");
|
|
35
|
+
const dataDir = resolve(dataBase, "tandem");
|
|
36
|
+
const logsDir = resolve(dataDir, "logs");
|
|
37
|
+
const controlPanelStateDir = normalizeDir(
|
|
38
|
+
allowAmbientStateEnv ? env.TANDEM_CONTROL_PANEL_STATE_DIR : "",
|
|
39
|
+
resolve(dataDir, "control-panel")
|
|
40
|
+
);
|
|
41
|
+
const engineStateDir = normalizeDir(
|
|
42
|
+
allowAmbientStateEnv ? env.TANDEM_STATE_DIR : "",
|
|
43
|
+
resolve(dataDir, "data")
|
|
44
|
+
);
|
|
45
|
+
const envFile = normalizeDir(
|
|
46
|
+
env.TANDEM_CONTROL_PANEL_ENV_FILE,
|
|
47
|
+
resolve(configDir, "control-panel.env")
|
|
48
|
+
);
|
|
49
|
+
const tokenFile = resolve(dataDir, "security", "engine_api_token");
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
home: resolve(home),
|
|
53
|
+
configBase,
|
|
54
|
+
dataBase,
|
|
55
|
+
configDir,
|
|
56
|
+
dataDir,
|
|
57
|
+
logsDir,
|
|
58
|
+
controlPanelStateDir,
|
|
59
|
+
engineStateDir,
|
|
60
|
+
envFile,
|
|
61
|
+
tokenFile,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export { resolveSetupPaths };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { createRequire } from "module";
|
|
2
|
+
import { existsSync } from "fs";
|
|
3
|
+
import { join, resolve } from "path";
|
|
4
|
+
|
|
5
|
+
import { runCmd } from "../common.js";
|
|
6
|
+
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
|
|
9
|
+
function resolveControlPanelRoot() {
|
|
10
|
+
return resolve(join(new URL("../../", import.meta.url).pathname));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function resolveScriptPath(name) {
|
|
14
|
+
return resolveControlPanelRoot() + `/bin/${name}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function resolveEngineEntrypoint() {
|
|
18
|
+
return require.resolve("@frumu/tandem/bin/tandem-engine.js");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function resolveUserHome(user, platform = process.platform) {
|
|
22
|
+
const name = String(user || "").trim();
|
|
23
|
+
if (!name) return "";
|
|
24
|
+
if (platform === "linux") {
|
|
25
|
+
try {
|
|
26
|
+
const out = await runCmd("getent", ["passwd", name]);
|
|
27
|
+
const row = String(out.stdout || "").trim();
|
|
28
|
+
const fields = row.split(":");
|
|
29
|
+
return fields[5] ? resolve(fields[5]) : "";
|
|
30
|
+
} catch {}
|
|
31
|
+
}
|
|
32
|
+
if (platform === "darwin") {
|
|
33
|
+
try {
|
|
34
|
+
const out = await runCmd("dscl", [".", "-read", `/Users/${name}`, "NFSHomeDirectory"]);
|
|
35
|
+
const line = String(out.stdout || "")
|
|
36
|
+
.split(/\r?\n/)
|
|
37
|
+
.find((row) => row.includes("NFSHomeDirectory:"));
|
|
38
|
+
return line ? resolve(line.split(":").slice(1).join(":").trim()) : "";
|
|
39
|
+
} catch {}
|
|
40
|
+
}
|
|
41
|
+
const guess = platform === "darwin" ? `/Users/${name}` : `/home/${name}`;
|
|
42
|
+
return existsSync(guess) ? resolve(guess) : "";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export { resolveControlPanelRoot, resolveEngineEntrypoint, resolveScriptPath, resolveUserHome };
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "fs/promises";
|
|
2
|
+
import { resolve } from "path";
|
|
3
|
+
|
|
4
|
+
import { log, runCmd } from "../common.js";
|
|
5
|
+
import { resolveScriptPath } from "./common.js";
|
|
6
|
+
|
|
7
|
+
function buildLaunchdPlists(options = {}) {
|
|
8
|
+
const nodePath = options.nodePath || process.execPath;
|
|
9
|
+
const serviceRunner = resolveScriptPath("service-runner.js");
|
|
10
|
+
const envFile = options.envFile;
|
|
11
|
+
const logsDir = resolve(options.logsDir);
|
|
12
|
+
const homeDir = resolve(options.homeDir);
|
|
13
|
+
const userName = options.serviceUser;
|
|
14
|
+
const engineLabel = "ai.frumu.tandem.engine";
|
|
15
|
+
const panelLabel = "ai.frumu.tandem.control-panel";
|
|
16
|
+
const enginePath = `/Library/LaunchDaemons/${engineLabel}.plist`;
|
|
17
|
+
const panelPath = `/Library/LaunchDaemons/${panelLabel}.plist`;
|
|
18
|
+
const plistFor = (label, mode) => `<?xml version="1.0" encoding="UTF-8"?>
|
|
19
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
20
|
+
<plist version="1.0">
|
|
21
|
+
<dict>
|
|
22
|
+
<key>Label</key><string>${label}</string>
|
|
23
|
+
<key>UserName</key><string>${userName}</string>
|
|
24
|
+
<key>WorkingDirectory</key><string>${homeDir}</string>
|
|
25
|
+
<key>ProgramArguments</key>
|
|
26
|
+
<array>
|
|
27
|
+
<string>${nodePath}</string>
|
|
28
|
+
<string>${serviceRunner}</string>
|
|
29
|
+
<string>${mode}</string>
|
|
30
|
+
<string>--env-file</string>
|
|
31
|
+
<string>${envFile}</string>
|
|
32
|
+
</array>
|
|
33
|
+
<key>RunAtLoad</key><true/>
|
|
34
|
+
<key>KeepAlive</key><true/>
|
|
35
|
+
<key>ThrottleInterval</key><integer>5</integer>
|
|
36
|
+
<key>StandardOutPath</key><string>${logsDir}/${mode}.log</string>
|
|
37
|
+
<key>StandardErrorPath</key><string>${logsDir}/${mode}.log</string>
|
|
38
|
+
</dict>
|
|
39
|
+
</plist>
|
|
40
|
+
`;
|
|
41
|
+
return {
|
|
42
|
+
engineLabel,
|
|
43
|
+
panelLabel,
|
|
44
|
+
enginePath,
|
|
45
|
+
panelPath,
|
|
46
|
+
enginePlist: plistFor(engineLabel, "engine"),
|
|
47
|
+
panelPlist: plistFor(panelLabel, "panel"),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function installLaunchdServices(options = {}) {
|
|
52
|
+
if (process.platform !== "darwin") {
|
|
53
|
+
throw new Error("launchd install is only supported on macOS.");
|
|
54
|
+
}
|
|
55
|
+
if (typeof process.getuid === "function" && process.getuid() !== 0) {
|
|
56
|
+
throw new Error("launchd install requires root.");
|
|
57
|
+
}
|
|
58
|
+
const plists = buildLaunchdPlists(options);
|
|
59
|
+
await mkdir(options.logsDir, { recursive: true });
|
|
60
|
+
await writeFile(plists.enginePath, plists.enginePlist, "utf8");
|
|
61
|
+
await writeFile(plists.panelPath, plists.panelPlist, "utf8");
|
|
62
|
+
await runCmd("launchctl", ["bootout", "system", plists.enginePath]).catch(() => null);
|
|
63
|
+
await runCmd("launchctl", ["bootout", "system", plists.panelPath]).catch(() => null);
|
|
64
|
+
await runCmd("launchctl", ["bootstrap", "system", plists.enginePath], { stdio: "inherit" });
|
|
65
|
+
await runCmd("launchctl", ["bootstrap", "system", plists.panelPath], { stdio: "inherit" });
|
|
66
|
+
await runCmd("launchctl", ["kickstart", "-k", `system/${plists.engineLabel}`], {
|
|
67
|
+
stdio: "inherit",
|
|
68
|
+
});
|
|
69
|
+
await runCmd("launchctl", ["kickstart", "-k", `system/${plists.panelLabel}`], {
|
|
70
|
+
stdio: "inherit",
|
|
71
|
+
});
|
|
72
|
+
log(`Installed ${plists.engineLabel} and ${plists.panelLabel}`);
|
|
73
|
+
return plists;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function operateLaunchdServices(operation) {
|
|
77
|
+
const labels = ["ai.frumu.tandem.engine", "ai.frumu.tandem.control-panel"];
|
|
78
|
+
if (operation === "status") {
|
|
79
|
+
for (const label of labels) {
|
|
80
|
+
await runCmd("launchctl", ["print", `system/${label}`], { stdio: "inherit" });
|
|
81
|
+
}
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (operation === "logs") {
|
|
85
|
+
throw new Error("launchd logs are file-based; inspect the configured log paths.");
|
|
86
|
+
}
|
|
87
|
+
if (operation === "uninstall") {
|
|
88
|
+
for (const label of labels) {
|
|
89
|
+
await runCmd("launchctl", ["bootout", `system/${label}`], { stdio: "inherit" }).catch(() => null);
|
|
90
|
+
}
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (!["start", "stop", "restart"].includes(operation)) {
|
|
94
|
+
throw new Error(`Unsupported launchd operation: ${operation}`);
|
|
95
|
+
}
|
|
96
|
+
for (const label of labels) {
|
|
97
|
+
if (operation === "stop") {
|
|
98
|
+
await runCmd("launchctl", ["kill", "TERM", `system/${label}`], { stdio: "inherit" }).catch(
|
|
99
|
+
() => null
|
|
100
|
+
);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
await runCmd("launchctl", ["kickstart", operation === "restart" ? "-k" : "", `system/${label}`].filter(Boolean), {
|
|
104
|
+
stdio: "inherit",
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export { buildLaunchdPlists, installLaunchdServices, operateLaunchdServices };
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { writeFile } from "fs/promises";
|
|
2
|
+
|
|
3
|
+
import { log, runCmd } from "../common.js";
|
|
4
|
+
import { resolveEngineEntrypoint, resolveScriptPath } from "./common.js";
|
|
5
|
+
|
|
6
|
+
function buildSystemdUnits(options = {}) {
|
|
7
|
+
const nodePath = options.nodePath || process.execPath;
|
|
8
|
+
const serviceRunner = resolveScriptPath("service-runner.js");
|
|
9
|
+
const envFile = options.envFile;
|
|
10
|
+
const serviceUser = options.serviceUser;
|
|
11
|
+
const serviceGroup = options.serviceGroup || serviceUser;
|
|
12
|
+
const engineLabel = "tandem-engine";
|
|
13
|
+
const panelLabel = "tandem-control-panel";
|
|
14
|
+
const engineUnit = `[Unit]
|
|
15
|
+
Description=Tandem Engine
|
|
16
|
+
After=network-online.target
|
|
17
|
+
Wants=network-online.target
|
|
18
|
+
|
|
19
|
+
[Service]
|
|
20
|
+
Type=simple
|
|
21
|
+
User=${serviceUser}
|
|
22
|
+
Group=${serviceGroup}
|
|
23
|
+
WorkingDirectory=${options.homeDir}
|
|
24
|
+
ExecStart=${nodePath} ${serviceRunner} engine --env-file ${envFile}
|
|
25
|
+
Restart=on-failure
|
|
26
|
+
RestartSec=5
|
|
27
|
+
NoNewPrivileges=true
|
|
28
|
+
PrivateTmp=true
|
|
29
|
+
|
|
30
|
+
[Install]
|
|
31
|
+
WantedBy=multi-user.target
|
|
32
|
+
`;
|
|
33
|
+
const panelUnit = `[Unit]
|
|
34
|
+
Description=Tandem Control Panel
|
|
35
|
+
After=network-online.target ${engineLabel}.service
|
|
36
|
+
Wants=network-online.target
|
|
37
|
+
|
|
38
|
+
[Service]
|
|
39
|
+
Type=simple
|
|
40
|
+
User=${serviceUser}
|
|
41
|
+
Group=${serviceGroup}
|
|
42
|
+
WorkingDirectory=${options.homeDir}
|
|
43
|
+
ExecStart=${nodePath} ${serviceRunner} panel --env-file ${envFile}
|
|
44
|
+
Restart=on-failure
|
|
45
|
+
RestartSec=5
|
|
46
|
+
|
|
47
|
+
[Install]
|
|
48
|
+
WantedBy=multi-user.target
|
|
49
|
+
`;
|
|
50
|
+
return {
|
|
51
|
+
engineLabel,
|
|
52
|
+
panelLabel,
|
|
53
|
+
enginePath: `/etc/systemd/system/${engineLabel}.service`,
|
|
54
|
+
panelPath: `/etc/systemd/system/${panelLabel}.service`,
|
|
55
|
+
engineUnit,
|
|
56
|
+
panelUnit,
|
|
57
|
+
engineEntrypoint: resolveEngineEntrypoint(),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function installSystemdServices(options = {}) {
|
|
62
|
+
if (process.platform !== "linux") {
|
|
63
|
+
throw new Error("systemd install is only supported on Linux.");
|
|
64
|
+
}
|
|
65
|
+
if (typeof process.getuid === "function" && process.getuid() !== 0) {
|
|
66
|
+
throw new Error("systemd install requires root.");
|
|
67
|
+
}
|
|
68
|
+
const units = buildSystemdUnits(options);
|
|
69
|
+
await writeFile(units.enginePath, units.engineUnit, "utf8");
|
|
70
|
+
await writeFile(units.panelPath, units.panelUnit, "utf8");
|
|
71
|
+
await runCmd("systemctl", ["daemon-reload"], { stdio: "inherit" });
|
|
72
|
+
await runCmd("systemctl", ["enable", "--now", `${units.engineLabel}.service`], {
|
|
73
|
+
stdio: "inherit",
|
|
74
|
+
});
|
|
75
|
+
await runCmd("systemctl", ["enable", "--now", `${units.panelLabel}.service`], {
|
|
76
|
+
stdio: "inherit",
|
|
77
|
+
});
|
|
78
|
+
log(`Installed ${units.engineLabel}.service and ${units.panelLabel}.service`);
|
|
79
|
+
return units;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function operateSystemdServices(operation) {
|
|
83
|
+
const units = ["tandem-engine.service", "tandem-control-panel.service"];
|
|
84
|
+
if (operation === "logs") {
|
|
85
|
+
const args = units.flatMap((unit) => ["-u", unit]).concat(["-n", "120", "-f", "-o", "short-iso"]);
|
|
86
|
+
await runCmd("journalctl", args, { stdio: "inherit" });
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (operation === "status") {
|
|
90
|
+
await runCmd("systemctl", ["--no-pager", "--full", "status", ...units], { stdio: "inherit" });
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (operation === "uninstall") {
|
|
94
|
+
for (const unit of units) {
|
|
95
|
+
await runCmd("systemctl", ["disable", "--now", unit], { stdio: "inherit" }).catch(() => null);
|
|
96
|
+
}
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
for (const unit of units) {
|
|
100
|
+
await runCmd("systemctl", [operation, unit], { stdio: "inherit" });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export { buildSystemdUnits, installSystemdServices, operateSystemdServices };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frumu/tandem-panel",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.11",
|
|
4
4
|
"description": "Full web control center for Tandem Engine (chat, routines, swarm, memory, channels, and ops)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -15,12 +15,20 @@
|
|
|
15
15
|
"test:smoke": "node --test tests/smoke.test.mjs",
|
|
16
16
|
"init:env": "node bin/init-env.js",
|
|
17
17
|
"doctor": "node bin/cli.js doctor",
|
|
18
|
+
"docker:up": "docker compose up --build",
|
|
19
|
+
"docker:down": "docker compose down",
|
|
20
|
+
"docker:logs": "docker compose logs -f --tail=200",
|
|
21
|
+
"docker:ps": "docker compose ps",
|
|
22
|
+
"docker:config": "docker compose config",
|
|
23
|
+
"docker:token": "node bin/docker-token.js",
|
|
18
24
|
"prepublishOnly": "npm run build",
|
|
19
25
|
"start": "node bin/cli.js run",
|
|
20
26
|
"mcp:catalog:refresh": "node ../../scripts/generate-mcp-catalog.mjs"
|
|
21
27
|
},
|
|
22
28
|
"files": [
|
|
23
29
|
"bin",
|
|
30
|
+
"lib",
|
|
31
|
+
"server",
|
|
24
32
|
"dist",
|
|
25
33
|
".env.example",
|
|
26
34
|
"README.md"
|
|
@@ -32,8 +40,8 @@
|
|
|
32
40
|
"homepage": "https://tandem.frumu.ai",
|
|
33
41
|
"author": "Frumu Ltd",
|
|
34
42
|
"dependencies": {
|
|
35
|
-
"@frumu/tandem": "^0.4.
|
|
36
|
-
"@frumu/tandem-client": "^0.4.
|
|
43
|
+
"@frumu/tandem": "^0.4.11",
|
|
44
|
+
"@frumu/tandem-client": "^0.4.11",
|
|
37
45
|
"@tanstack/react-query": "^5.90.21",
|
|
38
46
|
"dompurify": "^3.3.1",
|
|
39
47
|
"lucide": "^0.575.0",
|