@monster-orange/den 0.4.5 → 0.4.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/README.md +21 -7
  3. package/docs/distributions.md +221 -0
  4. package/package.json +4 -3
  5. package/skills/install-den/SKILL.md +114 -0
  6. package/src/shared/agent-setup.ts +9 -0
  7. package/src/shared/agents/den-claude.ts +19 -5
  8. package/src/shared/agents/guardrails/hooks.json +27 -0
  9. package/src/shared/agents/guardrails/instructions.md +39 -0
  10. package/src/shared/agents/guardrails/pretooluse.mjs +430 -0
  11. package/src/shared/agents/guardrails/stop-hook.ts +66 -0
  12. package/src/shared/agents/guardrails.ts +114 -0
  13. package/src/shared/agents/handback.ts +241 -0
  14. package/src/shared/agents/personas.ts +55 -6
  15. package/src/shared/constants.ts +7 -0
  16. package/src/shared/den-config.ts +67 -6
  17. package/src/shared/dev-metrics/emit.ts +59 -5
  18. package/src/shared/dev-metrics/polling.ts +135 -0
  19. package/src/shared/engine.ts +14 -2
  20. package/src/shared/port-pick.ts +60 -0
  21. package/src/ui/cli/agent-ops.ts +7 -3
  22. package/src/ui/cli/agents-command.ts +51 -1
  23. package/src/ui/cli/create-main.ts +1 -1
  24. package/src/ui/cli/create.ts +263 -30
  25. package/src/ui/cli/doctor-command.ts +87 -4
  26. package/src/ui/cli/doctor-onboard.ts +291 -0
  27. package/src/ui/cli/flag-parser.ts +5 -0
  28. package/src/ui/cli/index.ts +352 -5
  29. package/src/ui/cli/json-out.ts +49 -0
  30. package/src/ui/cli/mcp-connect.ts +179 -15
  31. package/src/ui/cli/repo-command.ts +490 -0
  32. package/src/ui/cli/repo-detect.ts +192 -0
  33. package/src/ui/cli/web-command.ts +3 -0
  34. package/src/ui/mcp/connect.ts +134 -0
  35. package/src/ui/server/public/settings-registry.js +3 -3
  36. package/src/ui/server/request-guard.ts +1 -1
@@ -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,17 +898,45 @@ 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 (args.includes("--json") && (sub === "down" || sub === "stop")) {
904
+ process.exitCode = await this.cmdWebDownJson(
905
+ args.slice(1).filter((a) => a !== "--json"),
906
+ );
907
+ break;
908
+ }
909
+ if (
910
+ args.includes("--json") &&
911
+ (sub === undefined ||
912
+ sub.startsWith("-") ||
913
+ sub === "up" ||
914
+ sub === "restart")
915
+ ) {
916
+ process.exitCode = await this.cmdWebUpJson(
917
+ args.slice(1).filter((a) => a !== "--json"),
918
+ );
919
+ break;
920
+ }
872
921
  await cmdWeb(args.slice(1));
873
922
  break;
923
+ }
874
924
 
875
925
  // ── Environment manager commands ──────────────────────────────────
876
926
  case "up":
877
- await this.cmdServiceUp(args.slice(1));
927
+ if (args.slice(1).includes("--json"))
928
+ process.exitCode = await this.cmdServiceUpJson(
929
+ args.slice(1).filter((a) => a !== "--json"),
930
+ );
931
+ else await this.cmdServiceUp(args.slice(1));
878
932
  break;
879
933
 
880
934
  case "down":
881
- await this.cmdServiceDown(args.slice(1));
935
+ if (args.slice(1).includes("--json"))
936
+ process.exitCode = await this.cmdServiceDownJson(
937
+ args.slice(1).filter((a) => a !== "--json"),
938
+ );
939
+ else await this.cmdServiceDown(args.slice(1));
882
940
  break;
883
941
 
884
942
  case "restart":
