comisai 1.0.5 → 1.0.7
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/node_modules/@comis/agent/package.json +1 -1
- package/node_modules/@comis/channels/package.json +1 -1
- package/node_modules/@comis/cli/dist/client/rpc-client.js +48 -0
- package/node_modules/@comis/cli/dist/commands/config.js +37 -2
- package/node_modules/@comis/cli/dist/commands/daemon.js +62 -11
- package/node_modules/@comis/cli/dist/commands/doctor.js +19 -2
- package/node_modules/@comis/cli/dist/commands/health.js +19 -2
- package/node_modules/@comis/cli/dist/commands/models.js +7 -5
- package/node_modules/@comis/cli/dist/doctor/checks/config-health.js +24 -1
- package/node_modules/@comis/cli/dist/doctor/repairs/repair-config.js +5 -0
- package/node_modules/@comis/cli/dist/wizard/steps/11-daemon-start.js +92 -14
- package/node_modules/@comis/cli/package.json +1 -1
- package/node_modules/@comis/core/package.json +1 -1
- package/node_modules/@comis/daemon/dist/wiring/setup-gateway-rpc.js +1 -0
- package/node_modules/@comis/daemon/package.json +1 -1
- package/node_modules/@comis/gateway/package.json +1 -1
- package/node_modules/@comis/infra/package.json +1 -1
- package/node_modules/@comis/memory/package.json +1 -1
- package/node_modules/@comis/scheduler/package.json +1 -1
- package/node_modules/@comis/shared/package.json +1 -1
- package/node_modules/@comis/skills/package.json +1 -1
- package/package.json +12 -12
|
@@ -11,16 +11,48 @@
|
|
|
11
11
|
import { existsSync, readFileSync } from "node:fs";
|
|
12
12
|
import os from "node:os";
|
|
13
13
|
import WebSocket from "ws";
|
|
14
|
+
import { loadEnvFile } from "@comis/core";
|
|
14
15
|
/** Default connection timeout in milliseconds. */
|
|
15
16
|
const CONNECTION_TIMEOUT_MS = 2000;
|
|
16
17
|
/** Fallback gateway WebSocket URL when no config is found. */
|
|
17
18
|
const FALLBACK_GATEWAY_URL = "ws://localhost:4766/ws";
|
|
19
|
+
/** Whether we have already loaded ~/.comis/.env into process.env. */
|
|
20
|
+
let envFileLoaded = false;
|
|
21
|
+
/**
|
|
22
|
+
* Ensure ~/.comis/.env is loaded into process.env (once).
|
|
23
|
+
*
|
|
24
|
+
* The daemon calls loadEnvFile() at startup, but the CLI does not.
|
|
25
|
+
* Config values like `${COMIS_GATEWAY_TOKEN}` reference env vars that
|
|
26
|
+
* live in the .env file, so the CLI must load it too before resolving.
|
|
27
|
+
*/
|
|
28
|
+
function ensureEnvFileLoaded() {
|
|
29
|
+
if (envFileLoaded)
|
|
30
|
+
return;
|
|
31
|
+
envFileLoaded = true;
|
|
32
|
+
const envPath = os.homedir() + "/.comis/.env";
|
|
33
|
+
loadEnvFile(envPath);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Resolve `${VAR}` references in a string using process.env.
|
|
37
|
+
*
|
|
38
|
+
* Returns the original string if it contains no references or if the
|
|
39
|
+
* referenced variable is not set.
|
|
40
|
+
*/
|
|
41
|
+
function resolveEnvRef(value) {
|
|
42
|
+
const match = value.match(/^\$\{([A-Z_][A-Z0-9_]*)\}$/);
|
|
43
|
+
if (!match)
|
|
44
|
+
return value;
|
|
45
|
+
// eslint-disable-next-line no-restricted-syntax -- CLI bootstrap before SecretManager
|
|
46
|
+
const resolved = process.env[match[1]];
|
|
47
|
+
return resolved ?? value;
|
|
48
|
+
}
|
|
18
49
|
/**
|
|
19
50
|
* Resolve gateway URL, token, and TLS status from config file on disk.
|
|
20
51
|
*
|
|
21
52
|
* Reads ~/.comis/config.yaml (matching daemon defaults) to extract
|
|
22
53
|
* gateway.host, gateway.port, TLS configuration, and the first token secret.
|
|
23
54
|
* Uses a minimal line-based parser to avoid importing the full YAML library.
|
|
55
|
+
* Resolves `${VAR}` references in token values via ~/.comis/.env.
|
|
24
56
|
*/
|
|
25
57
|
function resolveFromConfig() {
|
|
26
58
|
const configPath = os.homedir() + "/.comis/config.yaml";
|
|
@@ -86,6 +118,15 @@ function resolveFromConfig() {
|
|
|
86
118
|
}
|
|
87
119
|
}
|
|
88
120
|
}
|
|
121
|
+
// Resolve ${VAR} references in the token (e.g. ${COMIS_GATEWAY_TOKEN})
|
|
122
|
+
if (token && token.startsWith("${")) {
|
|
123
|
+
ensureEnvFileLoaded();
|
|
124
|
+
token = resolveEnvRef(token);
|
|
125
|
+
// If still unresolved, treat as no token
|
|
126
|
+
if (token.startsWith("${")) {
|
|
127
|
+
token = undefined;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
89
130
|
const protocol = tls ? "wss" : "ws";
|
|
90
131
|
return { url: `${protocol}://${host}:${port}/ws`, token, tls };
|
|
91
132
|
}
|
|
@@ -161,6 +202,7 @@ export async function createRpcClient(url, token) {
|
|
|
161
202
|
}
|
|
162
203
|
const ws = new WebSocket(url, { headers });
|
|
163
204
|
let nextId = 1;
|
|
205
|
+
let closed = false;
|
|
164
206
|
const pending = new Map();
|
|
165
207
|
const notificationHandlers = [];
|
|
166
208
|
// Connection timeout
|
|
@@ -172,6 +214,9 @@ export async function createRpcClient(url, token) {
|
|
|
172
214
|
clearTimeout(timeout);
|
|
173
215
|
resolve({
|
|
174
216
|
call(method, params) {
|
|
217
|
+
if (closed) {
|
|
218
|
+
return Promise.reject(new Error("Connection closed unexpectedly"));
|
|
219
|
+
}
|
|
175
220
|
const id = nextId++;
|
|
176
221
|
return new Promise((res, rej) => {
|
|
177
222
|
pending.set(id, { resolve: res, reject: rej });
|
|
@@ -179,6 +224,7 @@ export async function createRpcClient(url, token) {
|
|
|
179
224
|
});
|
|
180
225
|
},
|
|
181
226
|
close() {
|
|
227
|
+
closed = true;
|
|
182
228
|
// Reject all pending requests
|
|
183
229
|
for (const [, p] of pending) {
|
|
184
230
|
p.reject(new Error("Client closed"));
|
|
@@ -237,6 +283,7 @@ export async function createRpcClient(url, token) {
|
|
|
237
283
|
});
|
|
238
284
|
ws.on("close", () => {
|
|
239
285
|
clearTimeout(timeout);
|
|
286
|
+
closed = true;
|
|
240
287
|
// Reject all pending requests on unexpected close
|
|
241
288
|
for (const [, p] of pending) {
|
|
242
289
|
p.reject(new Error("Connection closed unexpectedly"));
|
|
@@ -255,6 +302,7 @@ export async function createRpcClient(url, token) {
|
|
|
255
302
|
* @returns The result of fn
|
|
256
303
|
*/
|
|
257
304
|
export async function withClient(fn) {
|
|
305
|
+
ensureEnvFileLoaded();
|
|
258
306
|
const configDefaults = resolveFromConfig();
|
|
259
307
|
// eslint-disable-next-line no-restricted-syntax -- CLI bootstrap before SecretManager
|
|
260
308
|
const url = process.env["COMIS_GATEWAY_URL"] ?? configDefaults.url;
|
|
@@ -8,14 +8,46 @@
|
|
|
8
8
|
*
|
|
9
9
|
* @module
|
|
10
10
|
*/
|
|
11
|
+
import * as os from "node:os";
|
|
11
12
|
import chalk from "chalk";
|
|
12
|
-
import { loadConfigFile, validateConfig, deepMerge } from "@comis/core";
|
|
13
|
+
import { loadConfigFile, validateConfig, deepMerge, loadEnvFile } from "@comis/core";
|
|
13
14
|
import { withClient } from "../client/rpc-client.js";
|
|
14
15
|
import { success, error, info, warn, json } from "../output/format.js";
|
|
15
16
|
import { withSpinner } from "../output/spinner.js";
|
|
16
17
|
import { renderTable, renderKeyValue } from "../output/table.js";
|
|
17
18
|
/** Default config paths to check (matching daemon defaults). */
|
|
18
|
-
const DEFAULT_CONFIG_PATHS = [
|
|
19
|
+
const DEFAULT_CONFIG_PATHS = [
|
|
20
|
+
os.homedir() + "/.comis/config.yaml",
|
|
21
|
+
os.homedir() + "/.comis/config.local.yaml",
|
|
22
|
+
"/etc/comis/config.yaml",
|
|
23
|
+
"/etc/comis/config.local.yaml",
|
|
24
|
+
];
|
|
25
|
+
/** Pattern matching `${VAR_NAME}` env var references. */
|
|
26
|
+
const ENV_REF_RE = /\$\{([A-Z_][A-Z0-9_]*)\}/g;
|
|
27
|
+
/**
|
|
28
|
+
* Deep-walk an object and resolve `${VAR}` references using process.env.
|
|
29
|
+
* Mutates in place for efficiency since the input is a transient merge result.
|
|
30
|
+
*/
|
|
31
|
+
function resolveEnvRefs(obj) {
|
|
32
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
33
|
+
if (typeof value === "string" && value.includes("${")) {
|
|
34
|
+
obj[key] = value.replace(ENV_REF_RE, (match, varName) => {
|
|
35
|
+
// eslint-disable-next-line no-restricted-syntax -- CLI bootstrap before SecretManager
|
|
36
|
+
return process.env[varName] ?? match;
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
else if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
40
|
+
resolveEnvRefs(value);
|
|
41
|
+
}
|
|
42
|
+
else if (Array.isArray(value)) {
|
|
43
|
+
for (const item of value) {
|
|
44
|
+
if (item && typeof item === "object") {
|
|
45
|
+
resolveEnvRefs(item);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
19
51
|
/**
|
|
20
52
|
* Register the `config` subcommand group on the program.
|
|
21
53
|
*
|
|
@@ -56,6 +88,9 @@ export function registerConfigCommand(program) {
|
|
|
56
88
|
if (!anyFound) {
|
|
57
89
|
info("No config files found. Validating with defaults only.");
|
|
58
90
|
}
|
|
91
|
+
// Load .env so ${VAR} references resolve before validation
|
|
92
|
+
loadEnvFile(os.homedir() + "/.comis/.env");
|
|
93
|
+
resolveEnvRefs(merged);
|
|
59
94
|
// Validate merged config
|
|
60
95
|
const validation = validateConfig(merged);
|
|
61
96
|
if (validation.ok) {
|
|
@@ -181,6 +181,19 @@ async function detectServiceManager() {
|
|
|
181
181
|
function systemctlArgs(manager, ...rest) {
|
|
182
182
|
return manager === "systemd-user" ? ["--user", ...rest] : rest;
|
|
183
183
|
}
|
|
184
|
+
/**
|
|
185
|
+
* Execute a systemctl command, auto-escalating to sudo for system-scope
|
|
186
|
+
* services when running as a non-root user.
|
|
187
|
+
*/
|
|
188
|
+
async function execSystemctl(manager, ...args) {
|
|
189
|
+
const fullArgs = systemctlArgs(manager, ...args);
|
|
190
|
+
// System-scope systemd requires root; escalate via sudo if we're not root
|
|
191
|
+
// eslint-disable-next-line no-restricted-syntax -- CLI bootstrap before SecretManager
|
|
192
|
+
if (manager === "systemd" && process.getuid?.() !== 0) {
|
|
193
|
+
return exec("sudo", ["systemctl", ...fullArgs], { timeout: 15_000 });
|
|
194
|
+
}
|
|
195
|
+
return exec("systemctl", fullArgs, { timeout: 15_000 });
|
|
196
|
+
}
|
|
184
197
|
// ---------- Subcommand handlers ----------
|
|
185
198
|
/**
|
|
186
199
|
* Start the daemon in direct mode (no systemd).
|
|
@@ -233,6 +246,16 @@ async function startDirectMode() {
|
|
|
233
246
|
process.exit(1);
|
|
234
247
|
}
|
|
235
248
|
}
|
|
249
|
+
/** Check if a systemd unit is already active (does not require sudo). */
|
|
250
|
+
async function isSystemdActive(manager) {
|
|
251
|
+
try {
|
|
252
|
+
const { stdout } = await exec("systemctl", systemctlArgs(manager, "is-active", "comis"), { timeout: 5_000 });
|
|
253
|
+
return stdout.trim() === "active";
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
236
259
|
/** Handle the `daemon start` subcommand. */
|
|
237
260
|
async function handleDaemonStart() {
|
|
238
261
|
try {
|
|
@@ -240,9 +263,13 @@ async function handleDaemonStart() {
|
|
|
240
263
|
switch (manager) {
|
|
241
264
|
case "systemd":
|
|
242
265
|
case "systemd-user": {
|
|
266
|
+
if (await isSystemdActive(manager)) {
|
|
267
|
+
success("Daemon is already running (systemd)");
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
243
270
|
const scope = manager === "systemd-user" ? "systemd (user scope)" : "systemd";
|
|
244
271
|
info(`Starting daemon via ${scope}...`);
|
|
245
|
-
await
|
|
272
|
+
await execSystemctl(manager, "start", "comis");
|
|
246
273
|
success("Daemon started");
|
|
247
274
|
return;
|
|
248
275
|
}
|
|
@@ -271,9 +298,13 @@ async function handleDaemonStop() {
|
|
|
271
298
|
switch (manager) {
|
|
272
299
|
case "systemd":
|
|
273
300
|
case "systemd-user": {
|
|
301
|
+
if (!(await isSystemdActive(manager))) {
|
|
302
|
+
warn("Daemon is not running");
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
274
305
|
const scope = manager === "systemd-user" ? "systemd (user scope)" : "systemd";
|
|
275
306
|
info(`Stopping daemon via ${scope}...`);
|
|
276
|
-
await
|
|
307
|
+
await execSystemctl(manager, "stop", "comis");
|
|
277
308
|
success("Daemon stopped");
|
|
278
309
|
return;
|
|
279
310
|
}
|
|
@@ -477,24 +508,44 @@ async function handleDaemonLogs(options) {
|
|
|
477
508
|
}
|
|
478
509
|
}
|
|
479
510
|
async function streamSystemdLogs(scope, options) {
|
|
480
|
-
const
|
|
511
|
+
const jArgs = ["--unit=comis", "--no-pager", `-n${options.lines}`];
|
|
481
512
|
if (scope === "systemd-user")
|
|
482
|
-
|
|
513
|
+
jArgs.push("--user");
|
|
514
|
+
if (options.follow)
|
|
515
|
+
jArgs.push("--follow");
|
|
483
516
|
if (options.follow) {
|
|
484
|
-
|
|
485
|
-
const child = spawn("journalctl", args, { stdio: "inherit" });
|
|
517
|
+
const child = spawn("journalctl", jArgs, { stdio: "inherit" });
|
|
486
518
|
child.on("error", (err) => {
|
|
487
519
|
error(`Failed to read logs: ${err.message}`);
|
|
488
520
|
process.exit(1);
|
|
489
521
|
});
|
|
490
522
|
return;
|
|
491
523
|
}
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
524
|
+
// Try journalctl directly first (works when user is in systemd-journal group),
|
|
525
|
+
// then fall back to sudo for system-scope journals.
|
|
526
|
+
try {
|
|
527
|
+
const { stdout } = await exec("journalctl", jArgs, { timeout: 10_000 });
|
|
528
|
+
if (stdout.trim()) {
|
|
529
|
+
console.log(stdout);
|
|
530
|
+
}
|
|
531
|
+
else {
|
|
532
|
+
info("No logs found");
|
|
533
|
+
}
|
|
495
534
|
}
|
|
496
|
-
|
|
497
|
-
|
|
535
|
+
catch {
|
|
536
|
+
// eslint-disable-next-line no-restricted-syntax -- CLI bootstrap before SecretManager
|
|
537
|
+
if (scope === "systemd" && process.getuid?.() !== 0) {
|
|
538
|
+
const { stdout } = await exec("sudo", ["journalctl", ...jArgs], { timeout: 10_000 });
|
|
539
|
+
if (stdout.trim()) {
|
|
540
|
+
console.log(stdout);
|
|
541
|
+
}
|
|
542
|
+
else {
|
|
543
|
+
info("No logs found");
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
else {
|
|
547
|
+
throw new Error("Failed to read journal logs");
|
|
548
|
+
}
|
|
498
549
|
}
|
|
499
550
|
}
|
|
500
551
|
function streamPm2Logs(options) {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* @module
|
|
10
10
|
*/
|
|
11
11
|
import * as os from "node:os";
|
|
12
|
-
import { readFileSync } from "node:fs";
|
|
12
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
13
13
|
import { loadConfigFile, validateConfig } from "@comis/core";
|
|
14
14
|
import { success, error, info } from "../output/format.js";
|
|
15
15
|
import { withSpinner } from "../output/spinner.js";
|
|
@@ -31,6 +31,23 @@ const ALL_CHECKS = [
|
|
|
31
31
|
channelHealthCheck,
|
|
32
32
|
workspaceHealthCheck,
|
|
33
33
|
];
|
|
34
|
+
/**
|
|
35
|
+
* Resolve default config paths from COMIS_CONFIG_PATHS env var or standard locations.
|
|
36
|
+
*/
|
|
37
|
+
function resolveDefaultConfigPaths() {
|
|
38
|
+
// eslint-disable-next-line no-restricted-syntax -- CLI bootstrap before SecretManager
|
|
39
|
+
const envPaths = process.env["COMIS_CONFIG_PATHS"];
|
|
40
|
+
if (envPaths) {
|
|
41
|
+
return envPaths.split(":").filter((p) => p.length > 0);
|
|
42
|
+
}
|
|
43
|
+
const candidates = [
|
|
44
|
+
os.homedir() + "/.comis/config.yaml",
|
|
45
|
+
os.homedir() + "/.comis/config.local.yaml",
|
|
46
|
+
"/etc/comis/config.yaml",
|
|
47
|
+
"/etc/comis/config.local.yaml",
|
|
48
|
+
];
|
|
49
|
+
return candidates.filter((p) => existsSync(p));
|
|
50
|
+
}
|
|
34
51
|
/**
|
|
35
52
|
* Build a DoctorContext from CLI options.
|
|
36
53
|
*
|
|
@@ -93,7 +110,7 @@ export function registerDoctorCommand(program) {
|
|
|
93
110
|
.option("-c, --config <paths...>", "Config file paths to check")
|
|
94
111
|
.option("--format <format>", 'Output format: "table" or "json"', "table")
|
|
95
112
|
.action(async (options) => {
|
|
96
|
-
const configPaths = options.config ??
|
|
113
|
+
const configPaths = options.config ?? resolveDefaultConfigPaths();
|
|
97
114
|
const context = buildDoctorContext(configPaths);
|
|
98
115
|
const result = await withSpinner("Running diagnostics...", () => runDoctorChecks(ALL_CHECKS, context));
|
|
99
116
|
// Render results
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* @module
|
|
11
11
|
*/
|
|
12
12
|
import * as os from "node:os";
|
|
13
|
-
import { readFileSync } from "node:fs";
|
|
13
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
14
14
|
import { loadConfigFile, validateConfig } from "@comis/core";
|
|
15
15
|
import chalk from "chalk";
|
|
16
16
|
import { json } from "../output/format.js";
|
|
@@ -29,6 +29,23 @@ const ALL_CHECKS = [
|
|
|
29
29
|
channelHealthCheck,
|
|
30
30
|
workspaceHealthCheck,
|
|
31
31
|
];
|
|
32
|
+
/**
|
|
33
|
+
* Resolve default config paths from COMIS_CONFIG_PATHS env var or standard locations.
|
|
34
|
+
*/
|
|
35
|
+
function resolveDefaultConfigPaths() {
|
|
36
|
+
// eslint-disable-next-line no-restricted-syntax -- CLI bootstrap before SecretManager
|
|
37
|
+
const envPaths = process.env["COMIS_CONFIG_PATHS"];
|
|
38
|
+
if (envPaths) {
|
|
39
|
+
return envPaths.split(":").filter((p) => p.length > 0);
|
|
40
|
+
}
|
|
41
|
+
const candidates = [
|
|
42
|
+
os.homedir() + "/.comis/config.yaml",
|
|
43
|
+
os.homedir() + "/.comis/config.local.yaml",
|
|
44
|
+
"/etc/comis/config.yaml",
|
|
45
|
+
"/etc/comis/config.local.yaml",
|
|
46
|
+
];
|
|
47
|
+
return candidates.filter((p) => existsSync(p));
|
|
48
|
+
}
|
|
32
49
|
/**
|
|
33
50
|
* Build a DoctorContext from CLI config paths.
|
|
34
51
|
*
|
|
@@ -136,7 +153,7 @@ export function registerHealthCommand(program) {
|
|
|
136
153
|
.option("--format <format>", 'Output format: "table" or "json"', "table")
|
|
137
154
|
.option("--all", "Show all findings including passing checks", false)
|
|
138
155
|
.action(async (options) => {
|
|
139
|
-
const configPaths = options.config ??
|
|
156
|
+
const configPaths = options.config ?? resolveDefaultConfigPaths();
|
|
140
157
|
const context = buildHealthContext(configPaths);
|
|
141
158
|
const result = await withSpinner("Checking health...", () => runDoctorChecks(ALL_CHECKS, context));
|
|
142
159
|
// Filter findings: by default only fail/warn
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* @module
|
|
10
10
|
*/
|
|
11
11
|
import * as fs from "node:fs";
|
|
12
|
+
import * as os from "node:os";
|
|
12
13
|
import chalk from "chalk";
|
|
13
14
|
import { parseDocument } from "yaml";
|
|
14
15
|
import { createModelCatalog } from "@comis/agent";
|
|
@@ -16,8 +17,10 @@ import { withClient } from "../client/rpc-client.js";
|
|
|
16
17
|
import { success, error, info, json } from "../output/format.js";
|
|
17
18
|
import { withSpinner } from "../output/spinner.js";
|
|
18
19
|
import { renderTable } from "../output/table.js";
|
|
19
|
-
/** Default config paths to check (matching daemon defaults). */
|
|
20
|
+
/** Default config paths to check (matching daemon and CLI defaults). */
|
|
20
21
|
const DEFAULT_CONFIG_PATHS = [
|
|
22
|
+
os.homedir() + "/.comis/config.yaml",
|
|
23
|
+
os.homedir() + "/.comis/config.local.yaml",
|
|
21
24
|
"/etc/comis/config.yaml",
|
|
22
25
|
"/etc/comis/config.local.yaml",
|
|
23
26
|
];
|
|
@@ -201,11 +204,10 @@ export function registerModelsCommand(program) {
|
|
|
201
204
|
process.exit(1);
|
|
202
205
|
}
|
|
203
206
|
// Get old model value for display
|
|
204
|
-
const oldModel = doc.getIn([...agentsPath, agent, "
|
|
207
|
+
const oldModel = doc.getIn([...agentsPath, agent, "model"]);
|
|
205
208
|
const oldModelStr = typeof oldModel === "string" ? oldModel : "(not set)";
|
|
206
209
|
// Update the model field
|
|
207
|
-
|
|
208
|
-
doc.setIn([...agentsPath, agent, "defaultModel"], fullModelId);
|
|
210
|
+
doc.setIn([...agentsPath, agent, "model"], entry.modelId);
|
|
209
211
|
// Write config back preserving format
|
|
210
212
|
const dir = configPath.substring(0, configPath.lastIndexOf("/"));
|
|
211
213
|
if (dir) {
|
|
@@ -213,7 +215,7 @@ export function registerModelsCommand(program) {
|
|
|
213
215
|
}
|
|
214
216
|
fs.writeFileSync(configPath, doc.toString(), { mode: 0o600 });
|
|
215
217
|
success(`Model updated for agent "${agent}"`);
|
|
216
|
-
console.log(` ${chalk.gray(oldModelStr)} ${chalk.white("->")} ${chalk.cyan(
|
|
218
|
+
console.log(` ${chalk.gray(oldModelStr)} ${chalk.white("->")} ${chalk.cyan(entry.modelId)}`);
|
|
217
219
|
info("Daemon will restart to apply the change");
|
|
218
220
|
}
|
|
219
221
|
catch (err) {
|
|
@@ -9,8 +9,28 @@
|
|
|
9
9
|
* @module
|
|
10
10
|
*/
|
|
11
11
|
import { readFileSync } from "node:fs";
|
|
12
|
+
import os from "node:os";
|
|
12
13
|
import { parse as parseYaml } from "yaml";
|
|
13
|
-
import { AppConfigSchema } from "@comis/core";
|
|
14
|
+
import { AppConfigSchema, loadEnvFile } from "@comis/core";
|
|
15
|
+
const ENV_REF_RE = /\$\{([A-Z_][A-Z0-9_]*)\}/g;
|
|
16
|
+
/** Deep-walk an object and resolve `${VAR}` references using process.env. */
|
|
17
|
+
function resolveEnvRefs(obj) {
|
|
18
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
19
|
+
if (typeof value === "string" && value.includes("${")) {
|
|
20
|
+
// eslint-disable-next-line no-restricted-syntax -- CLI bootstrap before SecretManager
|
|
21
|
+
obj[key] = value.replace(ENV_REF_RE, (match, varName) => process.env[varName] ?? match);
|
|
22
|
+
}
|
|
23
|
+
else if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
24
|
+
resolveEnvRefs(value);
|
|
25
|
+
}
|
|
26
|
+
else if (Array.isArray(value)) {
|
|
27
|
+
for (const item of value) {
|
|
28
|
+
if (item && typeof item === "object")
|
|
29
|
+
resolveEnvRefs(item);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
14
34
|
const CATEGORY = "config";
|
|
15
35
|
/**
|
|
16
36
|
* Doctor check: config file health.
|
|
@@ -89,6 +109,9 @@ export const configHealthCheck = {
|
|
|
89
109
|
});
|
|
90
110
|
return findings;
|
|
91
111
|
}
|
|
112
|
+
// Resolve ${VAR} references before validation (mirrors daemon startup)
|
|
113
|
+
loadEnvFile(os.homedir() + "/.comis/.env");
|
|
114
|
+
resolveEnvRefs(parsed);
|
|
92
115
|
// Validate against schema
|
|
93
116
|
const result = AppConfigSchema.safeParse(parsed);
|
|
94
117
|
if (!result.success) {
|
|
@@ -71,6 +71,11 @@ export async function repairConfig(findings, configPaths) {
|
|
|
71
71
|
}
|
|
72
72
|
else if (finding.message.includes("not found") || finding.message.includes("No config")) {
|
|
73
73
|
// Config missing -- create directory and write defaults
|
|
74
|
+
// Guard: never overwrite an existing config file
|
|
75
|
+
if (existsSync(targetPath)) {
|
|
76
|
+
actions.push(`Config already exists at ${targetPath} (skipped overwrite). Use -c ${targetPath} to check it.`);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
74
79
|
const lastSlash = targetPath.lastIndexOf("/");
|
|
75
80
|
if (lastSlash > 0) {
|
|
76
81
|
const dir = targetPath.slice(0, lastSlash);
|
|
@@ -13,10 +13,12 @@
|
|
|
13
13
|
*
|
|
14
14
|
* @module
|
|
15
15
|
*/
|
|
16
|
-
import { spawn } from "node:child_process";
|
|
16
|
+
import { execFile, spawn } from "node:child_process";
|
|
17
17
|
import { existsSync, mkdirSync, writeFileSync, openSync, closeSync, accessSync, constants as fsConstants, } from "node:fs";
|
|
18
18
|
import * as os from "node:os";
|
|
19
|
+
import { promisify } from "node:util";
|
|
19
20
|
import { safePath } from "@comis/core";
|
|
21
|
+
const exec = promisify(execFile);
|
|
20
22
|
import { updateState, sectionSeparator, success as themeSuccess, error as themeError, } from "../index.js";
|
|
21
23
|
// ---------- Constants ----------
|
|
22
24
|
/** Max time to wait for daemon gateway to become ready (ms). */
|
|
@@ -194,6 +196,58 @@ async function runHealthCheck(state, prompter, gatewayHost, gatewayPort) {
|
|
|
194
196
|
}
|
|
195
197
|
}
|
|
196
198
|
}
|
|
199
|
+
async function detectServiceManager() {
|
|
200
|
+
if (!existsSync("/run/systemd/system"))
|
|
201
|
+
return "direct";
|
|
202
|
+
try {
|
|
203
|
+
const { stdout } = await exec("systemctl", ["list-unit-files", "comis.service", "--no-pager", "--no-legend"], { timeout: 5_000 });
|
|
204
|
+
if (stdout.includes("comis.service"))
|
|
205
|
+
return "systemd";
|
|
206
|
+
}
|
|
207
|
+
catch { /* not available */ }
|
|
208
|
+
try {
|
|
209
|
+
const { stdout } = await exec("systemctl", ["--user", "list-unit-files", "comis.service", "--no-pager", "--no-legend"], { timeout: 5_000 });
|
|
210
|
+
if (stdout.includes("comis.service"))
|
|
211
|
+
return "systemd-user";
|
|
212
|
+
}
|
|
213
|
+
catch { /* not available */ }
|
|
214
|
+
return "direct";
|
|
215
|
+
}
|
|
216
|
+
async function restartViaSystemd(manager) {
|
|
217
|
+
try {
|
|
218
|
+
const args = manager === "systemd-user"
|
|
219
|
+
? ["--user", "restart", "comis"]
|
|
220
|
+
: ["restart", "comis"];
|
|
221
|
+
// System-scope requires root; try sudo for non-root users
|
|
222
|
+
if (manager === "systemd" && process.getuid?.() !== 0) {
|
|
223
|
+
await exec("sudo", ["systemctl", ...args], { timeout: 15_000 });
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
await exec("systemctl", args, { timeout: 15_000 });
|
|
227
|
+
}
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
async function startViaSystemd(manager) {
|
|
235
|
+
try {
|
|
236
|
+
const args = manager === "systemd-user"
|
|
237
|
+
? ["--user", "start", "comis"]
|
|
238
|
+
: ["start", "comis"];
|
|
239
|
+
if (manager === "systemd" && process.getuid?.() !== 0) {
|
|
240
|
+
await exec("sudo", ["systemctl", ...args], { timeout: 15_000 });
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
await exec("systemctl", args, { timeout: 15_000 });
|
|
244
|
+
}
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
197
251
|
// ---------- Step Implementation ----------
|
|
198
252
|
export const daemonStartStep = {
|
|
199
253
|
id: "daemon-start",
|
|
@@ -216,6 +270,7 @@ export const daemonStartStep = {
|
|
|
216
270
|
catch {
|
|
217
271
|
// Not running
|
|
218
272
|
}
|
|
273
|
+
const serviceManager = await detectServiceManager();
|
|
219
274
|
// 1. Offer auto-start (or restart if already running)
|
|
220
275
|
let choice;
|
|
221
276
|
if (daemonRunning) {
|
|
@@ -239,10 +294,44 @@ export const daemonStartStep = {
|
|
|
239
294
|
}
|
|
240
295
|
// 2. If user declines, show manual command and move on
|
|
241
296
|
if (choice === "no") {
|
|
242
|
-
|
|
297
|
+
if (daemonRunning && serviceManager !== "direct") {
|
|
298
|
+
prompter.log.info("Restart to apply changes: sudo systemctl restart comis");
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
prompter.log.info("Start later with: comis daemon start");
|
|
302
|
+
}
|
|
303
|
+
return updateState(state, {});
|
|
304
|
+
}
|
|
305
|
+
// 3. Use systemd when it owns the daemon
|
|
306
|
+
if (serviceManager !== "direct") {
|
|
307
|
+
const spinner = prompter.spinner();
|
|
308
|
+
const action = choice === "restart" ? "Restarting" : "Starting";
|
|
309
|
+
spinner.start(`${action} daemon via systemd...`);
|
|
310
|
+
const ok = choice === "restart"
|
|
311
|
+
? await restartViaSystemd(serviceManager)
|
|
312
|
+
: await startViaSystemd(serviceManager);
|
|
313
|
+
if (!ok) {
|
|
314
|
+
spinner.stop(`Could not ${choice === "restart" ? "restart" : "start"} via systemd`);
|
|
315
|
+
if (process.getuid?.() !== 0) {
|
|
316
|
+
prompter.log.warn("Run as root: sudo systemctl restart comis");
|
|
317
|
+
}
|
|
318
|
+
return updateState(state, {});
|
|
319
|
+
}
|
|
320
|
+
const ready = await waitForReady(host, port);
|
|
321
|
+
if (ready) {
|
|
322
|
+
spinner.stop(`Daemon ${choice === "restart" ? "restarted" : "started"} and ready`);
|
|
323
|
+
}
|
|
324
|
+
else {
|
|
325
|
+
spinner.stop(`Daemon ${choice === "restart" ? "restarted" : "started"} but gateway not yet responding`);
|
|
326
|
+
prompter.log.warn("Check logs: comis daemon logs");
|
|
327
|
+
}
|
|
328
|
+
if (!state.skipHealth) {
|
|
329
|
+
await runHealthCheck(state, prompter, host, port);
|
|
330
|
+
}
|
|
243
331
|
return updateState(state, {});
|
|
244
332
|
}
|
|
245
|
-
//
|
|
333
|
+
// 4. Direct-spawn fallback (no systemd)
|
|
334
|
+
// Stop existing daemon before restart
|
|
246
335
|
if (choice === "restart") {
|
|
247
336
|
const stopSpinner = prompter.spinner();
|
|
248
337
|
stopSpinner.start("Stopping daemon...");
|
|
@@ -256,7 +345,6 @@ export const daemonStartStep = {
|
|
|
256
345
|
process.kill(pid, "SIGTERM");
|
|
257
346
|
}
|
|
258
347
|
catch { /* already stopped */ }
|
|
259
|
-
// Brief wait for graceful shutdown
|
|
260
348
|
await new Promise((r) => setTimeout(r, 1500));
|
|
261
349
|
}
|
|
262
350
|
}
|
|
@@ -266,24 +354,19 @@ export const daemonStartStep = {
|
|
|
266
354
|
stopSpinner.stop("Could not stop daemon (may already be stopped)");
|
|
267
355
|
}
|
|
268
356
|
}
|
|
269
|
-
// 3. Spawn daemon
|
|
270
357
|
const spinner = prompter.spinner();
|
|
271
358
|
spinner.start("Starting daemon...");
|
|
272
359
|
try {
|
|
273
|
-
// Resolve daemon binary path (relative to this file's location in dist/)
|
|
274
360
|
const daemonPath = new URL("../../../../daemon/dist/daemon.js", import.meta.url).pathname;
|
|
275
361
|
if (!existsSync(daemonPath)) {
|
|
276
362
|
spinner.stop("Daemon binary not found");
|
|
277
363
|
prompter.log.warn("Run 'pnpm build' first, then 'comis daemon start'");
|
|
278
364
|
return updateState(state, {});
|
|
279
365
|
}
|
|
280
|
-
// Determine paths using safePath (matching daemon.ts pattern)
|
|
281
366
|
const comisDir = safePath(os.homedir(), ".comis");
|
|
282
367
|
const pidFile = safePath(comisDir, "daemon.pid");
|
|
283
368
|
const logFile = safePath(comisDir, "daemon.log");
|
|
284
|
-
// Ensure directory exists with restricted permissions
|
|
285
369
|
mkdirSync(comisDir, { recursive: true, mode: 0o700 });
|
|
286
|
-
// Open log file for daemon stdout/stderr
|
|
287
370
|
const logFd = openSync(logFile, "a", 0o600);
|
|
288
371
|
let childPid;
|
|
289
372
|
try {
|
|
@@ -295,17 +378,14 @@ export const daemonStartStep = {
|
|
|
295
378
|
childPid = child.pid ?? undefined;
|
|
296
379
|
}
|
|
297
380
|
finally {
|
|
298
|
-
// Close the file descriptor in the parent process after spawn
|
|
299
381
|
closeSync(logFd);
|
|
300
382
|
}
|
|
301
383
|
if (!childPid) {
|
|
302
384
|
spinner.stop("Failed to start daemon: no PID returned");
|
|
303
385
|
return updateState(state, {});
|
|
304
386
|
}
|
|
305
|
-
// Write PID file
|
|
306
387
|
writeFileSync(pidFile, String(childPid));
|
|
307
388
|
spinner.update(`Daemon started (PID ${childPid})`);
|
|
308
|
-
// 4. Wait for readiness
|
|
309
389
|
const ready = await waitForReady(host, port);
|
|
310
390
|
if (ready) {
|
|
311
391
|
spinner.stop(`Daemon started and ready (PID ${childPid})`);
|
|
@@ -314,7 +394,6 @@ export const daemonStartStep = {
|
|
|
314
394
|
spinner.stop("Daemon started but gateway not yet responding");
|
|
315
395
|
prompter.log.warn("Check logs: comis daemon logs");
|
|
316
396
|
}
|
|
317
|
-
// 5. Run health check (skip if --skip-health was passed)
|
|
318
397
|
if (!state.skipHealth) {
|
|
319
398
|
await runHealthCheck(state, prompter, host, port);
|
|
320
399
|
}
|
|
@@ -324,7 +403,6 @@ export const daemonStartStep = {
|
|
|
324
403
|
spinner.stop(`Failed to start daemon: ${msg}`);
|
|
325
404
|
prompter.log.warn("You can start the daemon later with: comis daemon start");
|
|
326
405
|
}
|
|
327
|
-
// 7. Return state
|
|
328
406
|
return updateState(state, {});
|
|
329
407
|
},
|
|
330
408
|
};
|
|
@@ -135,6 +135,7 @@ export function registerRpcMethods(deps) {
|
|
|
135
135
|
registerRpcPassthrough(dynamicRouter, rpcCall, [
|
|
136
136
|
"agents.list", "agents.create", "agents.get", "agents.update",
|
|
137
137
|
"agents.delete", "agents.suspend", "agents.resume",
|
|
138
|
+
"agent.getOperationModels",
|
|
138
139
|
], "admin");
|
|
139
140
|
// -------------------------------------------------------------------------
|
|
140
141
|
// Session management admin methods
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "comisai",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
4
4
|
"author": "Moshe Anconina",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"description": "Security-first AI agent platform — connects AI agents to Discord, Telegram, Slack, WhatsApp, and more",
|
|
@@ -115,17 +115,17 @@
|
|
|
115
115
|
"@comis/daemon"
|
|
116
116
|
],
|
|
117
117
|
"dependencies": {
|
|
118
|
-
"@comis/shared": "1.0.
|
|
119
|
-
"@comis/core": "1.0.
|
|
120
|
-
"@comis/infra": "1.0.
|
|
121
|
-
"@comis/memory": "1.0.
|
|
122
|
-
"@comis/gateway": "1.0.
|
|
123
|
-
"@comis/skills": "1.0.
|
|
124
|
-
"@comis/scheduler": "1.0.
|
|
125
|
-
"@comis/agent": "1.0.
|
|
126
|
-
"@comis/channels": "1.0.
|
|
127
|
-
"@comis/cli": "1.0.
|
|
128
|
-
"@comis/daemon": "1.0.
|
|
118
|
+
"@comis/shared": "1.0.7",
|
|
119
|
+
"@comis/core": "1.0.7",
|
|
120
|
+
"@comis/infra": "1.0.7",
|
|
121
|
+
"@comis/memory": "1.0.7",
|
|
122
|
+
"@comis/gateway": "1.0.7",
|
|
123
|
+
"@comis/skills": "1.0.7",
|
|
124
|
+
"@comis/scheduler": "1.0.7",
|
|
125
|
+
"@comis/agent": "1.0.7",
|
|
126
|
+
"@comis/channels": "1.0.7",
|
|
127
|
+
"@comis/cli": "1.0.7",
|
|
128
|
+
"@comis/daemon": "1.0.7",
|
|
129
129
|
"@agentclientprotocol/sdk": "^0.15.0",
|
|
130
130
|
"@clack/core": "^1.1.0",
|
|
131
131
|
"@clack/prompts": "^1.1.0",
|