@monster-orange/den 0.4.0 → 0.4.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.
Files changed (58) hide show
  1. package/CHANGELOG.md +457 -0
  2. package/README.md +88 -158
  3. package/docs/distributions.md +216 -16
  4. package/docs/reference.md +177 -0
  5. package/package.json +3 -1
  6. package/src/shared/actor-token.ts +275 -0
  7. package/src/shared/agent-actor.ts +12 -2
  8. package/src/shared/agents/den-claude.ts +99 -1
  9. package/src/shared/agents/runtime.ts +7 -0
  10. package/src/shared/agents/runtimes.ts +9 -2
  11. package/src/shared/den-config.ts +52 -3
  12. package/src/shared/environment-manager/scope.ts +22 -0
  13. package/src/shared/environment-manager/service-runner.ts +94 -2
  14. package/src/shared/profile.ts +15 -4
  15. package/src/shared/sessions/client.ts +16 -2
  16. package/src/shared/sessions/den-pty.ts +10 -2
  17. package/src/shared/sessions/env.ts +13 -3
  18. package/src/shared/sessions/host.ts +56 -8
  19. package/src/shared/sessions/types.ts +8 -0
  20. package/src/shared/web-secret.ts +181 -0
  21. package/src/shared/web-session.ts +491 -0
  22. package/src/ui/cli/agent-ops.ts +843 -0
  23. package/src/ui/cli/agents-command.ts +97 -615
  24. package/src/ui/cli/flag-parser.ts +21 -0
  25. package/src/ui/cli/index.ts +127 -11
  26. package/src/ui/cli/sessions-command.ts +49 -5
  27. package/src/ui/cli/target-workspace.ts +105 -0
  28. package/src/ui/cli/web-command.ts +542 -14
  29. package/src/ui/mcp/api.ts +7 -0
  30. package/src/ui/mcp/server.ts +74 -6
  31. package/src/ui/server/agent-routes.ts +324 -0
  32. package/src/ui/server/audit.ts +81 -27
  33. package/src/ui/server/http-errors.ts +21 -3
  34. package/src/ui/server/index.ts +821 -66
  35. package/src/ui/server/public/agents-page.js +654 -0
  36. package/src/ui/server/public/agents-section.js +299 -0
  37. package/src/ui/server/public/agents.html +439 -0
  38. package/src/ui/server/public/appearance.js +3 -0
  39. package/src/ui/server/public/error-page.js +103 -0
  40. package/src/ui/server/public/header.js +45 -11
  41. package/src/ui/server/public/i18n/es.js +90 -1
  42. package/src/ui/server/public/i18n/ja.js +86 -1
  43. package/src/ui/server/public/local-dev.html +53 -0
  44. package/src/ui/server/public/logs.html +3 -0
  45. package/src/ui/server/public/panel.js +1 -1
  46. package/src/ui/server/public/release.html +8 -0
  47. package/src/ui/server/public/settings-page.js +5 -0
  48. package/src/ui/server/public/settings-registry.js +33 -0
  49. package/src/ui/server/public/shell.js +248 -5
  50. package/src/ui/server/public/topbar.js +51 -2
  51. package/src/ui/server/request-guard.ts +670 -0
  52. package/src/ui/server/route-classes.ts +317 -0
  53. package/src/ui/server/session-routes.ts +445 -0
  54. package/src/ui/server/shutdown-guard.ts +91 -0
  55. package/src/ui/server/sudo-store.ts +29 -3
  56. package/src/ui/server/ui-assets.ts +10 -1
  57. package/src/ui/server/websocket.ts +326 -0
  58. package/src/ui/server/workspace-routes.ts +13 -7
@@ -170,6 +170,27 @@ function validateFlagScopes(parsed: ParsedCliArgs): void {
170
170
  }
171
171
  }
172
172
 