@@ -906,6 +964,12 @@ class DenCLI {
906
964
  await this.cmdRegister(args.slice(1));
907
965
  break;
908
966
 
967
+ case "repo": {
968
+ const { cmdRepo } = await import("./repo-command.js");
969
+ process.exitCode = await cmdRepo(args.slice(1), validateManifest);
970
+ break;
971
+ }
972
+
909
973
  case "relocate":
910
974
  await this.cmdRelocate(args.slice(1));
911
975
  break;
@@ -967,7 +1031,7 @@ class DenCLI {
967
1031
  case "create": {
968
1032
  // bin/den routes `create` to its own entry (it needs no den.yml);
969
1033
  // this covers the CLI run directly from a checkout.
970
- const code = runCreate(args.slice(1), getInvokeCwd());
1034
+ const code = await runCreate(args.slice(1), getInvokeCwd());
971
1035
  if (code !== 0) process.exit(code);
972
1036
  break;
973
1037
  }
@@ -1829,8 +1893,10 @@ class DenCLI {
1829
1893
  if (isNamedTarget(address, opts?.named ?? [])) {
1830
1894
  console.log(chalk.red(` ✗ ${remote}`));
1831
1895
  process.exitCode = 1;
1896
+ this.upReport?.failed.push(address);
1832
1897
  } else {
1833
1898
  console.log(chalk.gray(` • ${remote}`));
1899
+ this.upReport?.skipped.push(address);
1834
1900
  }
1835
1901
  continue;
1836
1902
  }
@@ -1854,6 +1920,7 @@ class DenCLI {
1854
1920
  );
1855
1921
  // A dependency only warns; the service you named failed.
1856
1922
  if (isNamedTarget(address, opts?.named ?? [])) process.exitCode = 1;
1923
+ this.upReport?.failed.push(address);
1857
1924
  continue;
1858
1925
  }
1859
1926
  if (health.health === "ok" && claim !== "conflict") {
@@ -1870,6 +1937,7 @@ class DenCLI {
1870
1937
  ` • ${chalk.bold(address)} already healthy — skipping dependency up${suffix}`,
1871
1938
  ),
1872
1939
  );
1940
+ if (command === "up") this.upReport?.alreadyRunning.push(address);
1873
1941
  continue;
1874
1942
  }
1875
1943
  }
