@monster-orange/den 0.2.1 → 0.2.3
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 +129 -0
- package/README.md +1 -1
- package/docs/distributions.md +116 -1
- package/package.json +1 -1
- package/scripts/setup-secrets.ts +11 -0
- package/service/install.sh +14 -2
- package/src/shared/den-config.ts +69 -0
- package/src/shared/environment-manager/service-runner.ts +370 -6
- package/src/shared/manifest/schema.ts +15 -0
- package/src/shared/manifest/validate-layers.ts +40 -1
- package/src/shared/secrets/doppler.ts +10 -5
- package/src/shared/secrets/index.ts +1 -0
- package/src/shared/secrets/keys.ts +2 -0
- package/src/shared/secrets/secret-env.ts +149 -0
- package/src/shared/secrets/types.ts +10 -0
- package/src/shared/stacks/config.ts +6 -0
- package/src/shared/stacks/manifest-commands.ts +11 -1
- package/src/shared/stacks/runtime.ts +70 -2
- package/src/shared/stacks/types.ts +8 -0
- package/src/shared/types/index.ts +15 -0
- package/src/ui/cli/flag-parser.ts +2 -1
- package/src/ui/cli/index.ts +216 -34
- package/src/ui/cli/service-command.ts +131 -0
- package/src/ui/cli/stack-command.ts +55 -4
- package/src/ui/mcp/connect.ts +3 -1
- package/src/ui/mcp/server.ts +82 -1
- package/src/ui/server/index.ts +3 -0
- package/src/ui/server/lifecycle-exec.ts +23 -0
- package/src/ui/server/workspace-routes.ts +32 -0
package/src/ui/cli/index.ts
CHANGED
|
@@ -96,7 +96,14 @@ 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
|
+
previewServiceSecretEnv,
|
|
103
|
+
prepareServiceSecretEnv,
|
|
104
|
+
resolveService,
|
|
105
|
+
type ServiceCommandPreview,
|
|
106
|
+
} from "../../shared/environment-manager/service-runner.js";
|
|
100
107
|
import { getGitHubCoords } from "../../shared/smoke/git-remote.js";
|
|
101
108
|
import {
|
|
102
109
|
getReposRoot,
|
|
@@ -157,6 +164,7 @@ import {
|
|
|
157
164
|
import { cleanupStaleTabs } from "../../shared/cli-tabs.js";
|
|
158
165
|
import { cmdConfig } from "./config-command.js";
|
|
159
166
|
import { cmdEdge } from "./edge-command.js";
|
|
167
|
+
import { cmdService } from "./service-command.js";
|
|
160
168
|
import { cmdStack } from "./stack-command.js";
|
|
161
169
|
import { cmdWeb, displayWebHelp } from "./web-command.js";
|
|
162
170
|
import { cmdGraph } from "./graph-command.js";
|
|
@@ -191,14 +199,15 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
|
|
|
191
199
|
down: {
|
|
192
200
|
usage: "down [repo] [flags]",
|
|
193
201
|
lines: [
|
|
194
|
-
"Stop the cwd (or named) repo
|
|
202
|
+
"Stop the cwd (or named) repo; --with-deps also stops what it sits on (its",
|
|
203
|
+
"derived closure, in reverse order), naming anything else that uses it.",
|
|
195
204
|
"-a/--all stops EVERY workspace system (reverse derived order, cwd-agnostic;",
|
|
196
205
|
"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)",
|
|
206
|
+
"Flags: -a/--all · -A/--app <app> · --with-deps · -n/--dry-run (print order, stop nothing) · --manifest (legacy depends_on)",
|
|
198
207
|
],
|
|
199
208
|
},
|
|
200
209
|
restart: {
|
|
201
|
-
usage: "restart [service...] [-A <app>]",
|
|
210
|
+
usage: "restart [service...] [-A <app>] [-n|--dry-run]",
|
|
202
211
|
lines: [
|
|
203
212
|
`Restart the given services, or every repo in an application (\`${product().cli} apps\`).`,
|
|
204
213
|
],
|
|
@@ -301,10 +310,18 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
|
|
|
301
310
|
],
|
|
302
311
|
},
|
|
303
312
|
mcp: {
|
|
304
|
-
usage: "mcp [--port <web port>]",
|
|
313
|
+
usage: "mcp [--port <web port>] [--list | --call <tool> ['<json args>']]",
|
|
305
314
|
lines: [
|
|
306
315
|
"An MCP server over stdio for agents: a client of the running web server",
|
|
307
316
|
`(\`${product().cli} web up\`). Read tools always; actions only as named in mcp.actions.`,
|
|
317
|
+
"--list prints the tool names; --call runs one tool and prints its result.",
|
|
318
|
+
],
|
|
319
|
+
},
|
|
320
|
+
service: {
|
|
321
|
+
usage: "service [install|status|print|uninstall]",
|
|
322
|
+
lines: [
|
|
323
|
+
"The web UI as a macOS launchd service, started at login. Label and logs:",
|
|
324
|
+
"config web.service { label, logs }.",
|
|
308
325
|
],
|
|
309
326
|
},
|
|
310
327
|
"local-dev": {
|
|
@@ -363,6 +380,7 @@ const TELEMETRY_COMMANDS = new Set<string>([
|
|
|
363
380
|
"apps",
|
|
364
381
|
"applications",
|
|
365
382
|
"mcp",
|
|
383
|
+
"service",
|
|
366
384
|
]);
|
|
367
385
|
|
|
368
386
|
/** Map a raw command token to a PII-safe, registered telemetry command name. */
|
|
@@ -380,6 +398,20 @@ function telemetryReason(error: unknown): string {
|
|
|
380
398
|
return name && name !== "Object" ? name : "Error";
|
|
381
399
|
}
|
|
382
400
|
|
|
401
|
+
/**
|
|
402
|
+
* What was typed, with each service alias (a manifest service key or a
|
|
403
|
+
* compose stack name) mapped to its service's address, so the derived graph,
|
|
404
|
+
* ownership and the daemon registry all see one name. Flags pass through; an
|
|
405
|
+
* ambiguous alias throws, naming the candidates. `limit` maps only the first
|
|
406
|
+
* n positional args.
|
|
407
|
+
*/
|
|
408
|
+
function canonicalTargets(args: string[], limit = Infinity): string[] {
|
|
409
|
+
let seen = 0;
|
|
410
|
+
return args.map((a) =>
|
|
411
|
+
a.startsWith("-") || seen++ >= limit ? a : canonicalServiceAddress(a),
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
383
415
|
/** Print one manifest's validation: a status line, then each issue. */
|
|
384
416
|
function printValidation(
|
|
385
417
|
label: string,
|
|
@@ -685,6 +717,10 @@ class DenCLI {
|
|
|
685
717
|
await cmdEdge(args.slice(1));
|
|
686
718
|
break;
|
|
687
719
|
|
|
720
|
+
case "service":
|
|
721
|
+
process.exitCode = await cmdService(args.slice(1));
|
|
722
|
+
break;
|
|
723
|
+
|
|
688
724
|
case "stack":
|
|
689
725
|
await cmdStack(args.slice(1));
|
|
690
726
|
break;
|
|
@@ -1318,6 +1354,11 @@ class DenCLI {
|
|
|
1318
1354
|
* as part of a workspace or app run is said and skipped.
|
|
1319
1355
|
*/
|
|
1320
1356
|
named?: readonly string[];
|
|
1357
|
+
/**
|
|
1358
|
+
* `-n`/`--dry-run` (and `--plan` on this path): print what would run,
|
|
1359
|
+
* in order, and run nothing. Read-only on every path.
|
|
1360
|
+
*/
|
|
1361
|
+
dryRun?: boolean;
|
|
1321
1362
|
},
|
|
1322
1363
|
): Promise<void> {
|
|
1323
1364
|
// How long to show startup output before handing control back to the menu
|
|
@@ -1328,6 +1369,22 @@ class DenCLI {
|
|
|
1328
1369
|
getFocusedWorkspaceName() ??
|
|
1329
1370
|
undefined;
|
|
1330
1371
|
const force = opts?.force ?? false;
|
|
1372
|
+
// R13: why a remote service is not run here, or null.
|
|
1373
|
+
const remoteRefusal = (address: string): string | null => {
|
|
1374
|
+
const placed = resolveService(address, workspace);
|
|
1375
|
+
return placed
|
|
1376
|
+
? placementRefusal(
|
|
1377
|
+
address,
|
|
1378
|
+
servicePlacement(
|
|
1379
|
+
readManifest(placed.repoPath),
|
|
1380
|
+
placed.subsystem,
|
|
1381
|
+
"local",
|
|
1382
|
+
),
|
|
1383
|
+
(e) => isProductionEnv(e),
|
|
1384
|
+
command,
|
|
1385
|
+
)
|
|
1386
|
+
: null;
|
|
1387
|
+
};
|
|
1331
1388
|
|
|
1332
1389
|
// Resolve dependency order. Derived mode supplies the order directly;
|
|
1333
1390
|
// otherwise topo-sort manifest depends_on (`down` → reverse).
|
|
@@ -1341,8 +1398,17 @@ class DenCLI {
|
|
|
1341
1398
|
const startupOrder = ws
|
|
1342
1399
|
? resolveStartupOrder(addresses, catalog, ws.config, ws.name)
|
|
1343
1400
|
: addresses;
|
|
1401
|
+
// Only up pulls in dependencies (a service needs them to start).
|
|
1402
|
+
// Down and restart act on what was named: its dependencies may be
|
|
1403
|
+
// serving other running services.
|
|
1404
|
+
const namedOnly = (): string[] => {
|
|
1405
|
+
const kept = startupOrder.filter((a) => addresses.includes(a));
|
|
1406
|
+
return [...kept, ...addresses.filter((a) => !kept.includes(a))];
|
|
1407
|
+
};
|
|
1344
1408
|
if (command === "down") {
|
|
1345
|
-
ordered =
|
|
1409
|
+
ordered = namedOnly().reverse();
|
|
1410
|
+
} else if (command !== "up") {
|
|
1411
|
+
ordered = namedOnly();
|
|
1346
1412
|
} else {
|
|
1347
1413
|
ordered = startupOrder;
|
|
1348
1414
|
if (ordered.length > addresses.length) {
|
|
@@ -1358,6 +1424,56 @@ class DenCLI {
|
|
|
1358
1424
|
}
|
|
1359
1425
|
}
|
|
1360
1426
|
|
|
1427
|
+
if (opts?.dryRun) {
|
|
1428
|
+
// The same lookup a real run makes, so the dry run says what it does.
|
|
1429
|
+
const previews = ordered.map((a): ServiceCommandPreview => {
|
|
1430
|
+
const remote = remoteRefusal(a);
|
|
1431
|
+
if (!remote) return previewServiceCommand(a, command, workspace);
|
|
1432
|
+
// As the loop below: a named remote target fails, any other is
|
|
1433
|
+
// said and skipped.
|
|
1434
|
+
return isNamedTarget(a, opts?.named ?? [])
|
|
1435
|
+
? { kind: "error", address: a, note: remote }
|
|
1436
|
+
: {
|
|
1437
|
+
kind: "nothing",
|
|
1438
|
+
address: a,
|
|
1439
|
+
note: `${a} (remote, skipped): ${remote}`,
|
|
1440
|
+
};
|
|
1441
|
+
});
|
|
1442
|
+
const doing =
|
|
1443
|
+
{ up: "starting", down: "stopping", restart: "restarting" }[command] ??
|
|
1444
|
+
`running "${command}" on`;
|
|
1445
|
+
const runs = previews
|
|
1446
|
+
.filter((p) => p.kind === "run" || p.kind === "via-repo")
|
|
1447
|
+
.map((p) => p.address);
|
|
1448
|
+
console.log(
|
|
1449
|
+
chalk.cyan(
|
|
1450
|
+
`\n Would ${command}: ${[...new Set(runs)].join(" → ") || "(nothing)"}`,
|
|
1451
|
+
),
|
|
1452
|
+
);
|
|
1453
|
+
for (const p of previews) {
|
|
1454
|
+
if (p.kind === "via-repo" || p.kind === "nothing")
|
|
1455
|
+
console.log(chalk.gray(` ${p.note}`));
|
|
1456
|
+
if (p.kind === "error") console.log(chalk.red(` ✗ ${p.note}`));
|
|
1457
|
+
}
|
|
1458
|
+
// R16: key names only. A dry run never asks the secrets provider.
|
|
1459
|
+
for (const a of new Set(runs)) {
|
|
1460
|
+
const sec = previewServiceSecretEnv(a, command, workspace);
|
|
1461
|
+
if (!sec) continue;
|
|
1462
|
+
const parts = [
|
|
1463
|
+
...(sec.service.length ? [sec.service.join(", ")] : []),
|
|
1464
|
+
...(sec.compose.length ? [`compose: ${sec.compose.join(", ")}`] : []),
|
|
1465
|
+
];
|
|
1466
|
+
console.log(
|
|
1467
|
+
chalk.gray(
|
|
1468
|
+
` ${sec.address}: would inject secret_env ${parts.join("; ")} (names only; not resolved in a dry run)`,
|
|
1469
|
+
),
|
|
1470
|
+
);
|
|
1471
|
+
}
|
|
1472
|
+
if (previews.some((p) => p.kind === "error")) process.exitCode = 1;
|
|
1473
|
+
console.log(chalk.gray(` (--dry-run: not ${doing} anything)\n`));
|
|
1474
|
+
return;
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1361
1477
|
// Build a catalog + workspace reference once per run for readiness lookups.
|
|
1362
1478
|
const catalog = readCatalog();
|
|
1363
1479
|
const ws = getActiveWorkspace();
|
|
@@ -1398,19 +1514,7 @@ class DenCLI {
|
|
|
1398
1514
|
}
|
|
1399
1515
|
// R13: a remote service is never started or stopped from here. Said,
|
|
1400
1516
|
// 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;
|
|
1517
|
+
const remote = remoteRefusal(address);
|
|
1414
1518
|
if (remote) {
|
|
1415
1519
|
if (isNamedTarget(address, opts?.named ?? [])) {
|
|
1416
1520
|
console.log(chalk.red(` ✗ ${remote}`));
|
|
@@ -1502,7 +1606,22 @@ class DenCLI {
|
|
|
1502
1606
|
}
|
|
1503
1607
|
}
|
|
1504
1608
|
|
|
1609
|
+
// R16: resolve the service's secret_env before anything spawns. A
|
|
1610
|
+
// missing key fails this target; nothing starts with a partial set.
|
|
1611
|
+
const secrets = await prepareServiceSecretEnv(
|
|
1612
|
+
address,
|
|
1613
|
+
command,
|
|
1614
|
+
workspace,
|
|
1615
|
+
);
|
|
1616
|
+
|
|
1505
1617
|
const started = await new Promise<boolean>((resolve) => {
|
|
1618
|
+
if (!secrets.ok) {
|
|
1619
|
+
console.log(chalk.red(` ✗ ${command} ${chalk.bold(address)}`));
|
|
1620
|
+
for (const l of secrets.message.split("\n"))
|
|
1621
|
+
console.log(chalk.gray(` ${l}`));
|
|
1622
|
+
resolve(false);
|
|
1623
|
+
return;
|
|
1624
|
+
}
|
|
1506
1625
|
const spinner = ora(`${command} ${chalk.bold(address)}...`).start();
|
|
1507
1626
|
let detachTimer: ReturnType<typeof setTimeout> | null = null;
|
|
1508
1627
|
let settled = false;
|
|
@@ -1584,6 +1703,7 @@ class DenCLI {
|
|
|
1584
1703
|
runtime,
|
|
1585
1704
|
workspace,
|
|
1586
1705
|
force: command === "down" ? force : false,
|
|
1706
|
+
secretEnv: secrets.secretEnv,
|
|
1587
1707
|
onLine: (line) => {
|
|
1588
1708
|
spinner.text = `${command} ${chalk.bold(address)}: ${chalk.gray(line.slice(0, 80))}`;
|
|
1589
1709
|
const trimmed = line.trim();
|
|
@@ -1629,6 +1749,9 @@ class DenCLI {
|
|
|
1629
1749
|
}
|
|
1630
1750
|
});
|
|
1631
1751
|
|
|
1752
|
+
// Any target that failed makes the whole command fail (scripts and
|
|
1753
|
+
// agents read the exit code).
|
|
1754
|
+
if (!started) process.exitCode = 1;
|
|
1632
1755
|
if (plan?.failFast && !started) {
|
|
1633
1756
|
console.log(
|
|
1634
1757
|
chalk.red(
|
|
@@ -1817,7 +1940,12 @@ class DenCLI {
|
|
|
1817
1940
|
return;
|
|
1818
1941
|
}
|
|
1819
1942
|
if (dryRun) {
|
|
1820
|
-
|
|
1943
|
+
await this.runServicesCommand(
|
|
1944
|
+
order,
|
|
1945
|
+
"up",
|
|
1946
|
+
{ order },
|
|
1947
|
+
{ named, dryRun: true },
|
|
1948
|
+
);
|
|
1821
1949
|
return;
|
|
1822
1950
|
}
|
|
1823
1951
|
// Check the secrets backend first so any first-clone `make setup` (and the up
|
|
@@ -1862,10 +1990,13 @@ class DenCLI {
|
|
|
1862
1990
|
seed: string,
|
|
1863
1991
|
dryRun: boolean,
|
|
1864
1992
|
force = false,
|
|
1993
|
+
withDeps = false,
|
|
1865
1994
|
): Promise<void> {
|
|
1866
1995
|
const full = deriveGraph({ scope: "all" });
|
|
1996
|
+
// Only the named repo, unless --with-deps: what it sits on may be
|
|
1997
|
+
// serving other running systems (the same rule as a named service).
|
|
1867
1998
|
const upOrder = this.runtimeOnly(
|
|
1868
|
-
deriveStartTargets(full, seed).order,
|
|
1999
|
+
withDeps ? deriveStartTargets(full, seed).order : [seed],
|
|
1869
2000
|
"stopped",
|
|
1870
2001
|
);
|
|
1871
2002
|
if (upOrder.length === 0) return;
|
|
@@ -1876,6 +2007,9 @@ class DenCLI {
|
|
|
1876
2007
|
// still running on them. Name them rather than letting them fall over
|
|
1877
2008
|
// silently.
|
|
1878
2009
|
const inClosure = new Set(order);
|
|
2010
|
+
// Only what really stops here puts dependents at risk: a remote service
|
|
2011
|
+
// is refused (R13), so it stops nothing.
|
|
2012
|
+
const stopsHere = new Set(order.filter((a) => !this.isRemote(a, "down")));
|
|
1879
2013
|
const atRisk = [
|
|
1880
2014
|
...new Set(
|
|
1881
2015
|
full.edges
|
|
@@ -1883,14 +2017,16 @@ class DenCLI {
|
|
|
1883
2017
|
(e) =>
|
|
1884
2018
|
e.kind !== "jwks" &&
|
|
1885
2019
|
e.ordering !== false &&
|
|
1886
|
-
|
|
2020
|
+
stopsHere.has(e.to) &&
|
|
1887
2021
|
!inClosure.has(e.from),
|
|
1888
2022
|
)
|
|
1889
2023
|
.map((e) => e.from),
|
|
1890
2024
|
),
|
|
1891
2025
|
].sort();
|
|
1892
2026
|
console.log(
|
|
1893
|
-
chalk.cyan(
|
|
2027
|
+
chalk.cyan(
|
|
2028
|
+
`\n Derived down for ${chalk.bold(seed)}${withDeps ? " (+ dependencies)" : ""}`,
|
|
2029
|
+
),
|
|
1894
2030
|
);
|
|
1895
2031
|
console.log(chalk.gray(` Order: ${order.join(" → ")}\n`));
|
|
1896
2032
|
if (atRisk.length > 0) {
|
|
@@ -1902,7 +2038,12 @@ class DenCLI {
|
|
|
1902
2038
|
);
|
|
1903
2039
|
}
|
|
1904
2040
|
if (dryRun) {
|
|
1905
|
-
|
|
2041
|
+
await this.runServicesCommand(
|
|
2042
|
+
order,
|
|
2043
|
+
"down",
|
|
2044
|
+
{ order },
|
|
2045
|
+
{ force, named: [seed], dryRun: true },
|
|
2046
|
+
);
|
|
1906
2047
|
return;
|
|
1907
2048
|
}
|
|
1908
2049
|
await this.runServicesCommand(
|
|
@@ -1913,6 +2054,24 @@ class DenCLI {
|
|
|
1913
2054
|
);
|
|
1914
2055
|
}
|
|
1915
2056
|
|
|
2057
|
+
/** Whether `address` runs remotely, so `command` is refused here (R13). */
|
|
2058
|
+
private isRemote(address: string, command: string): boolean {
|
|
2059
|
+
const placed = resolveService(address);
|
|
2060
|
+
return (
|
|
2061
|
+
!!placed &&
|
|
2062
|
+
placementRefusal(
|
|
2063
|
+
address,
|
|
2064
|
+
servicePlacement(
|
|
2065
|
+
readManifest(placed.repoPath),
|
|
2066
|
+
placed.subsystem,
|
|
2067
|
+
"local",
|
|
2068
|
+
),
|
|
2069
|
+
(e) => isProductionEnv(e),
|
|
2070
|
+
command,
|
|
2071
|
+
) !== null
|
|
2072
|
+
);
|
|
2073
|
+
}
|
|
2074
|
+
|
|
1916
2075
|
/** Workspace repos with no runtime (modules, libraries) → their kind. */
|
|
1917
2076
|
private noRuntimeRepos(): Map<string, RepoKind> {
|
|
1918
2077
|
const out = new Map<string, RepoKind>();
|
|
@@ -2337,9 +2496,10 @@ class DenCLI {
|
|
|
2337
2496
|
// Tokens after a `--` terminator belong to a child command — they must
|
|
2338
2497
|
// never be read as up's own flags (`den up -- -a` is NOT `up -a`).
|
|
2339
2498
|
const terminator = args.indexOf("--");
|
|
2340
|
-
const { app, rest
|
|
2499
|
+
const { app, rest } = takeAppFlag(
|
|
2341
2500
|
terminator === -1 ? args : args.slice(0, terminator),
|
|
2342
2501
|
);
|
|
2502
|
+
const ownArgs = canonicalTargets(rest);
|
|
2343
2503
|
const dryRun = ownArgs.includes("--dry-run") || ownArgs.includes("-n");
|
|
2344
2504
|
const plan = ownArgs.includes("--plan");
|
|
2345
2505
|
const pull = ownArgs.includes("--pull");
|
|
@@ -2396,11 +2556,17 @@ class DenCLI {
|
|
|
2396
2556
|
await this.runDerivedUp(seed, include, dryRun, plan, pull);
|
|
2397
2557
|
return;
|
|
2398
2558
|
}
|
|
2399
|
-
await this.cmdServiceUpManifest(
|
|
2559
|
+
await this.cmdServiceUpManifest(
|
|
2560
|
+
ownArgs.filter((a) => !a.startsWith("-")),
|
|
2561
|
+
dryRun || plan,
|
|
2562
|
+
);
|
|
2400
2563
|
}
|
|
2401
2564
|
|
|
2402
2565
|
/** Legacy: start the given services via manifest depends_on. */
|
|
2403
|
-
private async cmdServiceUpManifest(
|
|
2566
|
+
private async cmdServiceUpManifest(
|
|
2567
|
+
addresses: string[],
|
|
2568
|
+
dryRun = false,
|
|
2569
|
+
): Promise<void> {
|
|
2404
2570
|
// Resolve targets BEFORE the secrets preflight — otherwise running
|
|
2405
2571
|
// `den up` from a non-service dir (e.g. the den repo root) triggers
|
|
2406
2572
|
// an interactive sign-in just to print "nothing to do".
|
|
@@ -2427,9 +2593,10 @@ class DenCLI {
|
|
|
2427
2593
|
}
|
|
2428
2594
|
return;
|
|
2429
2595
|
}
|
|
2430
|
-
preflightSecrets();
|
|
2596
|
+
if (!dryRun) preflightSecrets();
|
|
2431
2597
|
await this.runServicesCommand(targets, "up", undefined, {
|
|
2432
2598
|
named: addresses,
|
|
2599
|
+
dryRun,
|
|
2433
2600
|
});
|
|
2434
2601
|
}
|
|
2435
2602
|
|
|
@@ -2438,9 +2605,10 @@ class DenCLI {
|
|
|
2438
2605
|
// never be read as down's own flags (`den down -- -a` must NOT tear
|
|
2439
2606
|
// down the whole workspace).
|
|
2440
2607
|
const terminator = args.indexOf("--");
|
|
2441
|
-
const { app, rest
|
|
2608
|
+
const { app, rest } = takeAppFlag(
|
|
2442
2609
|
terminator === -1 ? args : args.slice(0, terminator),
|
|
2443
2610
|
);
|
|
2611
|
+
const ownArgs = canonicalTargets(rest);
|
|
2444
2612
|
const dryRun = ownArgs.includes("--dry-run") || ownArgs.includes("-n");
|
|
2445
2613
|
const force = ownArgs.includes("--force") || ownArgs.includes("-f");
|
|
2446
2614
|
if (app) {
|
|
@@ -2482,7 +2650,12 @@ class DenCLI {
|
|
|
2482
2650
|
const seed = forceManifest ? undefined : this.derivedSeed(seedArg);
|
|
2483
2651
|
|
|
2484
2652
|
if (seed) {
|
|
2485
|
-
await this.runDerivedDown(
|
|
2653
|
+
await this.runDerivedDown(
|
|
2654
|
+
seed,
|
|
2655
|
+
dryRun,
|
|
2656
|
+
force,
|
|
2657
|
+
ownArgs.includes("--with-deps"),
|
|
2658
|
+
);
|
|
2486
2659
|
return;
|
|
2487
2660
|
}
|
|
2488
2661
|
const addresses = ownArgs.filter((a) => !a.startsWith("-"));
|
|
@@ -2493,27 +2666,30 @@ class DenCLI {
|
|
|
2493
2666
|
await this.runServicesCommand(targets, "down", undefined, {
|
|
2494
2667
|
force,
|
|
2495
2668
|
named: addresses,
|
|
2669
|
+
dryRun,
|
|
2496
2670
|
});
|
|
2497
2671
|
}
|
|
2498
2672
|
|
|
2499
2673
|
private async cmdServiceRestart(argv: string[]): Promise<void> {
|
|
2500
2674
|
const { app, rest: args } = takeAppFlag(argv);
|
|
2501
|
-
|
|
2675
|
+
const dryRun = args.includes("--dry-run") || args.includes("-n");
|
|
2676
|
+
if (!dryRun) preflightSecrets();
|
|
2502
2677
|
if (app) {
|
|
2503
2678
|
const order =
|
|
2504
2679
|
this.allWorkspaceOrder(focusedAppRepos(app))?.rawOrder ?? [];
|
|
2505
|
-
await this.runServicesCommand(order, "restart");
|
|
2680
|
+
await this.runServicesCommand(order, "restart", undefined, { dryRun });
|
|
2506
2681
|
return;
|
|
2507
2682
|
}
|
|
2508
2683
|
// Drop flag tokens so a bare `den restart --foo` doesn't treat the flag
|
|
2509
2684
|
// as a service address (matches cmdServiceUp/cmdServiceDown).
|
|
2510
|
-
const filtered = args.filter((a) => !a.startsWith("-"));
|
|
2685
|
+
const filtered = canonicalTargets(args.filter((a) => !a.startsWith("-")));
|
|
2511
2686
|
const addresses =
|
|
2512
2687
|
filtered.length > 0
|
|
2513
2688
|
? filtered
|
|
2514
2689
|
: this.resolveNoArgServiceTargets("restart");
|
|
2515
2690
|
await this.runServicesCommand(addresses, "restart", undefined, {
|
|
2516
2691
|
named: filtered,
|
|
2692
|
+
dryRun,
|
|
2517
2693
|
});
|
|
2518
2694
|
}
|
|
2519
2695
|
|
|
@@ -2622,7 +2798,7 @@ class DenCLI {
|
|
|
2622
2798
|
private async cmdLogs(args: string[]): Promise<void> {
|
|
2623
2799
|
await this.ensureWorkspaceSeeded();
|
|
2624
2800
|
const alias = loadAliasResolver();
|
|
2625
|
-
const target = resolveLogsTarget(args, alias);
|
|
2801
|
+
const target = resolveLogsTarget(canonicalTargets(args, 1), alias);
|
|
2626
2802
|
if (target.kind === "unknown-repo") {
|
|
2627
2803
|
console.log(chalk.red(`\n Unknown repo: ${target.arg}\n`));
|
|
2628
2804
|
process.exit(1);
|
|
@@ -3688,6 +3864,12 @@ class DenCLI {
|
|
|
3688
3864
|
console.log(
|
|
3689
3865
|
" mcp [--port <n>] MCP server (stdio) for agents, over the running web server",
|
|
3690
3866
|
);
|
|
3867
|
+
console.log(
|
|
3868
|
+
" mcp --call <tool> Call one MCP tool and print its result (--list: tool names)",
|
|
3869
|
+
);
|
|
3870
|
+
console.log(
|
|
3871
|
+
" service install Run the web UI as a launchd service (status, print, uninstall)",
|
|
3872
|
+
);
|
|
3691
3873
|
console.log();
|
|
3692
3874
|
console.log(" workspace list List workspaces");
|
|
3693
3875
|
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
|
+
}
|