@monster-orange/den 0.2.1 → 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 +89 -0
- package/README.md +1 -1
- package/docs/distributions.md +65 -1
- 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 +214 -2
- 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 +184 -34
- package/src/ui/cli/service-command.ts +131 -0
- package/src/ui/cli/stack-command.ts +9 -0
- package/src/ui/mcp/server.ts +82 -1
|
@@ -31,13 +31,23 @@ export function composeCommands(
|
|
|
31
31
|
repoName: string,
|
|
32
32
|
workspace: string,
|
|
33
33
|
): Record<string, CommandDef> {
|
|
34
|
-
if (!manifest.compose
|
|
34
|
+
if (!manifest.compose) return {};
|
|
35
35
|
// `docker compose up -d` returns once the containers are started, so these
|
|
36
36
|
// finish on their own: never daemonize them.
|
|
37
37
|
const stack = (sub: string) => ({
|
|
38
38
|
cmd: `${denBin()} stack ${sub} ${manifestStackName(manifest, repoName)} -w ${workspace}`,
|
|
39
39
|
detachable: false,
|
|
40
40
|
});
|
|
41
|
+
// Any other manifest service names a compose service: it restarts on its
|
|
42
|
+
// own (`docker compose restart`, same container and env). Up and down stay
|
|
43
|
+
// the stack's, through the primary service.
|
|
44
|
+
if (!isPrimary(manifest, subsystem))
|
|
45
|
+
return {
|
|
46
|
+
restart: {
|
|
47
|
+
...stack("restart"),
|
|
48
|
+
cmd: `${stack("restart").cmd} --service ${JSON.stringify(subsystem)}`,
|
|
49
|
+
},
|
|
50
|
+
};
|
|
41
51
|
return {
|
|
42
52
|
up: stack("up"),
|
|
43
53
|
down: stack("down"),
|
|
@@ -266,6 +266,26 @@ export function stackDown(ctx: StackContext, s: StackConfig): void {
|
|
|
266
266
|
}
|
|
267
267
|
}
|
|
268
268
|
|
|
269
|
+
/**
|
|
270
|
+
* Restart one compose service of a running stack in place (`docker compose
|
|
271
|
+
* restart`): the same container, so the same env; nothing is recreated.
|
|
272
|
+
*/
|
|
273
|
+
export function stackRestartService(
|
|
274
|
+
ctx: StackContext,
|
|
275
|
+
s: StackConfig,
|
|
276
|
+
service: string,
|
|
277
|
+
): void {
|
|
278
|
+
const override = path.join(stackDir(ctx, s), "override.yml");
|
|
279
|
+
const args = fs.existsSync(override)
|
|
280
|
+
? overrideArgs(ctx, s)
|
|
281
|
+
: baseArgs(ctx, s);
|
|
282
|
+
must("docker", [...args, "restart", service], {
|
|
283
|
+
cwd: repoDir(s),
|
|
284
|
+
env: composeEnv(ctx, s),
|
|
285
|
+
inherit: true,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
269
289
|
export function stackPs(ctx: StackContext, s: StackConfig): string {
|
|
270
290
|
return run(
|
|
271
291
|
"docker",
|
|
@@ -176,8 +176,9 @@ const COMMAND_FLAGS: Record<string, readonly string[]> = {
|
|
|
176
176
|
"--force",
|
|
177
177
|
"-A",
|
|
178
178
|
"--app",
|
|
179
|
+
"--with-deps",
|
|
179
180
|
],
|
|
180
|
-
restart: ["-A", "--app"],
|
|
181
|
+
restart: ["-A", "--app", "-n", "--dry-run"],
|
|
181
182
|
logs: [],
|
|
182
183
|
setup: ["-f", "--force"],
|
|
183
184
|
sync: [],
|
package/src/ui/cli/index.ts
CHANGED
|
@@ -96,7 +96,12 @@ import {
|
|
|
96
96
|
getWaitDeps,
|
|
97
97
|
} from "../../shared/environment-manager/dag.js";
|
|
98
98
|
import { waitForReady } from "../../shared/environment-manager/readiness.js";
|
|
99
|
-
import {
|
|
99
|
+
import {
|
|
100
|
+
canonicalServiceAddress,
|
|
101
|
+
previewServiceCommand,
|
|
102
|
+
resolveService,
|
|
103
|
+
type ServiceCommandPreview,
|
|
104
|
+
} from "../../shared/environment-manager/service-runner.js";
|
|
100
105
|
import { getGitHubCoords } from "../../shared/smoke/git-remote.js";
|
|
101
106
|
import {
|
|
102
107
|
getReposRoot,
|
|
@@ -157,6 +162,7 @@ import {
|
|
|
157
162
|
import { cleanupStaleTabs } from "../../shared/cli-tabs.js";
|
|
158
163
|
import { cmdConfig } from "./config-command.js";
|
|
159
164
|
import { cmdEdge } from "./edge-command.js";
|
|
165
|
+
import { cmdService } from "./service-command.js";
|
|
160
166
|
import { cmdStack } from "./stack-command.js";
|
|
161
167
|
import { cmdWeb, displayWebHelp } from "./web-command.js";
|
|
162
168
|
import { cmdGraph } from "./graph-command.js";
|
|
@@ -191,14 +197,15 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
|
|
|
191
197
|
down: {
|
|
192
198
|
usage: "down [repo] [flags]",
|
|
193
199
|
lines: [
|
|
194
|
-
"Stop the cwd (or named) repo
|
|
200
|
+
"Stop the cwd (or named) repo; --with-deps also stops what it sits on (its",
|
|
201
|
+
"derived closure, in reverse order), naming anything else that uses it.",
|
|
195
202
|
"-a/--all stops EVERY workspace system (reverse derived order, cwd-agnostic;",
|
|
196
203
|
"every down script runs — no already-stopped skip).",
|
|
197
|
-
"Flags: -a/--all · -A/--app <app> · -n/--dry-run (print order, stop nothing) · --manifest (legacy depends_on)",
|
|
204
|
+
"Flags: -a/--all · -A/--app <app> · --with-deps · -n/--dry-run (print order, stop nothing) · --manifest (legacy depends_on)",
|
|
198
205
|
],
|
|
199
206
|
},
|
|
200
207
|
restart: {
|
|
201
|
-
usage: "restart [service...] [-A <app>]",
|
|
208
|
+
usage: "restart [service...] [-A <app>] [-n|--dry-run]",
|
|
202
209
|
lines: [
|
|
203
210
|
`Restart the given services, or every repo in an application (\`${product().cli} apps\`).`,
|
|
204
211
|
],
|
|
@@ -301,10 +308,18 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
|
|
|
301
308
|
],
|
|
302
309
|
},
|
|
303
310
|
mcp: {
|
|
304
|
-
usage: "mcp [--port <web port>]",
|
|
311
|
+
usage: "mcp [--port <web port>] [--list | --call <tool> ['<json args>']]",
|
|
305
312
|
lines: [
|
|
306
313
|
"An MCP server over stdio for agents: a client of the running web server",
|
|
307
314
|
`(\`${product().cli} web up\`). Read tools always; actions only as named in mcp.actions.`,
|
|
315
|
+
"--list prints the tool names; --call runs one tool and prints its result.",
|
|
316
|
+
],
|
|
317
|
+
},
|
|
318
|
+
service: {
|
|
319
|
+
usage: "service [install|status|print|uninstall]",
|
|
320
|
+
lines: [
|
|
321
|
+
"The web UI as a macOS launchd service, started at login. Label and logs:",
|
|
322
|
+
"config web.service { label, logs }.",
|
|
308
323
|
],
|
|
309
324
|
},
|
|
310
325
|
"local-dev": {
|
|
@@ -363,6 +378,7 @@ const TELEMETRY_COMMANDS = new Set<string>([
|
|
|
363
378
|
"apps",
|
|
364
379
|
"applications",
|
|
365
380
|
"mcp",
|
|
381
|
+
"service",
|
|
366
382
|
]);
|
|
367
383
|
|
|
368
384
|
/** Map a raw command token to a PII-safe, registered telemetry command name. */
|
|
@@ -380,6 +396,20 @@ function telemetryReason(error: unknown): string {
|
|
|
380
396
|
return name && name !== "Object" ? name : "Error";
|
|
381
397
|
}
|
|
382
398
|
|
|
399
|
+
/**
|
|
400
|
+
* What was typed, with each service alias (a manifest service key or a
|
|
401
|
+
* compose stack name) mapped to its service's address, so the derived graph,
|
|
402
|
+
* ownership and the daemon registry all see one name. Flags pass through; an
|
|
403
|
+
* ambiguous alias throws, naming the candidates. `limit` maps only the first
|
|
404
|
+
* n positional args.
|
|
405
|
+
*/
|
|
406
|
+
function canonicalTargets(args: string[], limit = Infinity): string[] {
|
|
407
|
+
let seen = 0;
|
|
408
|
+
return args.map((a) =>
|
|
409
|
+
a.startsWith("-") || seen++ >= limit ? a : canonicalServiceAddress(a),
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
383
413
|
/** Print one manifest's validation: a status line, then each issue. */
|
|
384
414
|
function printValidation(
|
|
385
415
|
label: string,
|
|
@@ -685,6 +715,10 @@ class DenCLI {
|
|
|
685
715
|
await cmdEdge(args.slice(1));
|
|
686
716
|
break;
|
|
687
717
|
|
|
718
|
+
case "service":
|
|
719
|
+
process.exitCode = await cmdService(args.slice(1));
|
|
720
|
+
break;
|
|
721
|
+
|
|
688
722
|
case "stack":
|
|
689
723
|
await cmdStack(args.slice(1));
|
|
690
724
|
break;
|
|
@@ -1318,6 +1352,11 @@ class DenCLI {
|
|
|
1318
1352
|
* as part of a workspace or app run is said and skipped.
|
|
1319
1353
|
*/
|
|
1320
1354
|
named?: readonly string[];
|
|
1355
|
+
/**
|
|
1356
|
+
* `-n`/`--dry-run` (and `--plan` on this path): print what would run,
|
|
1357
|
+
* in order, and run nothing. Read-only on every path.
|
|
1358
|
+
*/
|
|
1359
|
+
dryRun?: boolean;
|
|
1321
1360
|
},
|
|
1322
1361
|
): Promise<void> {
|
|
1323
1362
|
// How long to show startup output before handing control back to the menu
|
|
@@ -1328,6 +1367,22 @@ class DenCLI {
|
|
|
1328
1367
|
getFocusedWorkspaceName() ??
|
|
1329
1368
|
undefined;
|
|
1330
1369
|
const force = opts?.force ?? false;
|
|
1370
|
+
// R13: why a remote service is not run here, or null.
|
|
1371
|
+
const remoteRefusal = (address: string): string | null => {
|
|
1372
|
+
const placed = resolveService(address, workspace);
|
|
1373
|
+
return placed
|
|
1374
|
+
? placementRefusal(
|
|
1375
|
+
address,
|
|
1376
|
+
servicePlacement(
|
|
1377
|
+
readManifest(placed.repoPath),
|
|
1378
|
+
placed.subsystem,
|
|
1379
|
+
"local",
|
|
1380
|
+
),
|
|
1381
|
+
(e) => isProductionEnv(e),
|
|
1382
|
+
command,
|
|
1383
|
+
)
|
|
1384
|
+
: null;
|
|
1385
|
+
};
|
|
1331
1386
|
|
|
1332
1387
|
// Resolve dependency order. Derived mode supplies the order directly;
|
|
1333
1388
|
// otherwise topo-sort manifest depends_on (`down` → reverse).
|
|
@@ -1341,8 +1396,17 @@ class DenCLI {
|
|
|
1341
1396
|
const startupOrder = ws
|
|
1342
1397
|
? resolveStartupOrder(addresses, catalog, ws.config, ws.name)
|
|
1343
1398
|
: addresses;
|
|
1399
|
+
// Only up pulls in dependencies (a service needs them to start).
|
|
1400
|
+
// Down and restart act on what was named: its dependencies may be
|
|
1401
|
+
// serving other running services.
|
|
1402
|
+
const namedOnly = (): string[] => {
|
|
1403
|
+
const kept = startupOrder.filter((a) => addresses.includes(a));
|
|
1404
|
+
return [...kept, ...addresses.filter((a) => !kept.includes(a))];
|
|
1405
|
+
};
|
|
1344
1406
|
if (command === "down") {
|
|
1345
|
-
ordered =
|
|
1407
|
+
ordered = namedOnly().reverse();
|
|
1408
|
+
} else if (command !== "up") {
|
|
1409
|
+
ordered = namedOnly();
|
|
1346
1410
|
} else {
|
|
1347
1411
|
ordered = startupOrder;
|
|
1348
1412
|
if (ordered.length > addresses.length) {
|
|
@@ -1358,6 +1422,42 @@ class DenCLI {
|
|
|
1358
1422
|
}
|
|
1359
1423
|
}
|
|
1360
1424
|
|
|
1425
|
+
if (opts?.dryRun) {
|
|
1426
|
+
// The same lookup a real run makes, so the dry run says what it does.
|
|
1427
|
+
const previews = ordered.map((a): ServiceCommandPreview => {
|
|
1428
|
+
const remote = remoteRefusal(a);
|
|
1429
|
+
if (!remote) return previewServiceCommand(a, command, workspace);
|
|
1430
|
+
// As the loop below: a named remote target fails, any other is
|
|
1431
|
+
// said and skipped.
|
|
1432
|
+
return isNamedTarget(a, opts?.named ?? [])
|
|
1433
|
+
? { kind: "error", address: a, note: remote }
|
|
1434
|
+
: {
|
|
1435
|
+
kind: "nothing",
|
|
1436
|
+
address: a,
|
|
1437
|
+
note: `${a} (remote, skipped): ${remote}`,
|
|
1438
|
+
};
|
|
1439
|
+
});
|
|
1440
|
+
const doing =
|
|
1441
|
+
{ up: "starting", down: "stopping", restart: "restarting" }[command] ??
|
|
1442
|
+
`running "${command}" on`;
|
|
1443
|
+
const runs = previews
|
|
1444
|
+
.filter((p) => p.kind === "run" || p.kind === "via-repo")
|
|
1445
|
+
.map((p) => p.address);
|
|
1446
|
+
console.log(
|
|
1447
|
+
chalk.cyan(
|
|
1448
|
+
`\n Would ${command}: ${[...new Set(runs)].join(" → ") || "(nothing)"}`,
|
|
1449
|
+
),
|
|
1450
|
+
);
|
|
1451
|
+
for (const p of previews) {
|
|
1452
|
+
if (p.kind === "via-repo" || p.kind === "nothing")
|
|
1453
|
+
console.log(chalk.gray(` ${p.note}`));
|
|
1454
|
+
if (p.kind === "error") console.log(chalk.red(` ✗ ${p.note}`));
|
|
1455
|
+
}
|
|
1456
|
+
if (previews.some((p) => p.kind === "error")) process.exitCode = 1;
|
|
1457
|
+
console.log(chalk.gray(` (--dry-run: not ${doing} anything)\n`));
|
|
1458
|
+
return;
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1361
1461
|
// Build a catalog + workspace reference once per run for readiness lookups.
|
|
1362
1462
|
const catalog = readCatalog();
|
|
1363
1463
|
const ws = getActiveWorkspace();
|
|
@@ -1398,19 +1498,7 @@ class DenCLI {
|
|
|
1398
1498
|
}
|
|
1399
1499
|
// R13: a remote service is never started or stopped from here. Said,
|
|
1400
1500
|
// not failed, so a workspace or app up/down carries on past it.
|
|
1401
|
-
const
|
|
1402
|
-
const remote = placed
|
|
1403
|
-
? placementRefusal(
|
|
1404
|
-
address,
|
|
1405
|
-
servicePlacement(
|
|
1406
|
-
readManifest(placed.repoPath),
|
|
1407
|
-
placed.subsystem,
|
|
1408
|
-
"local",
|
|
1409
|
-
),
|
|
1410
|
-
(e) => isProductionEnv(e),
|
|
1411
|
-
command,
|
|
1412
|
-
)
|
|
1413
|
-
: null;
|
|
1501
|
+
const remote = remoteRefusal(address);
|
|
1414
1502
|
if (remote) {
|
|
1415
1503
|
if (isNamedTarget(address, opts?.named ?? [])) {
|
|
1416
1504
|
console.log(chalk.red(` ✗ ${remote}`));
|
|
@@ -1629,6 +1717,9 @@ class DenCLI {
|
|
|
1629
1717
|
}
|
|
1630
1718
|
});
|
|
1631
1719
|
|
|
1720
|
+
// Any target that failed makes the whole command fail (scripts and
|
|
1721
|
+
// agents read the exit code).
|
|
1722
|
+
if (!started) process.exitCode = 1;
|
|
1632
1723
|
if (plan?.failFast && !started) {
|
|
1633
1724
|
console.log(
|
|
1634
1725
|
chalk.red(
|
|
@@ -1817,7 +1908,12 @@ class DenCLI {
|
|
|
1817
1908
|
return;
|
|
1818
1909
|
}
|
|
1819
1910
|
if (dryRun) {
|
|
1820
|
-
|
|
1911
|
+
await this.runServicesCommand(
|
|
1912
|
+
order,
|
|
1913
|
+
"up",
|
|
1914
|
+
{ order },
|
|
1915
|
+
{ named, dryRun: true },
|
|
1916
|
+
);
|
|
1821
1917
|
return;
|
|
1822
1918
|
}
|
|
1823
1919
|
// Check the secrets backend first so any first-clone `make setup` (and the up
|
|
@@ -1862,10 +1958,13 @@ class DenCLI {
|
|
|
1862
1958
|
seed: string,
|
|
1863
1959
|
dryRun: boolean,
|
|
1864
1960
|
force = false,
|
|
1961
|
+
withDeps = false,
|
|
1865
1962
|
): Promise<void> {
|
|
1866
1963
|
const full = deriveGraph({ scope: "all" });
|
|
1964
|
+
// Only the named repo, unless --with-deps: what it sits on may be
|
|
1965
|
+
// serving other running systems (the same rule as a named service).
|
|
1867
1966
|
const upOrder = this.runtimeOnly(
|
|
1868
|
-
deriveStartTargets(full, seed).order,
|
|
1967
|
+
withDeps ? deriveStartTargets(full, seed).order : [seed],
|
|
1869
1968
|
"stopped",
|
|
1870
1969
|
);
|
|
1871
1970
|
if (upOrder.length === 0) return;
|
|
@@ -1876,6 +1975,9 @@ class DenCLI {
|
|
|
1876
1975
|
// still running on them. Name them rather than letting them fall over
|
|
1877
1976
|
// silently.
|
|
1878
1977
|
const inClosure = new Set(order);
|
|
1978
|
+
// Only what really stops here puts dependents at risk: a remote service
|
|
1979
|
+
// is refused (R13), so it stops nothing.
|
|
1980
|
+
const stopsHere = new Set(order.filter((a) => !this.isRemote(a, "down")));
|
|
1879
1981
|
const atRisk = [
|
|
1880
1982
|
...new Set(
|
|
1881
1983
|
full.edges
|
|
@@ -1883,14 +1985,16 @@ class DenCLI {
|
|
|
1883
1985
|
(e) =>
|
|
1884
1986
|
e.kind !== "jwks" &&
|
|
1885
1987
|
e.ordering !== false &&
|
|
1886
|
-
|
|
1988
|
+
stopsHere.has(e.to) &&
|
|
1887
1989
|
!inClosure.has(e.from),
|
|
1888
1990
|
)
|
|
1889
1991
|
.map((e) => e.from),
|
|
1890
1992
|
),
|
|
1891
1993
|
].sort();
|
|
1892
1994
|
console.log(
|
|
1893
|
-
chalk.cyan(
|
|
1995
|
+
chalk.cyan(
|
|
1996
|
+
`\n Derived down for ${chalk.bold(seed)}${withDeps ? " (+ dependencies)" : ""}`,
|
|
1997
|
+
),
|
|
1894
1998
|
);
|
|
1895
1999
|
console.log(chalk.gray(` Order: ${order.join(" → ")}\n`));
|
|
1896
2000
|
if (atRisk.length > 0) {
|
|
@@ -1902,7 +2006,12 @@ class DenCLI {
|
|
|
1902
2006
|
);
|
|
1903
2007
|
}
|
|
1904
2008
|
if (dryRun) {
|
|
1905
|
-
|
|
2009
|
+
await this.runServicesCommand(
|
|
2010
|
+
order,
|
|
2011
|
+
"down",
|
|
2012
|
+
{ order },
|
|
2013
|
+
{ force, named: [seed], dryRun: true },
|
|
2014
|
+
);
|
|
1906
2015
|
return;
|
|
1907
2016
|
}
|
|
1908
2017
|
await this.runServicesCommand(
|
|
@@ -1913,6 +2022,24 @@ class DenCLI {
|
|
|
1913
2022
|
);
|
|
1914
2023
|
}
|
|
1915
2024
|
|
|
2025
|
+
/** Whether `address` runs remotely, so `command` is refused here (R13). */
|
|
2026
|
+
private isRemote(address: string, command: string): boolean {
|
|
2027
|
+
const placed = resolveService(address);
|
|
2028
|
+
return (
|
|
2029
|
+
!!placed &&
|
|
2030
|
+
placementRefusal(
|
|
2031
|
+
address,
|
|
2032
|
+
servicePlacement(
|
|
2033
|
+
readManifest(placed.repoPath),
|
|
2034
|
+
placed.subsystem,
|
|
2035
|
+
"local",
|
|
2036
|
+
),
|
|
2037
|
+
(e) => isProductionEnv(e),
|
|
2038
|
+
command,
|
|
2039
|
+
) !== null
|
|
2040
|
+
);
|
|
2041
|
+
}
|
|
2042
|
+
|
|
1916
2043
|
/** Workspace repos with no runtime (modules, libraries) → their kind. */
|
|
1917
2044
|
private noRuntimeRepos(): Map<string, RepoKind> {
|
|
1918
2045
|
const out = new Map<string, RepoKind>();
|
|
@@ -2337,9 +2464,10 @@ class DenCLI {
|
|
|
2337
2464
|
// Tokens after a `--` terminator belong to a child command — they must
|
|
2338
2465
|
// never be read as up's own flags (`den up -- -a` is NOT `up -a`).
|
|
2339
2466
|
const terminator = args.indexOf("--");
|
|
2340
|
-
const { app, rest
|
|
2467
|
+
const { app, rest } = takeAppFlag(
|
|
2341
2468
|
terminator === -1 ? args : args.slice(0, terminator),
|
|
2342
2469
|
);
|
|
2470
|
+
const ownArgs = canonicalTargets(rest);
|
|
2343
2471
|
const dryRun = ownArgs.includes("--dry-run") || ownArgs.includes("-n");
|
|
2344
2472
|
const plan = ownArgs.includes("--plan");
|
|
2345
2473
|
const pull = ownArgs.includes("--pull");
|
|
@@ -2396,11 +2524,17 @@ class DenCLI {
|
|
|
2396
2524
|
await this.runDerivedUp(seed, include, dryRun, plan, pull);
|
|
2397
2525
|
return;
|
|
2398
2526
|
}
|
|
2399
|
-
await this.cmdServiceUpManifest(
|
|
2527
|
+
await this.cmdServiceUpManifest(
|
|
2528
|
+
ownArgs.filter((a) => !a.startsWith("-")),
|
|
2529
|
+
dryRun || plan,
|
|
2530
|
+
);
|
|
2400
2531
|
}
|
|
2401
2532
|
|
|
2402
2533
|
/** Legacy: start the given services via manifest depends_on. */
|
|
2403
|
-
private async cmdServiceUpManifest(
|
|
2534
|
+
private async cmdServiceUpManifest(
|
|
2535
|
+
addresses: string[],
|
|
2536
|
+
dryRun = false,
|
|
2537
|
+
): Promise<void> {
|
|
2404
2538
|
// Resolve targets BEFORE the secrets preflight — otherwise running
|
|
2405
2539
|
// `den up` from a non-service dir (e.g. the den repo root) triggers
|
|
2406
2540
|
// an interactive sign-in just to print "nothing to do".
|
|
@@ -2427,9 +2561,10 @@ class DenCLI {
|
|
|
2427
2561
|
}
|
|
2428
2562
|
return;
|
|
2429
2563
|
}
|
|
2430
|
-
preflightSecrets();
|
|
2564
|
+
if (!dryRun) preflightSecrets();
|
|
2431
2565
|
await this.runServicesCommand(targets, "up", undefined, {
|
|
2432
2566
|
named: addresses,
|
|
2567
|
+
dryRun,
|
|
2433
2568
|
});
|
|
2434
2569
|
}
|
|
2435
2570
|
|
|
@@ -2438,9 +2573,10 @@ class DenCLI {
|
|
|
2438
2573
|
// never be read as down's own flags (`den down -- -a` must NOT tear
|
|
2439
2574
|
// down the whole workspace).
|
|
2440
2575
|
const terminator = args.indexOf("--");
|
|
2441
|
-
const { app, rest
|
|
2576
|
+
const { app, rest } = takeAppFlag(
|
|
2442
2577
|
terminator === -1 ? args : args.slice(0, terminator),
|
|
2443
2578
|
);
|
|
2579
|
+
const ownArgs = canonicalTargets(rest);
|
|
2444
2580
|
const dryRun = ownArgs.includes("--dry-run") || ownArgs.includes("-n");
|
|
2445
2581
|
const force = ownArgs.includes("--force") || ownArgs.includes("-f");
|
|
2446
2582
|
if (app) {
|
|
@@ -2482,7 +2618,12 @@ class DenCLI {
|
|
|
2482
2618
|
const seed = forceManifest ? undefined : this.derivedSeed(seedArg);
|
|
2483
2619
|
|
|
2484
2620
|
if (seed) {
|
|
2485
|
-
await this.runDerivedDown(
|
|
2621
|
+
await this.runDerivedDown(
|
|
2622
|
+
seed,
|
|
2623
|
+
dryRun,
|
|
2624
|
+
force,
|
|
2625
|
+
ownArgs.includes("--with-deps"),
|
|
2626
|
+
);
|
|
2486
2627
|
return;
|
|
2487
2628
|
}
|
|
2488
2629
|
const addresses = ownArgs.filter((a) => !a.startsWith("-"));
|
|
@@ -2493,27 +2634,30 @@ class DenCLI {
|
|
|
2493
2634
|
await this.runServicesCommand(targets, "down", undefined, {
|
|
2494
2635
|
force,
|
|
2495
2636
|
named: addresses,
|
|
2637
|
+
dryRun,
|
|
2496
2638
|
});
|
|
2497
2639
|
}
|
|
2498
2640
|
|
|
2499
2641
|
private async cmdServiceRestart(argv: string[]): Promise<void> {
|
|
2500
2642
|
const { app, rest: args } = takeAppFlag(argv);
|
|
2501
|
-
|
|
2643
|
+
const dryRun = args.includes("--dry-run") || args.includes("-n");
|
|
2644
|
+
if (!dryRun) preflightSecrets();
|
|
2502
2645
|
if (app) {
|
|
2503
2646
|
const order =
|
|
2504
2647
|
this.allWorkspaceOrder(focusedAppRepos(app))?.rawOrder ?? [];
|
|
2505
|
-
await this.runServicesCommand(order, "restart");
|
|
2648
|
+
await this.runServicesCommand(order, "restart", undefined, { dryRun });
|
|
2506
2649
|
return;
|
|
2507
2650
|
}
|
|
2508
2651
|
// Drop flag tokens so a bare `den restart --foo` doesn't treat the flag
|
|
2509
2652
|
// as a service address (matches cmdServiceUp/cmdServiceDown).
|
|
2510
|
-
const filtered = args.filter((a) => !a.startsWith("-"));
|
|
2653
|
+
const filtered = canonicalTargets(args.filter((a) => !a.startsWith("-")));
|
|
2511
2654
|
const addresses =
|
|
2512
2655
|
filtered.length > 0
|
|
2513
2656
|
? filtered
|
|
2514
2657
|
: this.resolveNoArgServiceTargets("restart");
|
|
2515
2658
|
await this.runServicesCommand(addresses, "restart", undefined, {
|
|
2516
2659
|
named: filtered,
|
|
2660
|
+
dryRun,
|
|
2517
2661
|
});
|
|
2518
2662
|
}
|
|
2519
2663
|
|
|
@@ -2622,7 +2766,7 @@ class DenCLI {
|
|
|
2622
2766
|
private async cmdLogs(args: string[]): Promise<void> {
|
|
2623
2767
|
await this.ensureWorkspaceSeeded();
|
|
2624
2768
|
const alias = loadAliasResolver();
|
|
2625
|
-
const target = resolveLogsTarget(args, alias);
|
|
2769
|
+
const target = resolveLogsTarget(canonicalTargets(args, 1), alias);
|
|
2626
2770
|
if (target.kind === "unknown-repo") {
|
|
2627
2771
|
console.log(chalk.red(`\n Unknown repo: ${target.arg}\n`));
|
|
2628
2772
|
process.exit(1);
|
|
@@ -3688,6 +3832,12 @@ class DenCLI {
|
|
|
3688
3832
|
console.log(
|
|
3689
3833
|
" mcp [--port <n>] MCP server (stdio) for agents, over the running web server",
|
|
3690
3834
|
);
|
|
3835
|
+
console.log(
|
|
3836
|
+
" mcp --call <tool> Call one MCP tool and print its result (--list: tool names)",
|
|
3837
|
+
);
|
|
3838
|
+
console.log(
|
|
3839
|
+
" service install Run the web UI as a launchd service (status, print, uninstall)",
|
|
3840
|
+
);
|
|
3691
3841
|
console.log();
|
|
3692
3842
|
console.log(" workspace list List workspaces");
|
|
3693
3843
|
console.log(
|
|
@@ -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}`));
|