@monster-orange/den 0.2.0 → 0.2.2
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/CHANGELOG.md +156 -0
- package/README.md +1 -1
- package/bin/den +3 -2
- package/docs/distributions.md +101 -9
- package/package.json +1 -1
- package/service/install.sh +14 -2
- package/src/shared/den-config.ts +69 -0
- package/src/shared/environment-manager/service-runner.ts +218 -4
- package/src/shared/manifest/placement.ts +15 -3
- package/src/shared/manifest/validate-layers.ts +40 -1
- package/src/shared/stacks/manifest-commands.ts +11 -1
- package/src/shared/stacks/runtime.ts +20 -0
- package/src/ui/cli/flag-parser.ts +2 -1
- package/src/ui/cli/index.ts +238 -45
- package/src/ui/cli/mcp-connect.ts +350 -0
- package/src/ui/cli/named-target.ts +11 -0
- package/src/ui/cli/service-command.ts +131 -0
- package/src/ui/cli/stack-command.ts +9 -0
- package/src/ui/mcp/api.ts +2 -0
- package/src/ui/mcp/connect.ts +304 -0
- package/src/ui/mcp/guards.ts +6 -2
- package/src/ui/mcp/server.ts +84 -1
- package/src/ui/mcp/tools.ts +89 -10
- package/src/ui/server/audit.ts +69 -3
- package/src/ui/server/index.ts +83 -2
- package/src/ui/server/lifecycle-exec.ts +188 -0
- package/src/ui/server/public/graph.css +4 -0
- package/src/ui/server/public/panel.js +6 -3
- package/src/ui/server/readonly-routes.ts +11 -2
- package/src/ui/server/workspace-routes.ts +1 -1
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<cli> mcp connect | disconnect | clients | config`: register this
|
|
3
|
+
* distribution's MCP server with a developer's agents. Planning lives in
|
|
4
|
+
* ../mcp/connect.ts; this file does the I/O.
|
|
5
|
+
*/
|
|
6
|
+
import { spawnSync } from "child_process";
|
|
7
|
+
import * as fs from "fs";
|
|
8
|
+
import * as os from "os";
|
|
9
|
+
import * as path from "path";
|
|
10
|
+
import chalk from "chalk";
|
|
11
|
+
import {
|
|
12
|
+
MCP_CLIENTS,
|
|
13
|
+
findClient,
|
|
14
|
+
genericSnippet,
|
|
15
|
+
jsonHasEntry,
|
|
16
|
+
mergeJsonConfig,
|
|
17
|
+
serverSpec,
|
|
18
|
+
type ConnectAction,
|
|
19
|
+
type ConnectEnv,
|
|
20
|
+
type McpClient,
|
|
21
|
+
type Scope,
|
|
22
|
+
type ServerSpec,
|
|
23
|
+
} from "../mcp/connect.js";
|
|
24
|
+
import { product } from "../../shared/product.js";
|
|
25
|
+
import { getDistRoot } from "../../shared/paths.js";
|
|
26
|
+
import { readWebPort } from "../../shared/edge/config.js";
|
|
27
|
+
import { DEFAULT_PORT } from "../../shared/constants.js";
|
|
28
|
+
import { readMcpConfig } from "../../shared/den-config.js";
|
|
29
|
+
|
|
30
|
+
export const MCP_CONNECT_SUBCOMMANDS = [
|
|
31
|
+
"connect",
|
|
32
|
+
"disconnect",
|
|
33
|
+
"clients",
|
|
34
|
+
"config",
|
|
35
|
+
] as const;
|
|
36
|
+
|
|
37
|
+
function onPath(bin: string): boolean {
|
|
38
|
+
const exts =
|
|
39
|
+
process.platform === "win32"
|
|
40
|
+
? (process.env.PATHEXT ?? ".EXE;.CMD").split(";")
|
|
41
|
+
: [""];
|
|
42
|
+
for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
|
|
43
|
+
if (!dir) continue;
|
|
44
|
+
for (const ext of exts) {
|
|
45
|
+
try {
|
|
46
|
+
fs.accessSync(path.join(dir, bin + ext), fs.constants.X_OK);
|
|
47
|
+
return true;
|
|
48
|
+
} catch {
|
|
49
|
+
/* next */
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function connectEnv(projectDir: string): ConnectEnv {
|
|
57
|
+
return {
|
|
58
|
+
home: os.homedir(),
|
|
59
|
+
platform: process.platform,
|
|
60
|
+
appData: process.env.APPDATA,
|
|
61
|
+
projectDir,
|
|
62
|
+
which: onPath,
|
|
63
|
+
exists: (p) => fs.existsSync(p),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface Flags {
|
|
68
|
+
clients: string[];
|
|
69
|
+
all: boolean;
|
|
70
|
+
project: string | null;
|
|
71
|
+
port?: number;
|
|
72
|
+
dryRun: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function parseFlags(argv: string[]): Flags {
|
|
76
|
+
const f: Flags = { clients: [], all: false, project: null, dryRun: false };
|
|
77
|
+
for (let i = 0; i < argv.length; i++) {
|
|
78
|
+
const a = argv[i];
|
|
79
|
+
if (a === "--all" || a === "-a") f.all = true;
|
|
80
|
+
else if (a === "--dry-run" || a === "-n") f.dryRun = true;
|
|
81
|
+
else if (a === "--project" || a === "-p") {
|
|
82
|
+
const next = argv[i + 1];
|
|
83
|
+
if (next && !next.startsWith("-") && !findClient(next)) {
|
|
84
|
+
f.project = path.resolve(next);
|
|
85
|
+
i++;
|
|
86
|
+
} else f.project = process.env.DEN_INVOKE_CWD ?? process.cwd();
|
|
87
|
+
} else if (a === "--port") {
|
|
88
|
+
const n = Number(argv[++i]);
|
|
89
|
+
if (!Number.isInteger(n) || n < 1 || n > 65535)
|
|
90
|
+
throw new Error(`--port must be a port number`);
|
|
91
|
+
f.port = n;
|
|
92
|
+
} else if (a.startsWith("-")) throw new Error(`unknown flag ${a}`);
|
|
93
|
+
else f.clients.push(a);
|
|
94
|
+
}
|
|
95
|
+
return f;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function spec(port?: number): ServerSpec {
|
|
99
|
+
const cli = product().cli;
|
|
100
|
+
const s = serverSpec({
|
|
101
|
+
cli,
|
|
102
|
+
distRoot: getDistRoot(),
|
|
103
|
+
nodeBinDir: path.dirname(process.execPath),
|
|
104
|
+
platform: process.platform,
|
|
105
|
+
port,
|
|
106
|
+
});
|
|
107
|
+
if (!fs.existsSync(s.command))
|
|
108
|
+
throw new Error(
|
|
109
|
+
`${s.command} does not exist: run this from a distribution with a bin/${cli} shim`,
|
|
110
|
+
);
|
|
111
|
+
return s;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const show = (argv: string[]): string =>
|
|
115
|
+
argv.map((a) => (/^[\w./:=@-]+$/.test(a) ? a : `'${a}'`)).join(" ");
|
|
116
|
+
|
|
117
|
+
function run(argv: string[]): { code: number; out: string } {
|
|
118
|
+
const r = spawnSync(argv[0], argv.slice(1), { encoding: "utf-8" });
|
|
119
|
+
return {
|
|
120
|
+
code: r.status ?? 1,
|
|
121
|
+
out: `${r.stdout ?? ""}${r.stderr ?? ""}`.trim(),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function readText(file: string): string | null {
|
|
126
|
+
try {
|
|
127
|
+
return fs.readFileSync(file, "utf-8");
|
|
128
|
+
} catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Write `text` to `file` atomically, keeping the previous copy as .bak. */
|
|
134
|
+
function writeConfig(file: string, text: string): void {
|
|
135
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
136
|
+
if (fs.existsSync(file)) fs.copyFileSync(file, `${file}.bak`);
|
|
137
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
138
|
+
fs.writeFileSync(tmp, text);
|
|
139
|
+
fs.renameSync(tmp, file);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function apply(
|
|
143
|
+
a: ConnectAction,
|
|
144
|
+
remove: boolean,
|
|
145
|
+
dryRun: boolean,
|
|
146
|
+
): { ok: boolean; said: string } {
|
|
147
|
+
if (a.kind === "json") {
|
|
148
|
+
const before = readText(a.file);
|
|
149
|
+
const after = mergeJsonConfig(
|
|
150
|
+
before,
|
|
151
|
+
a.key,
|
|
152
|
+
a.name,
|
|
153
|
+
remove ? null : a.entry,
|
|
154
|
+
);
|
|
155
|
+
if (!dryRun) writeConfig(a.file, after);
|
|
156
|
+
return {
|
|
157
|
+
ok: true,
|
|
158
|
+
said: `${dryRun ? "would write" : remove ? "removed from" : "wrote"} ${a.file}`,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
if (remove) {
|
|
162
|
+
if (!a.remove)
|
|
163
|
+
return {
|
|
164
|
+
ok: false,
|
|
165
|
+
said: "remove it from the client's own MCP settings",
|
|
166
|
+
};
|
|
167
|
+
if (dryRun) return { ok: true, said: `would run ${show(a.remove)}` };
|
|
168
|
+
const r = run(a.remove);
|
|
169
|
+
return { ok: r.code === 0, said: r.out || show(a.remove) };
|
|
170
|
+
}
|
|
171
|
+
if (dryRun) return { ok: true, said: `would run ${show(a.add)}` };
|
|
172
|
+
if (a.remove) run(a.remove);
|
|
173
|
+
const r = run(a.add);
|
|
174
|
+
return {
|
|
175
|
+
ok: r.code === 0,
|
|
176
|
+
said:
|
|
177
|
+
r.code === 0 ? `ran ${a.add[0]} ${a.add.slice(1, 3).join(" ")}` : r.out,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function status(
|
|
182
|
+
c: McpClient,
|
|
183
|
+
s: ServerSpec,
|
|
184
|
+
scope: Scope,
|
|
185
|
+
env: ConnectEnv,
|
|
186
|
+
): string {
|
|
187
|
+
const a = c.plan(s, scope, env);
|
|
188
|
+
if (a.kind === "json") {
|
|
189
|
+
const st = jsonHasEntry(readText(a.file), a.key, a.name, a.entry);
|
|
190
|
+
return st === "connected"
|
|
191
|
+
? chalk.green("connected")
|
|
192
|
+
: st === "different"
|
|
193
|
+
? chalk.yellow("connected (different command: reconnect)")
|
|
194
|
+
: chalk.gray("not connected");
|
|
195
|
+
}
|
|
196
|
+
if (!a.check) return chalk.gray("unknown (check the client's MCP list)");
|
|
197
|
+
return run(a.check).code === 0
|
|
198
|
+
? chalk.green("connected")
|
|
199
|
+
: chalk.gray("not connected");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function webServerLine(port?: number): Promise<string> {
|
|
203
|
+
let p = port;
|
|
204
|
+
if (p === undefined) {
|
|
205
|
+
try {
|
|
206
|
+
p = readWebPort();
|
|
207
|
+
} catch {
|
|
208
|
+
p = DEFAULT_PORT;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const url = `http://localhost:${p}`;
|
|
212
|
+
try {
|
|
213
|
+
const r = await fetch(`${url}/api/server-info`, {
|
|
214
|
+
signal: AbortSignal.timeout(1500),
|
|
215
|
+
});
|
|
216
|
+
const info = (await r.json()) as { version?: string };
|
|
217
|
+
return (
|
|
218
|
+
chalk.green(`web server up at ${url}`) +
|
|
219
|
+
(info.version ? chalk.gray(` (Den ${info.version})`) : "")
|
|
220
|
+
);
|
|
221
|
+
} catch {
|
|
222
|
+
return chalk.yellow(
|
|
223
|
+
`web server not running at ${url}: agents get read errors until \`${product().cli} web up\``,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function usage(): string {
|
|
229
|
+
const cli = product().cli;
|
|
230
|
+
return [
|
|
231
|
+
`Usage:`,
|
|
232
|
+
` ${cli} mcp clients which agents are installed and connected`,
|
|
233
|
+
` ${cli} mcp connect [<client>...|--all] register ${cli}'s MCP server (user scope)`,
|
|
234
|
+
` [--project [dir]] in a project's config instead (default: here)`,
|
|
235
|
+
` [--port <n>] [--dry-run]`,
|
|
236
|
+
` ${cli} mcp disconnect <client>... [--project [dir]]`,
|
|
237
|
+
` ${cli} mcp config [--port <n>] JSON for any other MCP client`,
|
|
238
|
+
``,
|
|
239
|
+
`Clients: ${MCP_CLIENTS.map((c) => c.id).join(", ")}`,
|
|
240
|
+
].join("\n");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export async function runMcpConnect(argv: string[]): Promise<number> {
|
|
244
|
+
const [sub, ...rest] = argv;
|
|
245
|
+
if (rest.includes("-h") || rest.includes("--help")) {
|
|
246
|
+
console.log(usage());
|
|
247
|
+
return 0;
|
|
248
|
+
}
|
|
249
|
+
let flags: Flags;
|
|
250
|
+
try {
|
|
251
|
+
flags = parseFlags(rest);
|
|
252
|
+
} catch (e) {
|
|
253
|
+
console.error(chalk.red(`${(e as Error).message}\n`) + usage());
|
|
254
|
+
return 1;
|
|
255
|
+
}
|
|
256
|
+
const s = spec(flags.port);
|
|
257
|
+
const scope: Scope = flags.project ? "project" : "user";
|
|
258
|
+
const env = connectEnv(
|
|
259
|
+
flags.project ?? process.env.DEN_INVOKE_CWD ?? process.cwd(),
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
if (sub === "config") {
|
|
263
|
+
console.log(genericSnippet(s));
|
|
264
|
+
return 0;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (sub === "clients") {
|
|
268
|
+
console.log(
|
|
269
|
+
chalk.bold(
|
|
270
|
+
`\n ${product().title} MCP server: ${s.command} ${s.args.join(" ")}`,
|
|
271
|
+
),
|
|
272
|
+
);
|
|
273
|
+
console.log(` ${await webServerLine(flags.port)}`);
|
|
274
|
+
const actions = readMcpConfig().actions;
|
|
275
|
+
console.log(
|
|
276
|
+
chalk.gray(
|
|
277
|
+
` tools: read${actions.length ? ` + actions ${actions.join(", ")}` : " only (mcp.actions is empty)"}\n`,
|
|
278
|
+
),
|
|
279
|
+
);
|
|
280
|
+
for (const c of MCP_CLIENTS) {
|
|
281
|
+
const here = c.detect(env);
|
|
282
|
+
const st =
|
|
283
|
+
here && c.scopes.includes(scope)
|
|
284
|
+
? status(c, s, scope, env)
|
|
285
|
+
: chalk.gray(here ? `no ${scope} scope` : "not installed");
|
|
286
|
+
console.log(
|
|
287
|
+
` ${here ? "●" : "○"} ${c.id.padEnd(15)} ${c.title.padEnd(24)} ${st}`,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
console.log(
|
|
291
|
+
chalk.gray(
|
|
292
|
+
`\n Connect: ${product().cli} mcp connect <client> (or --all for every installed one)\n`,
|
|
293
|
+
),
|
|
294
|
+
);
|
|
295
|
+
return 0;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (sub !== "connect" && sub !== "disconnect") {
|
|
299
|
+
console.log(usage());
|
|
300
|
+
return 1;
|
|
301
|
+
}
|
|
302
|
+
const remove = sub === "disconnect";
|
|
303
|
+
let targets: McpClient[];
|
|
304
|
+
if (flags.all)
|
|
305
|
+
targets = MCP_CLIENTS.filter(
|
|
306
|
+
(c) => c.detect(env) && c.scopes.includes(scope),
|
|
307
|
+
);
|
|
308
|
+
else {
|
|
309
|
+
const unknown = flags.clients.filter((id) => !findClient(id));
|
|
310
|
+
if (unknown.length) {
|
|
311
|
+
console.error(
|
|
312
|
+
chalk.red(`unknown client: ${unknown.join(", ")}\n`) + usage(),
|
|
313
|
+
);
|
|
314
|
+
return 1;
|
|
315
|
+
}
|
|
316
|
+
targets = flags.clients.map((id) => findClient(id)!);
|
|
317
|
+
}
|
|
318
|
+
if (targets.length === 0) {
|
|
319
|
+
console.log(usage());
|
|
320
|
+
return 1;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
let failed = 0;
|
|
324
|
+
for (const c of targets) {
|
|
325
|
+
if (!c.scopes.includes(scope)) {
|
|
326
|
+
console.log(` ${chalk.yellow("•")} ${c.title}: no ${scope} scope`);
|
|
327
|
+
failed++;
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
try {
|
|
331
|
+
const r = apply(c.plan(s, scope, env), remove, flags.dryRun);
|
|
332
|
+
console.log(
|
|
333
|
+
` ${r.ok ? chalk.green("✓") : chalk.red("✗")} ${c.title}: ${r.said}`,
|
|
334
|
+
);
|
|
335
|
+
if (!r.ok) failed++;
|
|
336
|
+
} catch (e) {
|
|
337
|
+
console.log(` ${chalk.red("✗")} ${c.title}: ${(e as Error).message}`);
|
|
338
|
+
failed++;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
if (!remove && !flags.dryRun && failed < targets.length) {
|
|
342
|
+
console.log(
|
|
343
|
+
chalk.gray(
|
|
344
|
+
`\n Restart the agent (or start a new session) to load "${s.name}".`,
|
|
345
|
+
),
|
|
346
|
+
);
|
|
347
|
+
console.log(` ${await webServerLine(flags.port)}\n`);
|
|
348
|
+
}
|
|
349
|
+
return failed ? 1 : 0;
|
|
350
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether `address` ("repo" or "repo/service") is something the user named on
|
|
3
|
+
* the command line: the address itself, or the repo it belongs to.
|
|
4
|
+
*/
|
|
5
|
+
export function isNamedTarget(
|
|
6
|
+
address: string,
|
|
7
|
+
named: readonly string[],
|
|
8
|
+
): boolean {
|
|
9
|
+
const repo = address.split("/")[0];
|
|
10
|
+
return named.some((n) => n === address || n === repo);
|
|
11
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `service install | status | print | uninstall`
|
|
3
|
+
*
|
|
4
|
+
* The web UI as a macOS launchd service, over Den's service/install.sh. The
|
|
5
|
+
* label and log directory come from the org config's `web.service`
|
|
6
|
+
* (readWebServiceConfig), with the script's own defaults, so a distribution
|
|
7
|
+
* no longer wraps the script in its Makefile.
|
|
8
|
+
*
|
|
9
|
+
* `install` (which also restarts a running service) says the service is
|
|
10
|
+
* running only once the web server answers /api/server-info with JSON: a
|
|
11
|
+
* fresh server takes a few seconds to start, and answers empty until then.
|
|
12
|
+
*/
|
|
13
|
+
import { spawnSync } from "child_process";
|
|
14
|
+
import * as os from "os";
|
|
15
|
+
import * as path from "path";
|
|
16
|
+
import {
|
|
17
|
+
readDenConfig,
|
|
18
|
+
readWebServiceConfig,
|
|
19
|
+
type DenConfig,
|
|
20
|
+
} from "../../shared/den-config.js";
|
|
21
|
+
import { readWebPort } from "../../shared/edge/config.js";
|
|
22
|
+
import { getDistRoot, getEngineRoot } from "../../shared/paths.js";
|
|
23
|
+
|
|
24
|
+
/** Runs a command with extra env vars; returns its exit code. */
|
|
25
|
+
export type ServiceRunner = (
|
|
26
|
+
cmd: string,
|
|
27
|
+
args: string[],
|
|
28
|
+
env: Record<string, string>,
|
|
29
|
+
) => number;
|
|
30
|
+
|
|
31
|
+
const SUBCOMMANDS = ["install", "status", "print", "uninstall"] as const;
|
|
32
|
+
|
|
33
|
+
const defaultRunner: ServiceRunner = (cmd, args, env) =>
|
|
34
|
+
spawnSync(cmd, args, { stdio: "inherit", env: { ...process.env, ...env } })
|
|
35
|
+
.status ?? 1;
|
|
36
|
+
|
|
37
|
+
/** How long `install` waits for the web server to answer. */
|
|
38
|
+
export const READY_TIMEOUT_MS = 30_000;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Poll `url` until it answers 2xx with a JSON object, or `timeoutMs` passes.
|
|
42
|
+
* Each attempt is bounded too, so a hung connection cannot outlast it.
|
|
43
|
+
*/
|
|
44
|
+
export async function waitForServerInfo(
|
|
45
|
+
url: string,
|
|
46
|
+
timeoutMs: number = READY_TIMEOUT_MS,
|
|
47
|
+
intervalMs = 500,
|
|
48
|
+
): Promise<boolean> {
|
|
49
|
+
const deadline = Date.now() + timeoutMs;
|
|
50
|
+
for (;;) {
|
|
51
|
+
const left = deadline - Date.now();
|
|
52
|
+
if (left <= 0) return false;
|
|
53
|
+
try {
|
|
54
|
+
const res = await fetch(url, {
|
|
55
|
+
signal: AbortSignal.timeout(Math.min(2_000, left)),
|
|
56
|
+
});
|
|
57
|
+
if (res.ok) {
|
|
58
|
+
const body: unknown = JSON.parse(await res.text());
|
|
59
|
+
if (body !== null && typeof body === "object") return true;
|
|
60
|
+
}
|
|
61
|
+
} catch {
|
|
62
|
+
/* not up yet: refused, empty or partial body */
|
|
63
|
+
}
|
|
64
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ServiceDeps {
|
|
69
|
+
run?: ServiceRunner;
|
|
70
|
+
config?: DenConfig;
|
|
71
|
+
distRoot?: string;
|
|
72
|
+
engineRoot?: string;
|
|
73
|
+
home?: string;
|
|
74
|
+
platform?: NodeJS.Platform;
|
|
75
|
+
/** The web UI port (default: config web.port). */
|
|
76
|
+
webPort?: number;
|
|
77
|
+
/** Readiness poller (default: waitForServerInfo). */
|
|
78
|
+
waitReady?: (url: string, timeoutMs: number) => Promise<boolean>;
|
|
79
|
+
log?: (line: string) => void;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Run `service <sub>`; returns the exit code. */
|
|
83
|
+
export async function cmdService(
|
|
84
|
+
args: string[],
|
|
85
|
+
deps: ServiceDeps = {},
|
|
86
|
+
): Promise<number> {
|
|
87
|
+
const sub = args[0];
|
|
88
|
+
if (!(SUBCOMMANDS as readonly string[]).includes(sub ?? ""))
|
|
89
|
+
throw new Error(
|
|
90
|
+
`${sub ? `Unknown service command "${sub}". ` : ""}Use: ${SUBCOMMANDS.join(" | ")}`,
|
|
91
|
+
);
|
|
92
|
+
// print only writes the plist; the rest talk to launchd.
|
|
93
|
+
if (sub !== "print" && (deps.platform ?? process.platform) !== "darwin")
|
|
94
|
+
throw new Error(
|
|
95
|
+
"the web UI service is a launchd LaunchAgent: macOS only (print works anywhere)",
|
|
96
|
+
);
|
|
97
|
+
const distRoot = deps.distRoot ?? getDistRoot();
|
|
98
|
+
const { label, logs } = readWebServiceConfig(
|
|
99
|
+
deps.config ?? readDenConfig(),
|
|
100
|
+
distRoot,
|
|
101
|
+
deps.home ?? os.homedir(),
|
|
102
|
+
);
|
|
103
|
+
const script = path.join(
|
|
104
|
+
deps.engineRoot ?? getEngineRoot(),
|
|
105
|
+
"service",
|
|
106
|
+
"install.sh",
|
|
107
|
+
);
|
|
108
|
+
const code = (deps.run ?? defaultRunner)("bash", [script, sub!], {
|
|
109
|
+
DEN_ROOT: distRoot,
|
|
110
|
+
DEN_SERVICE_LABEL: label,
|
|
111
|
+
DEN_SERVICE_LOGS: logs,
|
|
112
|
+
// The script then leaves saying "running" to us (after the poll below).
|
|
113
|
+
...(sub === "install" ? { DEN_SERVICE_READY_CHECK: "external" } : {}),
|
|
114
|
+
});
|
|
115
|
+
if (sub !== "install" || code !== 0) return code;
|
|
116
|
+
|
|
117
|
+
const log = deps.log ?? ((l: string) => console.log(l));
|
|
118
|
+
const port = deps.webPort ?? readWebPort(distRoot);
|
|
119
|
+
const url = `http://localhost:${port}/api/server-info`;
|
|
120
|
+
if (await (deps.waitReady ?? waitForServerInfo)(url, READY_TIMEOUT_MS)) {
|
|
121
|
+
log(
|
|
122
|
+
`✓ ${label} running at http://localhost:${port} (logs: ${path.join(logs, "web.log")})`,
|
|
123
|
+
);
|
|
124
|
+
return 0;
|
|
125
|
+
}
|
|
126
|
+
log(
|
|
127
|
+
`✗ ${label} installed but not answering yet: check ${logs} ` +
|
|
128
|
+
`(${url} gave no JSON within ${READY_TIMEOUT_MS / 1000}s)`,
|
|
129
|
+
);
|
|
130
|
+
return 1;
|
|
131
|
+
}
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
stackHealth,
|
|
15
15
|
stackLogs,
|
|
16
16
|
stackPs,
|
|
17
|
+
stackRestartService,
|
|
17
18
|
stackUp,
|
|
18
19
|
} from "../../shared/stacks/runtime.js";
|
|
19
20
|
import { loadStackContext, stackStatuses } from "../../shared/stacks/status.js";
|
|
@@ -71,6 +72,14 @@ export async function cmdStack(argv: string[]): Promise<void> {
|
|
|
71
72
|
return;
|
|
72
73
|
}
|
|
73
74
|
case "restart":
|
|
75
|
+
if (service) {
|
|
76
|
+
// One compose service, in place: no edge re-render, no recreate.
|
|
77
|
+
for (const n of pick()) {
|
|
78
|
+
console.log(chalk.cyan(`↻ ${n} ${service}`));
|
|
79
|
+
stackRestartService(ctx, stacks[n], service);
|
|
80
|
+
}
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
74
83
|
await edgeUp(edge);
|
|
75
84
|
for (const n of pick()) {
|
|
76
85
|
console.log(chalk.cyan(`↻ ${n}`));
|
package/src/ui/mcp/api.ts
CHANGED
|
@@ -58,6 +58,8 @@ const READ_ROUTES = new Set([
|
|
|
58
58
|
|
|
59
59
|
/** Action routes and their method. Anything else is never reached. */
|
|
60
60
|
const ACTION_ROUTES: Record<string, string> = {
|
|
61
|
+
// A refusal report: appends one entry to the activity trail, nothing more.
|
|
62
|
+
"/api/activity": "POST",
|
|
61
63
|
"/api/service/exec": "GET",
|
|
62
64
|
"/api/run": "GET",
|
|
63
65
|
"/api/workspace/sync": "POST",
|