@@ -2103,6 +2171,8 @@ class DenCLI {
2103
2171
  // Any target that failed makes the whole command fail (scripts and
2104
2172
  // agents read the exit code).
2105
2173
  if (!started) process.exitCode = 1;
2174
+ if (command === "up")
2175
+ this.upReport?.[started ? "started" : "failed"].push(address);
2106
2176
  if (plan?.failFast && !started) {
2107
2177
  console.log(
2108
2178
  chalk.red(
@@ -2746,6 +2816,10 @@ class DenCLI {
2746
2816
  "act in that (active) workspace instead of the focused one; focus stays",
2747
2817
  );
2748
2818
  row("--skip-secrets", "skip the secrets-backend sign-in preflight");
2819
+ row(
2820
+ "--json",
2821
+ "log on stderr; stdout: { ok, services: [{ address, result, health }], fix? }",
2822
+ );
2749
2823
  row("-h, --help", "show this help");
2750
2824
  console.log();
2751
2825
  }
@@ -3099,6 +3173,272 @@ class DenCLI {
3099
3173
  );
3100
3174
  }
3101
3175
 
3176
+ /**
3177
+ * `up --json` (ON-2): up as it always runs, its log on stderr, then one
3178
+ * JSON object on stdout: each service it touched, what happened to it
3179
+ * (started, already-running, failed, skipped) and its health once started
3180
+ * (a started service with a health check is waited on, up to 30s). Safe to
3181
+ * re-run: a healthy service is already-running. Every failure names a fix.
3182
+ */
3183
+ private async cmdServiceUpJson(args: string[]): Promise<number> {
3184
+ const report: UpReport = {
3185
+ started: [],
3186
+ failed: [],
3187
+ alreadyRunning: [],
3188
+ skipped: [],
3189
+ };
3190
+ const bin = fs.existsSync(
3191
+ nodePath.join(getDistRoot(), "bin", product().cli),
3192
+ )
3193
+ ? `./bin/${product().cli}`
3194
+ : product().cli;
3195
+ const before = process.exitCode;
3196
+ process.exitCode = undefined;
3197
+ let error: string | undefined;
3198
+ this.upReport = report;
3199
+ try {
3200
+ await quietStdout(() => this.cmdServiceUp(args));
3201
+ } catch (e) {
3202
+ if (!(e instanceof ExitRequested)) error = (e as Error).message;
3203
+ else if (e.code !== 0) process.exitCode = e.code;
3204
+ } finally {
3205
+ this.upReport = null;
3206
+ }
3207
+ const failedExit = process.exitCode !== undefined && process.exitCode !== 0;
3208
+ process.exitCode = before;
3209
+ const workspace =
3210
+ process.env.DEN_WORKSPACE_OVERRIDE ??
3211
+ getFocusedWorkspaceName() ??
3212
+ undefined;
3213
+ const result = (address: string): string =>
3214
+ report.failed.includes(address)
3215
+ ? "failed"
3216
+ : report.started.includes(address)
3217
+ ? "started"
3218
+ : report.alreadyRunning.includes(address)
3219
+ ? "already-running"
3220
+ : "skipped";
3221
+ const touched = [
3222
+ ...new Set([
3223
+ ...report.started,
3224
+ ...report.alreadyRunning,
3225
+ ...report.failed,
3226
+ ...report.skipped,
3227
+ ]),
3228
+ ];
3229
+ const services = await Promise.all(
3230
+ touched.map(async (address) => {
3231
+ const resolved = resolveService(address, workspace);
3232
+ let health = "unknown";
3233
+ let detail: string | undefined;
3234
+ if (resolved && result(address) !== "skipped") {
3235
+ const ready =
3236
+ result(address) === "started"
3237
+ ? await waitForReady(resolved, "local", { timeoutMs: 30_000 })
3238
+ : null;
3239
+ const h = await checkServiceHealth(resolved, "local");
3240
+ health = h.health;
3241
+ detail = h.healthDetail ?? ready?.detail;
3242
+ }
3243
+ return {
3244
+ address,
3245
+ result: result(address),
3246
+ health,
3247
+ ...(detail ? { detail } : {}),
3248
+ };
3249
+ }),
3250
+ );
3251
+ const unhealthy = services.filter(
3252
+ (s) => s.result !== "skipped" && s.health === "error",
3253
+ );
3254
+ const out: Record<string, unknown> = {
3255
+ ok:
3256
+ !error &&
3257
+ !failedExit &&
3258
+ report.failed.length === 0 &&
3259
+ unhealthy.length === 0 &&
3260
+ touched.length > 0,
3261
+ workspace: workspace ?? null,
3262
+ services,
3263
+ };
3264
+ if (error) {
3265
+ out.error = error;
3266
+ out.fix = `${bin} doctor --json`;
3267
+ } else if (report.failed.length > 0 || unhealthy.length > 0) {
3268
+ const names = [
3269
+ ...new Set([...report.failed, ...unhealthy.map((s) => s.address)]),
3270
+ ];
3271
+ out.error = `${names.join(", ")} did not start healthy`;
3272
+ out.fix = `${bin} logs ${names[0].split("/")[0]}`;
3273
+ } else if (failedExit) {
3274
+ out.error = "up failed (its log is on stderr)";
3275
+ out.fix = `${bin} up ${args.join(" ")} --plan`.replace(/\s+/g, " ");
3276
+ } else if (touched.length === 0) {
3277
+ out.error =
3278
+ "nothing was started: no repo named, and the folder up ran in is not a repo in the workspace";
3279
+ out.fix = `${bin} up -a --json`;
3280
+ out.ask = "Which repo should start? (its name, as `repo add` printed it)";
3281
+ }
3282
+ out.next = out.ok
3283
+ ? [`${bin} web up --no-open --json`, `${bin} doctor --json`]
3284
+ : [];
3285
+ printJson(out);
3286
+ return out.ok ? 0 : 1;
3287
+ }
3288
+
3289
+ /**
3290
+ * `down --json`: down as it always runs, its log on stderr, then one JSON
3291
+ * object on stdout, `{ ok, workspace, services, all? }`. Safe to re-run:
3292
+ * stopping what is already stopped is ok. A failure names a fix.
3293
+ */
3294
+ private async cmdServiceDownJson(args: string[]): Promise<number> {
3295
+ const bin = fs.existsSync(
3296
+ nodePath.join(getDistRoot(), "bin", product().cli),
3297
+ )
3298
+ ? `./bin/${product().cli}`
3299
+ : product().cli;
3300
+ const before = process.exitCode;
3301
+ process.exitCode = undefined;
3302
+ let error: string | undefined;
3303
+ try {
3304
+ await quietStdout(() => this.cmdServiceDown(args));
3305
+ } catch (e) {
3306
+ if (!(e instanceof ExitRequested)) error = (e as Error).message;
3307
+ else if (e.code !== 0) process.exitCode = e.code;
3308
+ }
3309
+ const failedExit = process.exitCode !== undefined && process.exitCode !== 0;
3310
+ process.exitCode = before;
3311
+ const terminator = args.indexOf("--");
3312
+ const own = terminator === -1 ? args : args.slice(0, terminator);
3313
+ const all = own.includes("-a") || own.includes("--all");
3314
+ const out: Record<string, unknown> = {
3315
+ ok: !error && !failedExit,
3316
+ workspace:
3317
+ process.env.DEN_WORKSPACE_OVERRIDE ?? getFocusedWorkspaceName() ?? null,
3318
+ services: own.filter((a) => !a.startsWith("-")),
3319
+ ...(all ? { all: true } : {}),
3320
+ };
3321
+ if (!out.ok) {
3322
+ out.error = error ?? "down failed (its log is on stderr)";
3323
+ out.fix = `${bin} doctor --json`;
3324
+ }
3325
+ printJson(out);
3326
+ return out.ok ? 0 : 1;
3327
+ }
3328
+
3329
+ /**
3330
+ * `web down --json`: the web server stopped as usual (its log on stderr),
3331
+ * then `{ ok, url, port, running: false, stopped }` once nothing answers
3332
+ * (up to 10s). `stopped` is false when it wasn't running: re-running is ok.
3333
+ */
3334
+ private async cmdWebDownJson(args: string[]): Promise<number> {
3335
+ const { parseWebCommandArgs } = await import("./web-command.js");
3336
+ const { probeWebServer } = await import("./mcp-connect.js");
3337
+ const bin = fs.existsSync(
3338
+ nodePath.join(getDistRoot(), "bin", product().cli),
3339
+ )
3340
+ ? `./bin/${product().cli}`
3341
+ : product().cli;
3342
+ let port: number;
3343
+ try {
3344
+ port = parseWebCommandArgs(args).port;
3345
+ } catch (e) {
3346
+ printJson({
3347
+ ok: false,
3348
+ error: (e as Error).message,
3349
+ fix: `${bin} web down --json`,
3350
+ });
3351
+ return 2;
3352
+ }
3353
+ const url = `http://localhost:${port}`;
3354
+ const was = (await probeWebServer(url)).kind === "up";
3355
+ let error: string | undefined;
3356
+ try {
3357
+ await quietStdout(() => cmdWeb(args));
3358
+ } catch (e) {
3359
+ if (!(e instanceof ExitRequested) || e.code !== 0)
3360
+ error = (e as Error).message;
3361
+ }
3362
+ let probe = await probeWebServer(url);
3363
+ const deadline = Date.now() + 10_000;
3364
+ while (!error && probe.kind === "up" && Date.now() < deadline) {
3365
+ await new Promise((r) => setTimeout(r, 250));
3366
+ probe = await probeWebServer(url);
3367
+ }
3368
+ const running = probe.kind === "up";
3369
+ const out: Record<string, unknown> = {
3370
+ ok: !error && !running,
3371
+ url,
3372
+ port,
3373
+ running,
3374
+ stopped: was && !running,
3375
+ };
3376
+ if (!out.ok) {
3377
+ out.error = error ?? `the web server still answers at ${url}`;
3378
+ out.fix = `${bin} doctor --json`;
3379
+ }
3380
+ printJson(out);
3381
+ return out.ok ? 0 : 1;
3382
+ }
3383
+
3384
+ /**
3385
+ * `web up|restart --json` (ON-2): the web server started as usual (its
3386
+ * log on stderr), then `{ ok, url, port, version? }` once it answers
3387
+ * (up to 30s), so an agent hands back the right URL.
3388
+ */
3389
+ private async cmdWebUpJson(args: string[]): Promise<number> {
3390
+ const { parseWebCommandArgs } = await import("./web-command.js");
3391
+ const { probeWebServer } = await import("./mcp-connect.js");
3392
+ const bin = fs.existsSync(
3393
+ nodePath.join(getDistRoot(), "bin", product().cli),
3394
+ )
3395
+ ? `./bin/${product().cli}`
3396
+ : product().cli;
3397
+ let port: number;
3398
+ try {
3399
+ port = parseWebCommandArgs(args).port;
3400
+ } catch (e) {
3401
+ printJson({
3402
+ ok: false,
3403
+ error: (e as Error).message,
3404
+ fix: `${bin} web up --no-open --json`,
3405
+ });
3406
+ return 2;
3407
+ }
3408
+ const url = `http://localhost:${port}`;
3409
+ let error: string | undefined;
3410
+ try {
3411
+ await quietStdout(() => cmdWeb(args));
3412
+ } catch (e) {
3413
+ if (!(e instanceof ExitRequested) || e.code !== 0)
3414
+ error = (e as Error).message;
3415
+ }
3416
+ let probe = await probeWebServer(url);
3417
+ const deadline = Date.now() + 30_000;
3418
+ while (!error && probe.kind !== "up" && probe.kind !== "foreign") {
3419
+ if (Date.now() > deadline) break;
3420
+ await new Promise((r) => setTimeout(r, 500));
3421
+ probe = await probeWebServer(url);
3422
+ }
3423
+ const out: Record<string, unknown> = {
3424
+ ok: probe.kind === "up",
3425
+ url,
3426
+ port,
3427
+ ...(probe.kind === "up" && probe.version
3428
+ ? { version: probe.version }
3429
+ : {}),
3430
+ };
3431
+ if (probe.kind === "foreign") {
3432
+ out.error = `${probe.who} answers at ${url}, not this distribution's web server`;
3433
+ out.ask = `Another Den distribution uses port ${port}. Which port should ${product().title} use? (web.port in config/local/user.yml)`;
3434
+ } else if (probe.kind !== "up") {
3435
+ out.error = error ?? `the web server is not answering at ${url}`;
3436
+ out.fix = `${bin} web restart --no-open --json`;
3437
+ }
3438
+ printJson(out);
3439
+ return out.ok ? 0 : 1;
3440
+ }
3441
+
3102
3442
  /** Legacy: start the given services via manifest depends_on. */
3103
3443
  private async cmdServiceUpManifest(
3104
3444
  addresses: string[],
@@ -4880,6 +5220,10 @@ class DenCLI {
4880
5220
  */
4881
5221
  private displayHelp(): void {
4882
5222
  this.displayHelpBanner();
5223
+ // ON-5: the line a developer pastes into their agent.
5224
+ console.log(`Set up by your agent: paste this into it:`);
5225
+ console.log(` ${AGENT_SETUP_LINE}`);
5226
+ console.log();
4883
5227
  console.log("Usage:");
4884
5228
  console.log(` ${product().cli} [command] [options]`);
4885
5229
  console.log("\nEnvironment commands:");
@@ -4917,6 +5261,9 @@ class DenCLI {
4917
5261
  console.log(
4918
5262
  " register [path] Register a repo in the service catalog",
4919
5263
  );
5264
+ console.log(
5265
+ " repo add <path|url> Add a repo with no prompts, never changing it (--sidecar, --json)",
5266
+ );
4920
5267
  console.log(
4921
5268
  " relocate <repo> <path> Point a moved checkout's workspace entries at its new path",
4922
5269
  );
@@ -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
+ }