@monster-orange/den 0.4.4 → 0.4.6
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 +243 -0
- package/README.md +24 -9
- package/docs/distributions.md +320 -26
- package/docs/reference.md +8 -3
- package/package.json +4 -3
- package/skills/install-den/SKILL.md +114 -0
- package/src/shared/agent-setup.ts +9 -0
- package/src/shared/auth/sudo-vault.ts +115 -11
- package/src/shared/cli-command.ts +186 -0
- package/src/shared/confirm-code.ts +34 -0
- package/src/shared/constants.ts +7 -0
- package/src/shared/den-config.ts +7 -0
- package/src/shared/dev-metrics/emit.ts +59 -2
- package/src/shared/edge/config.ts +39 -0
- package/src/shared/edge/render.ts +92 -18
- package/src/shared/edge/runtime.ts +57 -1
- package/src/shared/edge/types.ts +11 -1
- package/src/shared/github-app.ts +94 -0
- package/src/shared/github-device.ts +286 -0
- package/src/shared/port-pick.ts +60 -0
- package/src/shared/secrets/onepassword.ts +27 -4
- package/src/shared/web-owner.ts +91 -0
- package/src/shared/web-session.ts +135 -1
- package/src/ui/cli/create-main.ts +1 -1
- package/src/ui/cli/create.ts +263 -30
- package/src/ui/cli/doctor-command.ts +67 -4
- package/src/ui/cli/doctor-onboard.ts +291 -0
- package/src/ui/cli/flag-parser.ts +5 -1
- package/src/ui/cli/index.ts +246 -4
- package/src/ui/cli/json-out.ts +49 -0
- package/src/ui/cli/mcp-connect.ts +179 -15
- package/src/ui/cli/repo-command.ts +490 -0
- package/src/ui/cli/repo-detect.ts +192 -0
- package/src/ui/cli/web-command.ts +513 -33
- package/src/ui/mcp/connect.ts +134 -0
- package/src/ui/server/device-flows.ts +274 -0
- package/src/ui/server/edge-exposure.ts +16 -4
- package/src/ui/server/github-routes.ts +321 -0
- package/src/ui/server/http-errors.ts +10 -0
- package/src/ui/server/index.ts +463 -27
- package/src/ui/server/login-waits.ts +203 -0
- package/src/ui/server/pair-requests.ts +237 -0
- package/src/ui/server/pair-routes.ts +196 -0
- package/src/ui/server/public/error-page.js +452 -40
- package/src/ui/server/public/error.html +59 -0
- package/src/ui/server/public/i18n/es.js +74 -8
- package/src/ui/server/public/i18n/ja.js +74 -8
- package/src/ui/server/public/local-dev.html +16 -4
- package/src/ui/server/public/qr.js +317 -0
- package/src/ui/server/public/settings-page.js +10 -3
- package/src/ui/server/public/settings-registry.js +392 -1
- package/src/ui/server/public/shell.js +123 -10
- package/src/ui/server/public/topbar.js +7 -3
- package/src/ui/server/public/workspace-picker.js +1 -1
- package/src/ui/server/request-guard.ts +103 -36
- package/src/ui/server/route-classes.ts +102 -2
- package/src/ui/server/ui-assets.ts +2 -0
package/src/ui/cli/index.ts
CHANGED
|
@@ -26,6 +26,7 @@ import { envSelector } from "../../shared/environment/env-selector.js";
|
|
|
26
26
|
import {
|
|
27
27
|
recordInvocation,
|
|
28
28
|
recordFailure,
|
|
29
|
+
shipFromDetachedSender,
|
|
29
30
|
startTimer,
|
|
30
31
|
} from "../../shared/dev-metrics/emit.js";
|
|
31
32
|
import type { Environment, Pillar } from "../../shared/types/index.js";
|
|
@@ -242,6 +243,8 @@ import {
|
|
|
242
243
|
import { DEN_BANNER_LINES, DEN_DIVIDER } from "../../shared/banner.js";
|
|
243
244
|
|
|
244
245
|
import { product } from "../../shared/product.js";
|
|
246
|
+
import { AGENT_SETUP_LINE } from "../../shared/agent-setup.js";
|
|
247
|
+
import { ExitRequested, printJson, quietStdout } from "./json-out.js";
|
|
245
248
|
const DEN_HELP_DESCRIPTION = ` ${product().title} - local-first test, service, and workspace orchestration`;
|
|
246
249
|
|
|
247
250
|
/**
|
|
@@ -360,6 +363,16 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
|
|
|
360
363
|
"-w writes that workspace (active or not) and leaves focus alone.",
|
|
361
364
|
],
|
|
362
365
|
},
|
|
366
|
+
repo: {
|
|
367
|
+
usage:
|
|
368
|
+
'repo add <path|git-url> [--sidecar] [-w <workspace>] [--name <repo>] [--run "<cmd>"] [--health <url>] [--json]',
|
|
369
|
+
lines: [
|
|
370
|
+
"Add a repo with no prompts, never writing into it. A repo with no",
|
|
371
|
+
"manifest (or --sidecar) gets a side-car manifest in manifests/, detected",
|
|
372
|
+
"from its Makefile, compose file or package.json scripts. Safe to re-run.",
|
|
373
|
+
"`{cli} repo add --help` says more.",
|
|
374
|
+
],
|
|
375
|
+
},
|
|
363
376
|
relocate: {
|
|
364
377
|
usage: "relocate <repo> <path> [--dry-run] [--force] [-w <workspace>]",
|
|
365
378
|
lines: [
|
|
@@ -465,8 +478,11 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
|
|
|
465
478
|
],
|
|
466
479
|
},
|
|
467
480
|
doctor: {
|
|
468
|
-
usage: "doctor [--probe]",
|
|
481
|
+
usage: "doctor [--probe] [--json]",
|
|
469
482
|
lines: [
|
|
483
|
+
"--json: one object, { ok, checks: [{ id, status: ok|warn|fail, detail, fix?, ask? }] }:",
|
|
484
|
+
"Node 22.13+, git, Docker (only when a registered repo needs it), the web",
|
|
485
|
+
"server and Den's MCP through it, then each strategy below. Exit 1 on a fail.",
|
|
470
486
|
"Each strategy kind (secrets, tls): the one selected, whether it was implicit,",
|
|
471
487
|
"and whether it can work here (a PATH check; nothing is run or prompted).",
|
|
472
488
|
"--probe: start a short probe session (through the session host) for a",
|
|
@@ -575,6 +591,7 @@ const TELEMETRY_COMMANDS = new Set<string>([
|
|
|
575
591
|
"service",
|
|
576
592
|
"theme",
|
|
577
593
|
"plugin",
|
|
594
|
+
"repo",
|
|
578
595
|
]);
|
|
579
596
|
|
|
580
597
|
/**
|
|
@@ -678,7 +695,18 @@ function printValidation(
|
|
|
678
695
|
}
|
|
679
696
|
}
|
|
680
697
|
|
|
698
|
+
/** What `up --json` collects while up runs (runServicesCommand fills it). */
|
|
699
|
+
interface UpReport {
|
|
700
|
+
started: string[];
|
|
701
|
+
failed: string[];
|
|
702
|
+
alreadyRunning: string[];
|
|
703
|
+
skipped: string[];
|
|
704
|
+
}
|
|
705
|
+
|
|
681
706
|
class DenCLI {
|
|
707
|
+
/** Set while `up --json` runs. */
|
|
708
|
+
private upReport: UpReport | null = null;
|
|
709
|
+
|
|
682
710
|
private static readonly serviceCommands = new Set([
|
|
683
711
|
"up",
|
|
684
712
|
"down",
|
|
@@ -715,6 +743,8 @@ class DenCLI {
|
|
|
715
743
|
// synchronously. `telCmd` is always a REGISTERED command name (never raw
|
|
716
744
|
// argv — PII); it is refined once the args parse successfully.
|
|
717
745
|
const stopTimer = startTimer();
|
|
746
|
+
// The exit hook can't await a send; hand it to a detached sender.
|
|
747
|
+
shipFromDetachedSender();
|
|
718
748
|
let telCmd = telemetryCommandName(args.find((a) => !a.startsWith("-")));
|
|
719
749
|
process.once("exit", (code) => {
|
|
720
750
|
recordInvocation({
|
|
@@ -868,13 +898,31 @@ class DenCLI {
|
|
|
868
898
|
process.exit(0);
|
|
869
899
|
break;
|
|
870
900
|
|
|
871
|
-
case "web":
|
|
901
|
+
case "web": {
|
|
902
|
+
const sub = args[1];
|
|
903
|
+
if (
|
|
904
|
+
args.includes("--json") &&
|
|
905
|
+
(sub === undefined ||
|
|
906
|
+
sub.startsWith("-") ||
|
|
907
|
+
sub === "up" ||
|
|
908
|
+
sub === "restart")
|
|
909
|
+
) {
|
|
910
|
+
process.exitCode = await this.cmdWebUpJson(
|
|
911
|
+
args.slice(1).filter((a) => a !== "--json"),
|
|
912
|
+
);
|
|
913
|
+
break;
|
|
914
|
+
}
|
|
872
915
|
await cmdWeb(args.slice(1));
|
|
873
916
|
break;
|
|
917
|
+
}
|
|
874
918
|
|
|
875
919
|
// ── Environment manager commands ──────────────────────────────────
|
|
876
920
|
case "up":
|
|
877
|
-
|
|
921
|
+
if (args.slice(1).includes("--json"))
|
|
922
|
+
process.exitCode = await this.cmdServiceUpJson(
|
|
923
|
+
args.slice(1).filter((a) => a !== "--json"),
|
|
924
|
+
);
|
|
925
|
+
else await this.cmdServiceUp(args.slice(1));
|
|
878
926
|
break;
|
|
879
927
|
|
|
880
928
|
case "down":
|
|
@@ -906,6 +954,12 @@ class DenCLI {
|
|
|
906
954
|
await this.cmdRegister(args.slice(1));
|
|
907
955
|
break;
|
|
908
956
|
|
|
957
|
+
case "repo": {
|
|
958
|
+
const { cmdRepo } = await import("./repo-command.js");
|
|
959
|
+
process.exitCode = await cmdRepo(args.slice(1), validateManifest);
|
|
960
|
+
break;
|
|
961
|
+
}
|
|
962
|
+
|
|
909
963
|
case "relocate":
|
|
910
964
|
await this.cmdRelocate(args.slice(1));
|
|
911
965
|
break;
|
|
@@ -967,7 +1021,7 @@ class DenCLI {
|
|
|
967
1021
|
case "create": {
|
|
968
1022
|
// bin/den routes `create` to its own entry (it needs no den.yml);
|
|
969
1023
|
// this covers the CLI run directly from a checkout.
|
|
970
|
-
const code = runCreate(args.slice(1), getInvokeCwd());
|
|
1024
|
+
const code = await runCreate(args.slice(1), getInvokeCwd());
|
|
971
1025
|
if (code !== 0) process.exit(code);
|
|
972
1026
|
break;
|
|
973
1027
|
}
|
|
@@ -1829,8 +1883,10 @@ class DenCLI {
|
|
|
1829
1883
|
if (isNamedTarget(address, opts?.named ?? [])) {
|
|
1830
1884
|
console.log(chalk.red(` ✗ ${remote}`));
|
|
1831
1885
|
process.exitCode = 1;
|
|
1886
|
+
this.upReport?.failed.push(address);
|
|
1832
1887
|
} else {
|
|
1833
1888
|
console.log(chalk.gray(` • ${remote}`));
|
|
1889
|
+
this.upReport?.skipped.push(address);
|
|
1834
1890
|
}
|
|
1835
1891
|
continue;
|
|
1836
1892
|
}
|
|
@@ -1854,6 +1910,7 @@ class DenCLI {
|
|
|
1854
1910
|
);
|
|
1855
1911
|
// A dependency only warns; the service you named failed.
|
|
1856
1912
|
if (isNamedTarget(address, opts?.named ?? [])) process.exitCode = 1;
|
|
1913
|
+
this.upReport?.failed.push(address);
|
|
1857
1914
|
continue;
|
|
1858
1915
|
}
|
|
1859
1916
|
if (health.health === "ok" && claim !== "conflict") {
|
|
@@ -1870,6 +1927,7 @@ class DenCLI {
|
|
|
1870
1927
|
` • ${chalk.bold(address)} already healthy — skipping dependency up${suffix}`,
|
|
1871
1928
|
),
|
|
1872
1929
|
);
|
|
1930
|
+
if (command === "up") this.upReport?.alreadyRunning.push(address);
|
|
1873
1931
|
continue;
|
|
1874
1932
|
}
|
|
1875
1933
|
}
|
|
@@ -2103,6 +2161,8 @@ class DenCLI {
|
|
|
2103
2161
|
// Any target that failed makes the whole command fail (scripts and
|
|
2104
2162
|
// agents read the exit code).
|
|
2105
2163
|
if (!started) process.exitCode = 1;
|
|
2164
|
+
if (command === "up")
|
|
2165
|
+
this.upReport?.[started ? "started" : "failed"].push(address);
|
|
2106
2166
|
if (plan?.failFast && !started) {
|
|
2107
2167
|
console.log(
|
|
2108
2168
|
chalk.red(
|
|
@@ -2746,6 +2806,10 @@ class DenCLI {
|
|
|
2746
2806
|
"act in that (active) workspace instead of the focused one; focus stays",
|
|
2747
2807
|
);
|
|
2748
2808
|
row("--skip-secrets", "skip the secrets-backend sign-in preflight");
|
|
2809
|
+
row(
|
|
2810
|
+
"--json",
|
|
2811
|
+
"log on stderr; stdout: { ok, services: [{ address, result, health }], fix? }",
|
|
2812
|
+
);
|
|
2749
2813
|
row("-h, --help", "show this help");
|
|
2750
2814
|
console.log();
|
|
2751
2815
|
}
|
|
@@ -3099,6 +3163,177 @@ class DenCLI {
|
|
|
3099
3163
|
);
|
|
3100
3164
|
}
|
|
3101
3165
|
|
|
3166
|
+
/**
|
|
3167
|
+
* `up --json` (ON-2): up as it always runs, its log on stderr, then one
|
|
3168
|
+
* JSON object on stdout: each service it touched, what happened to it
|
|
3169
|
+
* (started, already-running, failed, skipped) and its health once started
|
|
3170
|
+
* (a started service with a health check is waited on, up to 30s). Safe to
|
|
3171
|
+
* re-run: a healthy service is already-running. Every failure names a fix.
|
|
3172
|
+
*/
|
|
3173
|
+
private async cmdServiceUpJson(args: string[]): Promise<number> {
|
|
3174
|
+
const report: UpReport = {
|
|
3175
|
+
started: [],
|
|
3176
|
+
failed: [],
|
|
3177
|
+
alreadyRunning: [],
|
|
3178
|
+
skipped: [],
|
|
3179
|
+
};
|
|
3180
|
+
const bin = fs.existsSync(
|
|
3181
|
+
nodePath.join(getDistRoot(), "bin", product().cli),
|
|
3182
|
+
)
|
|
3183
|
+
? `./bin/${product().cli}`
|
|
3184
|
+
: product().cli;
|
|
3185
|
+
const before = process.exitCode;
|
|
3186
|
+
process.exitCode = undefined;
|
|
3187
|
+
let error: string | undefined;
|
|
3188
|
+
this.upReport = report;
|
|
3189
|
+
try {
|
|
3190
|
+
await quietStdout(() => this.cmdServiceUp(args));
|
|
3191
|
+
} catch (e) {
|
|
3192
|
+
if (!(e instanceof ExitRequested)) error = (e as Error).message;
|
|
3193
|
+
else if (e.code !== 0) process.exitCode = e.code;
|
|
3194
|
+
} finally {
|
|
3195
|
+
this.upReport = null;
|
|
3196
|
+
}
|
|
3197
|
+
const failedExit = process.exitCode !== undefined && process.exitCode !== 0;
|
|
3198
|
+
process.exitCode = before;
|
|
3199
|
+
const workspace =
|
|
3200
|
+
process.env.DEN_WORKSPACE_OVERRIDE ??
|
|
3201
|
+
getFocusedWorkspaceName() ??
|
|
3202
|
+
undefined;
|
|
3203
|
+
const result = (address: string): string =>
|
|
3204
|
+
report.failed.includes(address)
|
|
3205
|
+
? "failed"
|
|
3206
|
+
: report.started.includes(address)
|
|
3207
|
+
? "started"
|
|
3208
|
+
: report.alreadyRunning.includes(address)
|
|
3209
|
+
? "already-running"
|
|
3210
|
+
: "skipped";
|
|
3211
|
+
const touched = [
|
|
3212
|
+
...new Set([
|
|
3213
|
+
...report.started,
|
|
3214
|
+
...report.alreadyRunning,
|
|
3215
|
+
...report.failed,
|
|
3216
|
+
...report.skipped,
|
|
3217
|
+
]),
|
|
3218
|
+
];
|
|
3219
|
+
const services = await Promise.all(
|
|
3220
|
+
touched.map(async (address) => {
|
|
3221
|
+
const resolved = resolveService(address, workspace);
|
|
3222
|
+
let health = "unknown";
|
|
3223
|
+
let detail: string | undefined;
|
|
3224
|
+
if (resolved && result(address) !== "skipped") {
|
|
3225
|
+
const ready =
|
|
3226
|
+
result(address) === "started"
|
|
3227
|
+
? await waitForReady(resolved, "local", { timeoutMs: 30_000 })
|
|
3228
|
+
: null;
|
|
3229
|
+
const h = await checkServiceHealth(resolved, "local");
|
|
3230
|
+
health = h.health;
|
|
3231
|
+
detail = h.healthDetail ?? ready?.detail;
|
|
3232
|
+
}
|
|
3233
|
+
return {
|
|
3234
|
+
address,
|
|
3235
|
+
result: result(address),
|
|
3236
|
+
health,
|
|
3237
|
+
...(detail ? { detail } : {}),
|
|
3238
|
+
};
|
|
3239
|
+
}),
|
|
3240
|
+
);
|
|
3241
|
+
const unhealthy = services.filter(
|
|
3242
|
+
(s) => s.result !== "skipped" && s.health === "error",
|
|
3243
|
+
);
|
|
3244
|
+
const out: Record<string, unknown> = {
|
|
3245
|
+
ok:
|
|
3246
|
+
!error &&
|
|
3247
|
+
!failedExit &&
|
|
3248
|
+
report.failed.length === 0 &&
|
|
3249
|
+
unhealthy.length === 0 &&
|
|
3250
|
+
touched.length > 0,
|
|
3251
|
+
workspace: workspace ?? null,
|
|
3252
|
+
services,
|
|
3253
|
+
};
|
|
3254
|
+
if (error) {
|
|
3255
|
+
out.error = error;
|
|
3256
|
+
out.fix = `${bin} doctor --json`;
|
|
3257
|
+
} else if (report.failed.length > 0 || unhealthy.length > 0) {
|
|
3258
|
+
const names = [
|
|
3259
|
+
...new Set([...report.failed, ...unhealthy.map((s) => s.address)]),
|
|
3260
|
+
];
|
|
3261
|
+
out.error = `${names.join(", ")} did not start healthy`;
|
|
3262
|
+
out.fix = `${bin} logs ${names[0].split("/")[0]}`;
|
|
3263
|
+
} else if (failedExit) {
|
|
3264
|
+
out.error = "up failed (its log is on stderr)";
|
|
3265
|
+
out.fix = `${bin} up ${args.join(" ")} --plan`.replace(/\s+/g, " ");
|
|
3266
|
+
} else if (touched.length === 0) {
|
|
3267
|
+
out.error =
|
|
3268
|
+
"nothing was started: no repo named, and the folder up ran in is not a repo in the workspace";
|
|
3269
|
+
out.fix = `${bin} up -a --json`;
|
|
3270
|
+
out.ask = "Which repo should start? (its name, as `repo add` printed it)";
|
|
3271
|
+
}
|
|
3272
|
+
out.next = out.ok
|
|
3273
|
+
? [`${bin} web up --no-open --json`, `${bin} doctor --json`]
|
|
3274
|
+
: [];
|
|
3275
|
+
printJson(out);
|
|
3276
|
+
return out.ok ? 0 : 1;
|
|
3277
|
+
}
|
|
3278
|
+
|
|
3279
|
+
/**
|
|
3280
|
+
* `web up|restart --json` (ON-2): the web server started as usual (its
|
|
3281
|
+
* log on stderr), then `{ ok, url, port, version? }` once it answers
|
|
3282
|
+
* (up to 30s), so an agent hands back the right URL.
|
|
3283
|
+
*/
|
|
3284
|
+
private async cmdWebUpJson(args: string[]): Promise<number> {
|
|
3285
|
+
const { parseWebCommandArgs } = await import("./web-command.js");
|
|
3286
|
+
const { probeWebServer } = await import("./mcp-connect.js");
|
|
3287
|
+
const bin = fs.existsSync(
|
|
3288
|
+
nodePath.join(getDistRoot(), "bin", product().cli),
|
|
3289
|
+
)
|
|
3290
|
+
? `./bin/${product().cli}`
|
|
3291
|
+
: product().cli;
|
|
3292
|
+
let port: number;
|
|
3293
|
+
try {
|
|
3294
|
+
port = parseWebCommandArgs(args).port;
|
|
3295
|
+
} catch (e) {
|
|
3296
|
+
printJson({
|
|
3297
|
+
ok: false,
|
|
3298
|
+
error: (e as Error).message,
|
|
3299
|
+
fix: `${bin} web up --no-open --json`,
|
|
3300
|
+
});
|
|
3301
|
+
return 2;
|
|
3302
|
+
}
|
|
3303
|
+
const url = `http://localhost:${port}`;
|
|
3304
|
+
let error: string | undefined;
|
|
3305
|
+
try {
|
|
3306
|
+
await quietStdout(() => cmdWeb(args));
|
|
3307
|
+
} catch (e) {
|
|
3308
|
+
if (!(e instanceof ExitRequested) || e.code !== 0)
|
|
3309
|
+
error = (e as Error).message;
|
|
3310
|
+
}
|
|
3311
|
+
let probe = await probeWebServer(url);
|
|
3312
|
+
const deadline = Date.now() + 30_000;
|
|
3313
|
+
while (!error && probe.kind !== "up" && probe.kind !== "foreign") {
|
|
3314
|
+
if (Date.now() > deadline) break;
|
|
3315
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
3316
|
+
probe = await probeWebServer(url);
|
|
3317
|
+
}
|
|
3318
|
+
const out: Record<string, unknown> = {
|
|
3319
|
+
ok: probe.kind === "up",
|
|
3320
|
+
url,
|
|
3321
|
+
port,
|
|
3322
|
+
...(probe.kind === "up" && probe.version
|
|
3323
|
+
? { version: probe.version }
|
|
3324
|
+
: {}),
|
|
3325
|
+
};
|
|
3326
|
+
if (probe.kind === "foreign") {
|
|
3327
|
+
out.error = `${probe.who} answers at ${url}, not this distribution's web server`;
|
|
3328
|
+
out.ask = `Another Den distribution uses port ${port}. Which port should ${product().title} use? (web.port in config/local/user.yml)`;
|
|
3329
|
+
} else if (probe.kind !== "up") {
|
|
3330
|
+
out.error = error ?? `the web server is not answering at ${url}`;
|
|
3331
|
+
out.fix = `${bin} web restart --no-open --json`;
|
|
3332
|
+
}
|
|
3333
|
+
printJson(out);
|
|
3334
|
+
return out.ok ? 0 : 1;
|
|
3335
|
+
}
|
|
3336
|
+
|
|
3102
3337
|
/** Legacy: start the given services via manifest depends_on. */
|
|
3103
3338
|
private async cmdServiceUpManifest(
|
|
3104
3339
|
addresses: string[],
|
|
@@ -4880,6 +5115,10 @@ class DenCLI {
|
|
|
4880
5115
|
*/
|
|
4881
5116
|
private displayHelp(): void {
|
|
4882
5117
|
this.displayHelpBanner();
|
|
5118
|
+
// ON-5: the line a developer pastes into their agent.
|
|
5119
|
+
console.log(`Set up by your agent: paste this into it:`);
|
|
5120
|
+
console.log(` ${AGENT_SETUP_LINE}`);
|
|
5121
|
+
console.log();
|
|
4883
5122
|
console.log("Usage:");
|
|
4884
5123
|
console.log(` ${product().cli} [command] [options]`);
|
|
4885
5124
|
console.log("\nEnvironment commands:");
|
|
@@ -4917,6 +5156,9 @@ class DenCLI {
|
|
|
4917
5156
|
console.log(
|
|
4918
5157
|
" register [path] Register a repo in the service catalog",
|
|
4919
5158
|
);
|
|
5159
|
+
console.log(
|
|
5160
|
+
" repo add <path|url> Add a repo with no prompts, never changing it (--sidecar, --json)",
|
|
5161
|
+
);
|
|
4920
5162
|
console.log(
|
|
4921
5163
|
" relocate <repo> <path> Point a moved checkout's workspace entries at its new path",
|
|
4922
5164
|
);
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `--json` for setup commands an agent drives (ON-2): stdout carries exactly
|
|
3
|
+
* one JSON object, and everything a command would print for a person goes
|
|
4
|
+
* to stderr instead, so an agent can parse stdout and still show the log.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Print one JSON object on stdout (one line, newline-terminated). */
|
|
8
|
+
export function printJson(value: unknown): void {
|
|
9
|
+
process.stdout.write(`${JSON.stringify(value)}\n`);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Thrown in place of process.exit while quietStdout runs. */
|
|
13
|
+
export class ExitRequested extends Error {
|
|
14
|
+
constructor(readonly code: number) {
|
|
15
|
+
super(`exit ${code}`);
|
|
16
|
+
this.name = "ExitRequested";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Run `fn` with console.log/info and process.stdout.write sent to stderr,
|
|
22
|
+
* and process.exit turned into a thrown ExitRequested (so the caller still
|
|
23
|
+
* prints its JSON). Everything is restored afterwards.
|
|
24
|
+
*/
|
|
25
|
+
export async function quietStdout<T>(fn: () => Promise<T>): Promise<T> {
|
|
26
|
+
const log = console.log;
|
|
27
|
+
const info = console.info;
|
|
28
|
+
const write = process.stdout.write;
|
|
29
|
+
const exit = process.exit;
|
|
30
|
+
const toErr = (...a: unknown[]) => console.error(...a);
|
|
31
|
+
console.log = toErr;
|
|
32
|
+
console.info = toErr;
|
|
33
|
+
process.stdout.write = ((chunk: unknown, ...rest: unknown[]) =>
|
|
34
|
+
(process.stderr.write as (...x: unknown[]) => boolean)(
|
|
35
|
+
chunk,
|
|
36
|
+
...rest,
|
|
37
|
+
)) as typeof process.stdout.write;
|
|
38
|
+
process.exit = ((code?: number) => {
|
|
39
|
+
throw new ExitRequested(code ?? 0);
|
|
40
|
+
}) as typeof process.exit;
|
|
41
|
+
try {
|
|
42
|
+
return await fn();
|
|
43
|
+
} finally {
|
|
44
|
+
console.log = log;
|
|
45
|
+
console.info = info;
|
|
46
|
+
process.stdout.write = write;
|
|
47
|
+
process.exit = exit;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -10,11 +10,15 @@ import * as path from "path";
|
|
|
10
10
|
import chalk from "chalk";
|
|
11
11
|
import {
|
|
12
12
|
MCP_CLIENTS,
|
|
13
|
+
RUNTIME_CLIENTS,
|
|
14
|
+
clientForRuntime,
|
|
13
15
|
findClient,
|
|
14
16
|
genericSnippet,
|
|
15
17
|
jsonHasEntry,
|
|
16
18
|
mergeJsonConfig,
|
|
19
|
+
mergeTomlConfig,
|
|
17
20
|
serverSpec,
|
|
21
|
+
tomlHasEntry,
|
|
18
22
|
type ConnectAction,
|
|
19
23
|
type ConnectEnv,
|
|
20
24
|
type McpClient,
|
|
@@ -33,6 +37,7 @@ import {
|
|
|
33
37
|
type OwnServer,
|
|
34
38
|
} from "./server-identity.js";
|
|
35
39
|
import { readDenConfig, readMcpConfig } from "../../shared/den-config.js";
|
|
40
|
+
import { printJson } from "./json-out.js";
|
|
36
41
|
|
|
37
42
|
export const MCP_CONNECT_SUBCOMMANDS = [
|
|
38
43
|
"connect",
|
|
@@ -77,15 +82,28 @@ interface Flags {
|
|
|
77
82
|
project: string | null;
|
|
78
83
|
port?: number;
|
|
79
84
|
dryRun: boolean;
|
|
85
|
+
json: boolean;
|
|
86
|
+
runtime?: string;
|
|
80
87
|
}
|
|
81
88
|
|
|
82
89
|
function parseFlags(argv: string[]): Flags {
|
|
83
|
-
const f: Flags = {
|
|
90
|
+
const f: Flags = {
|
|
91
|
+
clients: [],
|
|
92
|
+
all: false,
|
|
93
|
+
project: null,
|
|
94
|
+
dryRun: false,
|
|
95
|
+
json: false,
|
|
96
|
+
};
|
|
84
97
|
for (let i = 0; i < argv.length; i++) {
|
|
85
98
|
const a = argv[i];
|
|
86
99
|
if (a === "--all" || a === "-a") f.all = true;
|
|
87
100
|
else if (a === "--dry-run" || a === "-n") f.dryRun = true;
|
|
88
|
-
else if (a === "--
|
|
101
|
+
else if (a === "--json") f.json = true;
|
|
102
|
+
else if (a === "--runtime" || a.startsWith("--runtime=")) {
|
|
103
|
+
const v = a.includes("=") ? a.slice(a.indexOf("=") + 1) : argv[++i];
|
|
104
|
+
if (!v) throw new Error("--runtime needs a value");
|
|
105
|
+
f.runtime = v;
|
|
106
|
+
} else if (a === "--project" || a === "-p") {
|
|
89
107
|
const next = argv[i + 1];
|
|
90
108
|
if (next && !next.startsWith("-") && !findClient(next)) {
|
|
91
109
|
f.project = path.resolve(next);
|
|
@@ -150,19 +168,21 @@ function apply(
|
|
|
150
168
|
a: ConnectAction,
|
|
151
169
|
remove: boolean,
|
|
152
170
|
dryRun: boolean,
|
|
153
|
-
): { ok: boolean; said: string } {
|
|
154
|
-
if (a.kind === "json") {
|
|
171
|
+
): { ok: boolean; said: string; file?: string; changed?: boolean } {
|
|
172
|
+
if (a.kind === "json" || a.kind === "toml") {
|
|
155
173
|
const before = readText(a.file);
|
|
156
|
-
const after =
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
)
|
|
162
|
-
if (!dryRun) writeConfig(a.file, after);
|
|
174
|
+
const after =
|
|
175
|
+
a.kind === "json"
|
|
176
|
+
? mergeJsonConfig(before, a.key, a.name, remove ? null : a.entry)
|
|
177
|
+
: mergeTomlConfig(before, a.name, remove ? null : a.entry);
|
|
178
|
+
const changed = after !== (before ?? "");
|
|
179
|
+
// Unchanged is not rewritten: a re-run leaves the file (and its .bak) be.
|
|
180
|
+
if (!dryRun && changed) writeConfig(a.file, after);
|
|
163
181
|
return {
|
|
164
182
|
ok: true,
|
|
165
|
-
|
|
183
|
+
file: a.file,
|
|
184
|
+
changed,
|
|
185
|
+
said: `${dryRun ? "would write" : !changed ? "already in" : remove ? "removed from" : "wrote"} ${a.file}`,
|
|
166
186
|
};
|
|
167
187
|
}
|
|
168
188
|
if (remove) {
|
|
@@ -192,8 +212,11 @@ function status(
|
|
|
192
212
|
env: ConnectEnv,
|
|
193
213
|
): string {
|
|
194
214
|
const a = c.plan(s, scope, env);
|
|
195
|
-
if (a.kind === "json") {
|
|
196
|
-
const st =
|
|
215
|
+
if (a.kind === "json" || a.kind === "toml") {
|
|
216
|
+
const st =
|
|
217
|
+
a.kind === "json"
|
|
218
|
+
? jsonHasEntry(readText(a.file), a.key, a.name, a.entry)
|
|
219
|
+
: tomlHasEntry(readText(a.file), a.name, a.entry);
|
|
197
220
|
return st === "connected"
|
|
198
221
|
? chalk.green("connected")
|
|
199
222
|
: st === "different"
|
|
@@ -314,7 +337,8 @@ function usage(): string {
|
|
|
314
337
|
` ${cli} mcp clients which agents are installed and connected`,
|
|
315
338
|
` ${cli} mcp connect [<client>...|--all] register ${cli}'s MCP server (user scope)`,
|
|
316
339
|
` [--project [dir]] in a project's config instead (default: here)`,
|
|
317
|
-
` [--port <n>] [--dry-run]`,
|
|
340
|
+
` [--port <n>] [--dry-run] [--json]`,
|
|
341
|
+
` ${cli} mcp connect --runtime <runtime> by the agent's own name: ${Object.keys(RUNTIME_CLIENTS).join(", ")}`,
|
|
318
342
|
` ${cli} mcp disconnect <client>... [--project [dir]]`,
|
|
319
343
|
` ${cli} mcp config [--port <n>] JSON for any other MCP client`,
|
|
320
344
|
``,
|
|
@@ -332,9 +356,31 @@ export async function runMcpConnect(argv: string[]): Promise<number> {
|
|
|
332
356
|
try {
|
|
333
357
|
flags = parseFlags(rest);
|
|
334
358
|
} catch (e) {
|
|
359
|
+
if (rest.includes("--json")) {
|
|
360
|
+
printJson({
|
|
361
|
+
ok: false,
|
|
362
|
+
error: (e as Error).message,
|
|
363
|
+
fix: `${product().cli} mcp connect --runtime <${Object.keys(RUNTIME_CLIENTS).join("|")}> --json`,
|
|
364
|
+
});
|
|
365
|
+
return 1;
|
|
366
|
+
}
|
|
335
367
|
console.error(chalk.red(`${(e as Error).message}\n`) + usage());
|
|
336
368
|
return 1;
|
|
337
369
|
}
|
|
370
|
+
if (flags.json && (sub === "connect" || sub === "disconnect"))
|
|
371
|
+
return runMcpConnectJson(sub, flags);
|
|
372
|
+
if (flags.runtime) {
|
|
373
|
+
const c = clientForRuntime(flags.runtime);
|
|
374
|
+
if (!c) {
|
|
375
|
+
console.error(
|
|
376
|
+
chalk.red(
|
|
377
|
+
`unknown runtime: ${flags.runtime} (${Object.keys(RUNTIME_CLIENTS).join(", ")})\n`,
|
|
378
|
+
) + usage(),
|
|
379
|
+
);
|
|
380
|
+
return 1;
|
|
381
|
+
}
|
|
382
|
+
flags.clients.push(c.id);
|
|
383
|
+
}
|
|
338
384
|
const s = spec(flags.port);
|
|
339
385
|
const scope: Scope = flags.project ? "project" : "user";
|
|
340
386
|
const env = connectEnv(
|
|
@@ -441,3 +487,121 @@ export async function runMcpConnect(argv: string[]): Promise<number> {
|
|
|
441
487
|
}
|
|
442
488
|
return failed ? 1 : 0;
|
|
443
489
|
}
|
|
490
|
+
|
|
491
|
+
/** One client's result in `mcp connect --json`. */
|
|
492
|
+
export interface ConnectJsonClient {
|
|
493
|
+
id: string;
|
|
494
|
+
title: string;
|
|
495
|
+
ok: boolean;
|
|
496
|
+
said: string;
|
|
497
|
+
file?: string;
|
|
498
|
+
/** For a config file: whether this run changed it (false on a re-run). */
|
|
499
|
+
changed?: boolean;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* `mcp connect|disconnect … --json` (ON-2): one JSON object, `{ ok, server,
|
|
504
|
+
* clients, restart, web, fix? }`. Safe to re-run: a config file that
|
|
505
|
+
* already has the entry is left as it is (changed: false), and a client's
|
|
506
|
+
* own `mcp add` is preceded by its `mcp remove`.
|
|
507
|
+
*/
|
|
508
|
+
async function runMcpConnectJson(
|
|
509
|
+
sub: "connect" | "disconnect",
|
|
510
|
+
flags: Flags,
|
|
511
|
+
): Promise<number> {
|
|
512
|
+
const cli = product().cli;
|
|
513
|
+
const fixAll = `./bin/${cli} mcp connect --runtime <${Object.keys(RUNTIME_CLIENTS).join("|")}> --json`;
|
|
514
|
+
const fail = (error: string, extra: Record<string, unknown> = {}) => {
|
|
515
|
+
printJson({ ok: false, error, ...extra });
|
|
516
|
+
return 1;
|
|
517
|
+
};
|
|
518
|
+
let s: ServerSpec;
|
|
519
|
+
try {
|
|
520
|
+
s = spec(flags.port);
|
|
521
|
+
} catch (e) {
|
|
522
|
+
return fail((e as Error).message, {
|
|
523
|
+
ask: "Run this from your Den folder (the one with bin/ in it)?",
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
const scope: Scope = flags.project ? "project" : "user";
|
|
527
|
+
const env = connectEnv(
|
|
528
|
+
flags.project ?? process.env.DEN_INVOKE_CWD ?? process.cwd(),
|
|
529
|
+
);
|
|
530
|
+
const ids = [...flags.clients];
|
|
531
|
+
if (flags.runtime) {
|
|
532
|
+
const c = clientForRuntime(flags.runtime);
|
|
533
|
+
if (!c)
|
|
534
|
+
return fail(
|
|
535
|
+
`unknown runtime "${flags.runtime}": Den connects ${Object.keys(RUNTIME_CLIENTS).join(", ")}`,
|
|
536
|
+
{
|
|
537
|
+
fix: `./bin/${cli} mcp config`,
|
|
538
|
+
ask: "Your agent is not one Den knows. Add the JSON that `mcp config` prints to its MCP settings by hand?",
|
|
539
|
+
},
|
|
540
|
+
);
|
|
541
|
+
ids.push(c.id);
|
|
542
|
+
}
|
|
543
|
+
const unknown = ids.filter((id) => !findClient(id));
|
|
544
|
+
if (unknown.length)
|
|
545
|
+
return fail(`unknown client: ${unknown.join(", ")}`, { fix: fixAll });
|
|
546
|
+
const targets = flags.all
|
|
547
|
+
? MCP_CLIENTS.filter((c) => c.detect(env) && c.scopes.includes(scope))
|
|
548
|
+
: ids.map((id) => findClient(id)!);
|
|
549
|
+
if (targets.length === 0) return fail("no client named", { fix: fixAll });
|
|
550
|
+
|
|
551
|
+
const url = webUrl(flags.port);
|
|
552
|
+
const probe = await probeWebServer(url);
|
|
553
|
+
if (sub === "connect" && probe.kind === "foreign")
|
|
554
|
+
return fail(
|
|
555
|
+
`${probe.who} answers at ${url}, not this distribution's web server: agents would read it`,
|
|
556
|
+
{ ask: pickAnotherPortHint(cli) },
|
|
557
|
+
);
|
|
558
|
+
|
|
559
|
+
const clients: ConnectJsonClient[] = [];
|
|
560
|
+
for (const c of targets) {
|
|
561
|
+
if (!c.scopes.includes(scope)) {
|
|
562
|
+
clients.push({
|
|
563
|
+
id: c.id,
|
|
564
|
+
title: c.title,
|
|
565
|
+
ok: false,
|
|
566
|
+
said: `no ${scope} scope`,
|
|
567
|
+
});
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
try {
|
|
571
|
+
const r = apply(
|
|
572
|
+
c.plan(s, scope, env),
|
|
573
|
+
sub === "disconnect",
|
|
574
|
+
flags.dryRun,
|
|
575
|
+
);
|
|
576
|
+
clients.push({ id: c.id, title: c.title, ...r });
|
|
577
|
+
} catch (e) {
|
|
578
|
+
clients.push({
|
|
579
|
+
id: c.id,
|
|
580
|
+
title: c.title,
|
|
581
|
+
ok: false,
|
|
582
|
+
said: (e as Error).message,
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
const ok = clients.every((c) => c.ok);
|
|
587
|
+
const failed = clients.find((c) => !c.ok);
|
|
588
|
+
printJson({
|
|
589
|
+
ok,
|
|
590
|
+
server: { name: s.name, command: s.command, args: s.args },
|
|
591
|
+
clients,
|
|
592
|
+
dryRun: flags.dryRun,
|
|
593
|
+
// Most agents load a new MCP server only when they start.
|
|
594
|
+
restart: sub === "connect" && !flags.dryRun && ok,
|
|
595
|
+
web: { url, up: probe.kind === "up" },
|
|
596
|
+
...(failed
|
|
597
|
+
? {
|
|
598
|
+
error: `${failed.title}: ${failed.said}`,
|
|
599
|
+
fix: `./bin/${cli} mcp config`,
|
|
600
|
+
ask: `Add Den's MCP server to ${failed.title} by hand, with the JSON \`mcp config\` prints?`,
|
|
601
|
+
}
|
|
602
|
+
: probe.kind !== "up" && sub === "connect"
|
|
603
|
+
? { next: [`./bin/${cli} web up --no-open`] }
|
|
604
|
+
: {}),
|
|
605
|
+
});
|
|
606
|
+
return ok ? 0 : 1;
|
|
607
|
+
}
|