173
+ /**
174
+ * Subcommands that print their own `--help`: the dispatcher's
175
+ * `<command> --help` must not answer for them (`web sessions --help` printed
176
+ * the general web help). web-command.ts's WEB_SUBCOMMANDS_WITH_HELP and
177
+ * mcp-connect.ts's MCP_CONNECT_SUBCOMMANDS are the same lists (a test
178
+ * checks).
179
+ */
180
+ export const SUBCOMMAND_OWN_HELP: Readonly<Record<string, readonly string[]>> =
181
+ {
182
+ web: ["token", "login", "sessions", "logout"],
183
+ mcp: ["connect", "disconnect", "clients", "config"],
184
+ };
185
+
186
+ /** Whether `<command> <sub> --help` is the subcommand's own to answer. */
187
+ export function ownsSubcommandHelp(
188
+ command: string,
189
+ sub: string | undefined,
190
+ ): boolean {
191
+ return !!sub && (SUBCOMMAND_OWN_HELP[command] ?? []).includes(sub);
192
+ }
193
+
173
194
  // Dash-flags each mutation subcommand accepts (global flags — -w/--workspace,
174
195
  // -t/--tabs/--no-tabs, --skip-secrets — are consumed by parseCliArgs before
175
196
  // dispatch, and -h/--help is handled by the dispatcher). Commands not listed
@@ -215,10 +215,12 @@ import {
215
215
  import { githubOrgFor } from "../../shared/environment-manager/workspace-org.js";
216
216
  import { cmdWeb, displayWebHelp } from "./web-command.js";
217
217
  import { cmdGraph } from "./graph-command.js";
218
+ import { resolveTargetWorkspace } from "./target-workspace.js";
218
219
  import { resolveLogsTarget, tailServiceLogs } from "./logs-command.js";
219
220
  import {
220
221
  parseCliArgs,
221
222
  takeWorkspaceFlags,
223
+ ownsSubcommandHelp,
222
224
  unknownCommandFlags,
223
225
  workspaceFlagRequiresActive,
224
226
  type ParsedCliArgs,
@@ -311,19 +313,20 @@ const WORKSPACE_SUBCOMMANDS: {
311
313
 
312
314
  const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
313
315
  down: {
314
- usage: "down [repo] [flags]",
316
+ usage: "down [repo] [-w <workspace>] [flags]",
315
317
  lines: [
316
318
  "Stop the cwd (or named) repo; --with-deps also stops what it sits on (its",
317
319
  "derived closure, in reverse order), naming anything else that uses it.",
318
320
  "-a/--all stops EVERY workspace system (reverse derived order, cwd-agnostic;",
319
321
  "every down script runs — no already-stopped skip).",
320
- "Flags: -a/--all · -A/--app <app> · --with-deps · -n/--dry-run (print order, stop nothing) · --manifest (legacy depends_on)",
322
+ "Flags: -w/--workspace <ws> (that active workspace, not the focused one; focus stays) · -a/--all · -A/--app <app> · --with-deps · -n/--dry-run (print order, stop nothing) · --manifest (legacy depends_on)",
321
323
  ],
322
324
  },
323
325
  restart: {
324
- usage: "restart [service...] [-A <app>] [-n|--dry-run]",
326
+ usage: "restart [service...] [-w <workspace>] [-A <app>] [-n|--dry-run]",
325
327
  lines: [
326
328
  `Restart the given services, or every repo in an application (\`${product().cli} apps\`).`,
329
+ "-w/--workspace <ws> acts in that active workspace instead of the focused one; focus stays.",
327
330
  ],
328
331
  },
329
332
  status: {
@@ -368,7 +371,7 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
368
371
  },
369
372
  setup: {
370
373
  usage:
371
- "setup [service...] [--force|-f] [--plan [-w <workspace>]... [--all-active]]",
374
+ "setup [service...] [-w <workspace>] [--force|-f] [--plan [-w <workspace>]... [--all-active]]",
372
375
  lines: [
373
376
  "Run 'make setup' once per system (clones missing managed repos first).",
374
377
  "Default targets MANAGED clones only — setup's codegen/installs",
@@ -378,6 +381,7 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
378
381
  "cloned, seeded or written (on a fresh machine: the blueprint's plan).",
379
382
  "It plans the focused workspace; -w <workspace> (repeatable) plans those,",
380
383
  "--all-active every active one. Repos only other workspaces hold are named.",
384
+ "Without --plan, -w <workspace> sets up that workspace's systems instead of the focused one's.",
381
385
  ],
382
386
  },
383
387
  org: {
@@ -794,10 +798,13 @@ class DenCLI {
794
798
 
795
799
  // `<command> -h/--help` → that command's help (every command supports it).
796
800
  // (`-h`/`--help` AS the command falls through to the switch → global help.)
801
+ // A subcommand that prints its own help (`web sessions --help`, `mcp
802
+ // connect --help`) is dispatched instead: its help wins.
797
803
  if (
798
804
  command &&
799
805
  !["-h", "--help", "-v", "--version"].includes(command) &&
800
- (ownArgs.includes("-h") || ownArgs.includes("--help"))
806
+ (ownArgs.includes("-h") || ownArgs.includes("--help")) &&
807
+ !ownsSubcommandHelp(command, ownArgs[1])
801
808
  ) {
802
809
  this.printCommandHelp(command, ownArgs[1]);
803
810
  return;
@@ -2704,8 +2711,11 @@ class DenCLI {
2704
2711
  );
2705
2712
  console.log();
2706
2713
  console.log(chalk.bold(" Flags:"));
2714
+ // Two spaces at least between a flag and its text, a long one too.
2707
2715
  const row = (f: string, d: string) =>
2708
- console.log(` ${chalk.cyan(f.padEnd(20))}${d}`);
2716
+ console.log(
2717
+ ` ${chalk.cyan(f.padEnd(Math.max(20, f.length + 2)))}${d}`,
2718
+ );
2709
2719
  row(
2710
2720
  "-x, --exclude",
2711
2721
  "start only the dependencies, not the current/seed repo",
@@ -2731,6 +2741,10 @@ class DenCLI {
2731
2741
  "--manifest",
2732
2742
  `use legacy ${product().manifestYml} depends_on instead of the derived graph`,
2733
2743
  );
2744
+ row(
2745
+ "-w, --workspace <ws>",
2746
+ "act in that (active) workspace instead of the focused one; focus stays",
2747
+ );
2734
2748
  row("--skip-secrets", "skip the secrets-backend sign-in preflight");
2735
2749
  row("-h, --help", "show this help");
2736
2750
  console.log();
@@ -2976,7 +2990,44 @@ class DenCLI {
2976
2990
  }
2977
2991
  }
2978
2992
 
2993
+ /**
2994
+ * With no `-w`: the workspace the named services are in, when it isn't
2995
+ * the focus (target-workspace.ts), set as this run's workspace. False
2996
+ * (exit 1, said why) when a name is in no active workspace, in several,
2997
+ * or the names are in different ones.
2998
+ */
2999
+ private pickTargetWorkspace(command: string, args: string[]): boolean {
3000
+ if (process.env.DEN_WORKSPACE_OVERRIDE) return true;
3001
+ const terminator = args.indexOf("--");
3002
+ const own = terminator === -1 ? args : args.slice(0, terminator);
3003
+ let rest: string[];
3004
+ try {
3005
+ const taken = takeAppFlag(own);
3006
+ if (taken.app) return true; // -A names an application, not services
3007
+ rest = taken.rest;
3008
+ } catch {
3009
+ return true; // the command itself reports a bad -A
3010
+ }
3011
+ const names = rest.filter((a) => !a.startsWith("-"));
3012
+ const r = resolveTargetWorkspace(names, product().cli, command);
3013
+ if (!r.ok) {
3014
+ console.error(chalk.red(`\n ✗ ${r.error}\n`));
3015
+ process.exitCode = 1;
3016
+ return false;
3017
+ }
3018
+ if (r.workspace) {
3019
+ process.env.DEN_WORKSPACE_OVERRIDE = r.workspace;
3020
+ console.log(
3021
+ chalk.gray(
3022
+ `\n ${names.join(", ")}: in workspace ${r.workspace} (not the focus)`,
3023
+ ),
3024
+ );
3025
+ }
3026
+ return true;
3027
+ }
3028
+
2979
3029
  private async cmdServiceUp(args: string[]): Promise<void> {
3030
+ if (!this.pickTargetWorkspace("up", args)) return;
2980
3031
  // Tokens after a `--` terminator belong to a child command — they must
2981
3032
  // never be read as up's own flags (`den up -- -a` is NOT `up -a`).
2982
3033
  const terminator = args.indexOf("--");
@@ -3101,6 +3152,7 @@ class DenCLI {
3101
3152
  }
3102
3153
 
3103
3154
  private async cmdServiceDown(args: string[]): Promise<void> {
3155
+ if (!this.pickTargetWorkspace("down", args)) return;
3104
3156
  // Tokens after a `--` terminator belong to a child command — they must
3105
3157
  // never be read as down's own flags (`den down -- -a` must NOT tear
3106
3158
  // down the whole workspace).
@@ -3171,6 +3223,7 @@ class DenCLI {
3171
3223
  }
3172
3224
 
3173
3225
  private async cmdServiceRestart(argv: string[]): Promise<void> {
3226
+ if (!this.pickTargetWorkspace("restart", argv)) return;
3174
3227
  const { app, rest: args } = takeAppFlag(argv);
3175
3228
  const dryRun = args.includes("--dry-run") || args.includes("-n");
3176
3229
  if (!dryRun) preflightSecrets();
@@ -4068,15 +4121,33 @@ class DenCLI {
4068
4121
  console.log();
4069
4122
  return;
4070
4123
  }
4071
- if (planWs.length || allActive) {
4124
+ if (planWs.length > 1 || allActive) {
4072
4125
  console.error(
4073
4126
  chalk.red(
4074
- `\n -w and --all-active go with --plan: setup itself works on the focused workspace.\n`,
4127
+ `\n More than one -w, and --all-active, go with --plan: setup itself works on one workspace (-w <workspace>, or the one the named repos are in, else the focus).\n`,
4075
4128
  ),
4076
4129
  );
4077
4130
  process.exitCode = 1;
4078
4131
  return;
4079
4132
  }
4133
+ if (planWs.length === 1) {
4134
+ const ws = planWs[0];
4135
+ if (!readWorkspacesFile().workspaces[ws]) {
4136
+ console.error(chalk.red(`\n Workspace "${ws}" does not exist\n`));
4137
+ process.exitCode = 1;
4138
+ return;
4139
+ }
4140
+ if (!isWorkspaceActive(ws)) {
4141
+ console.error(
4142
+ chalk.red(
4143
+ `\n Workspace "${ws}" is not active — run \`${product().cli} workspace activate ${ws}\` first\n`,
4144
+ ),
4145
+ );
4146
+ process.exitCode = 1;
4147
+ return;
4148
+ }
4149
+ process.env.DEN_WORKSPACE_OVERRIDE = ws;
4150
+ } else if (!this.pickTargetWorkspace("setup", args)) return;
4080
4151
 
4081
4152
  // Clone any managed repos that aren't on disk yet before reading manifests.
4082
4153
  this.cloneMissingManagedRepos();
@@ -4091,11 +4162,27 @@ class DenCLI {
4091
4162
  return;
4092
4163
  }
4093
4164
 
4165
+ // A name the listing doesn't spell the same way (an alias, a bare repo
4166
+ // listed only by subsystem) is matched as resolveService resolves it.
4167
+ const matches = (name: string, s: (typeof services)[number]): boolean => {
4168
+ if (name === s.address || name === s.repoName) return true;
4169
+ const r = resolveService(name);
4170
+ return !!r && (r.address === s.address || r.repoName === s.address);
4171
+ };
4172
+ const unmatched = names.filter((n) => !services.some((s) => matches(n, s)));
4173
+ if (unmatched.length > 0) {
4174
+ const ws = getActiveWorkspace()?.name ?? "default";
4175
+ console.error(
4176
+ chalk.red(
4177
+ `\n ✗ Not in workspace ${ws}: ${unmatched.join(", ")}. Nothing was set up.\n`,
4178
+ ),
4179
+ );
4180
+ process.exitCode = 1;
4181
+ return;
4182
+ }
4094
4183
  let targets =
4095
4184
  names.length > 0
4096
- ? services.filter(
4097
- (s) => names.includes(s.address) || names.includes(s.repoName),
4098
- )
4185
+ ? services.filter((s) => names.some((n) => matches(n, s)))
4099
4186
  : services;
4100
4187
 
4101
4188
  // Workspace-wide default: MANAGED clones only. `make setup` runs
@@ -4576,6 +4663,35 @@ class DenCLI {
4576
4663
  if (!d.available) warn(`edge.tls ${edge.tls.strategy}: ${d.reason}`);
4577
4664
  }
4578
4665
  }
4666
+ // The web keys: a wrong value would stop `web up`; one read and
4667
+ // ignored (the distribution's web.allow_plain_http_remote: only
4668
+ // config/local/user.yml counts) is flagged, as `web up` says it.
4669
+ {
4670
+ const { ignoredConfigWarning, readWebAuthConfig } =
4671
+ await import("../../shared/web-session.js");
4672
+ try {
4673
+ const w = ignoredConfigWarning(
4674
+ readWebAuthConfig(getDistRoot(), product().configFile),
4675
+ );
4676
+ if (w) console.warn(chalk.yellow(`\n ⚠ ${w}`));
4677
+ } catch (e) {
4678
+ console.error(chalk.red(`\n ✗ web: ${(e as Error).message}`));
4679
+ process.exitCode = 1;
4680
+ }
4681
+ }
4682
+ // The caps: agents.max_concurrent (agents) and sessions.max_concurrent
4683
+ // (plain sessions). A wrong value would refuse every start.
4684
+ {
4685
+ const { readAgentsConfig, readSessionsConfig } =
4686
+ await import("../../shared/den-config.js");
4687
+ for (const read of [readAgentsConfig, readSessionsConfig])
4688
+ try {
4689
+ read();
4690
+ } catch (e) {
4691
+ console.error(chalk.red(`\n ✗ ${(e as Error).message}`));
4692
+ process.exitCode = 1;
4693
+ }
4694
+ }
4579
4695
  const positional = args.filter((a) => !a.startsWith("-"));
4580
4696
  const held = args.includes("--held");
4581
4697
  // Bare `validate` in the distribution root (which has no manifest of its
@@ -13,12 +13,16 @@
13
13
  *
14
14
  * The R8 lock is the web UI's (an in-memory admin password in the web
15
15
  * server); CLI service actions (up, down) do not consult it, so neither do
16
- * these. agents.max_concurrent caps every start.
16
+ * these. sessions.max_concurrent caps the plain sessions running at once;
17
+ * agents count against agents.max_concurrent instead, never this one.
17
18
  */
18
19
  import { spawn } from "child_process";
19
20
  import * as fs from "fs";
20
21
  import * as path from "path";
21
- import { readAgentsConfig } from "../../shared/den-config.js";
22
+ import {
23
+ readAgentsConfig,
24
+ readSessionsConfig,
25
+ } from "../../shared/den-config.js";
22
26
  import { cliEntrypoint } from "../../shared/engine.js";
23
27
  import { getDistRoot, getInvokeCwd, getStateDir } from "../../shared/paths.js";
24
28
  import { product } from "../../shared/product.js";
@@ -31,6 +35,10 @@ import {
31
35
  noHost,
32
36
  type HostClient,
33
37
  } from "../../shared/sessions/client.js";
38
+ import {
39
+ ACTOR_TOKEN_ENV,
40
+ isPersonCredentialEnv,
41
+ } from "../../shared/actor-token.js";
34
42
  import { DEN_PTY_ID } from "../../shared/sessions/den-pty.js";
35
43
  import { AGENT_ACTOR_ENV, buildSessionEnv } from "../../shared/sessions/env.js";
36
44
  import { runSessionHost } from "../../shared/sessions/host.js";
@@ -60,9 +68,10 @@ export const SESSIONS_HELP = {
60
68
  "session host (one per distribution, started on demand).",
61
69
  "",
62
70
  "{cli} sessions start --workspace <ws> --name <n> [--cwd <dir>] [--keep-env NAME…] -- <argv…>",
63
- " run argv in a PTY as <dist>-<ws>:<n> (cwd: here); capped by agents.max_concurrent",
71
+ " run argv in a PTY as <dist>-<ws>:<n> (cwd: here); capped by sessions.max_concurrent",
64
72
  " env: PATH, HOME, USER, LOGNAME, SHELL, TERM, COLORTERM, LANG, LC_*, TZ,",
65
73
  " TMPDIR only; --keep-env NAME (exact, repeatable; audited) adds one",
74
+ " (never a person's credential: DEN_PERSON_TOKEN, a web.secret path)",
66
75
  "{cli} sessions list [-w <ws>] [--json]",
67
76
  "{cli} sessions attach <name|id>",
68
77
  " raw terminal; ctrl-] detaches and leaves it running",
@@ -92,7 +101,10 @@ export interface SessionsDeps {
92
101
  connectExisting?: () => Promise<HostClient | null>;
93
102
  pty?: () => PtyLoad;
94
103
  workspaceExists?: (ws: string) => boolean;
104
+ /** agents.max_concurrent: the agents running at once. */
95
105
  maxConcurrent?: () => number;
106
+ /** sessions.max_concurrent: the plain sessions running at once. */
107
+ maxSessions?: () => number;
96
108
  invokeCwd?: () => string;
97
109
  env?: NodeJS.ProcessEnv;
98
110
  out?: (line: string) => void;
@@ -109,7 +121,10 @@ export interface Resolved {
109
121
  connectExisting: () => Promise<HostClient | null>;
110
122
  pty: () => PtyLoad;
111
123
  workspaceExists: (ws: string) => boolean;
124
+ /** agents.max_concurrent: the agents running at once. */
112
125
  maxConcurrent: () => number;
126
+ /** sessions.max_concurrent: the plain sessions running at once. */
127
+ maxSessions: () => number;
113
128
  invokeCwd: () => string;
114
129
  env: NodeJS.ProcessEnv;
115
130
  out: (line: string) => void;
@@ -175,6 +190,7 @@ export function resolveDeps(d: SessionsDeps): Resolved {
175
190
  pty: d.pty ?? (() => loadPty()),
176
191
  workspaceExists: d.workspaceExists ?? defaultWorkspaceExists,
177
192
  maxConcurrent: d.maxConcurrent ?? (() => readAgentsConfig().maxConcurrent),
193
+ maxSessions: d.maxSessions ?? (() => readSessionsConfig().maxConcurrent),
178
194
  invokeCwd: d.invokeCwd ?? getInvokeCwd,
179
195
  env: d.env ?? process.env,
180
196
  out: d.out ?? ((l) => console.log(l)),
@@ -387,6 +403,33 @@ async function start(args: string[], d: Resolved): Promise<number> {
387
403
  );
388
404
  return 1;
389
405
  }
406
+ // SEC-1 §5: a person's credential (DEN_PERSON_TOKEN, a person token, a
407
+ // path to web.secret) or an actor token is never forwarded. Refused, and
408
+ // audited by the session host (names only).
409
+ const forbidden = keep.filter(
410
+ (k) => k === ACTOR_TOKEN_ENV || isPersonCredentialEnv(k, d.env[k]),
411
+ );
412
+ if (forbidden.length) {
413
+ d.err(
414
+ `✗ --keep-env ${forbidden.join(", ")}: a person's credential (or an actor token) is never forwarded to a session`,
415
+ );
416
+ await withClient(d, (c) =>
417
+ c.start(
418
+ {
419
+ name: sessionName(d.dist, ws, name),
420
+ cwd,
421
+ argv: p.rest!,
422
+ env: {},
423
+ cols: 80,
424
+ rows: 24,
425
+ },
426
+ { refusedEnv: forbidden, actor: `user ${cliUser(d.env)}` },
427
+ ),
428
+ ).catch((e: unknown) => {
429
+ if (!(e instanceof HostError)) throw e;
430
+ });
431
+ return 1;
432
+ }
390
433
  const env = plainSessionEnv(d.env, keep);
391
434
  // Names only, never values: what was left out, so it can be asked for.
392
435
  const dropped = Object.keys(d.env)
@@ -405,7 +448,7 @@ async function start(args: string[], d: Resolved): Promise<number> {
405
448
  d.err(` \`${d.cli} doctor\` shows the session backend's state.`);
406
449
  return 1;
407
450
  }
408
- const limit = d.maxConcurrent();
451
+ const limit = d.maxSessions();
409
452
  const full = sessionName(d.dist, ws, name);
410
453
  const tty = d.stdout.isTTY;
411
454
  return withClient(d, async (c) => {
@@ -421,6 +464,7 @@ async function start(args: string[], d: Resolved): Promise<number> {
421
464
  },
422
465
  {
423
466
  limit,
467
+ pool: "sessions",
424
468
  // Audited by the host: each name, and whether it is
425
469
  // credential-shaped; never a value.
426
470
  ...(kept.length
@@ -448,7 +492,7 @@ async function start(args: string[], d: Resolved): Promise<number> {
448
492
  d.err(`✗ ${e.message}`);
449
493
  if (e.code === "cap")
450
494
  d.err(
451
- ` Stop one first (\`${d.cli} sessions kill <name>\`), or raise agents.max_concurrent.`,
495
+ ` Stop one first (\`${d.cli} sessions kill <name>\`), or raise sessions.max_concurrent.`,
452
496
  );
453
497
  if (e.code === "exists")
454
498
  d.err(
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Which workspace a lifecycle command (`setup`, `up`, `down`, `restart`)
3
+ * names its services in, when no `-w` says.
4
+ *
5
+ * The focused workspace is the default target (WS-6.1). A name it doesn't
6
+ * hold resolves across the other active workspaces, as the web API does
7
+ * with `&workspace=` (resolveService(address, workspace)):
8
+ *
9
+ * - in the focus: the focus (nothing changes);
10
+ * - in exactly one other active workspace: that one;
11
+ * - in several: an error that lists them (`-w <workspace>` picks one);
12
+ * - nowhere: an error (exit 1, never a silent exit 0).
13
+ *
14
+ * Every name must land in one workspace; names in different workspaces are
15
+ * an error (run them one workspace at a time, with `-w`).
16
+ */
17
+ import {
18
+ resolveService,
19
+ serviceAliasCandidates,
20
+ } from "../../shared/environment-manager/service-runner.js";
21
+ import {
22
+ getActiveWorkspaces,
23
+ getFocusedWorkspaceName,
24
+ } from "../../shared/environment-manager/workspaces.js";
25
+
26
+ export type TargetWorkspace =
27
+ | { ok: true; workspace: string | null }
28
+ | { ok: false; error: string };
29
+
30
+ /** Whether `name` names a service (a repo, an address or an alias) in `ws`. */
31
+ export function nameResolvesIn(name: string, ws: string): boolean {
32
+ return (
33
+ resolveService(name, ws) !== null ||
34
+ serviceAliasCandidates(name, ws).length > 0
35
+ );
36
+ }
37
+
38
+ /**
39
+ * The workspace `names` resolve in, given the focus and the active set:
40
+ * `workspace: null` when it is the focus (or there are no names). Pure
41
+ * apart from `resolves`.
42
+ */
43
+ export function targetWorkspaceFor(
44
+ names: readonly string[],
45
+ o: {
46
+ cli: string;
47
+ command: string;
48
+ focus: string | null;
49
+ active: readonly string[];
50
+ resolves: (name: string, ws: string) => boolean;
51
+ },
52
+ ): TargetWorkspace {
53
+ if (names.length === 0) return { ok: true, workspace: null };
54
+ const others = o.active.filter((w) => w !== o.focus);
55
+ const where = new Map<string, string>();
56
+ for (const name of names) {
57
+ if (o.focus && o.resolves(name, o.focus)) {
58
+ where.set(name, o.focus);
59
+ continue;
60
+ }
61
+ const hits = others.filter((w) => o.resolves(name, w));
62
+ if (hits.length === 0) {
63
+ const looked = [o.focus, ...others].filter(Boolean).join(", ");
64
+ return {
65
+ ok: false,
66
+ error: `"${name}" is not a service in any active workspace (${looked || "none active"}).`,
67
+ };
68
+ }
69
+ if (hits.length > 1)
70
+ return {
71
+ ok: false,
72
+ error:
73
+ `"${name}" is in more than one active workspace: ` +
74
+ `${hits.map((w) => `${w} (${o.cli} ${o.command} ${name} -w ${w})`).join(", ")}. ` +
75
+ "Name one with -w.",
76
+ };
77
+ where.set(name, hits[0]);
78
+ }
79
+ const workspaces = [...new Set(where.values())];
80
+ if (workspaces.length > 1)
81
+ return {
82
+ ok: false,
83
+ error:
84
+ "These names are in different workspaces: " +
85
+ `${[...where].map(([n, w]) => `${n} (${w})`).join(", ")}. ` +
86
+ `Run ${o.cli} ${o.command} once per workspace, with -w.`,
87
+ };
88
+ const ws = workspaces[0];
89
+ return { ok: true, workspace: ws === o.focus ? null : ws };
90
+ }
91
+
92
+ /** targetWorkspaceFor over this distribution's workspaces. */
93
+ export function resolveTargetWorkspace(
94
+ names: readonly string[],
95
+ cli: string,
96
+ command: string,
97
+ ): TargetWorkspace {
98
+ return targetWorkspaceFor(names, {
99
+ cli,
100
+ command,
101
+ focus: getFocusedWorkspaceName(),
102
+ active: getActiveWorkspaces(),
103
+ resolves: nameResolvesIn,
104
+ });
105
+ }