@monster-orange/den 0.1.0 → 0.2.0

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 (60) hide show
  1. package/CHANGELOG.md +137 -0
  2. package/README.md +1 -1
  3. package/bin/den +7 -0
  4. package/docs/distributions.md +255 -12
  5. package/package.json +4 -2
  6. package/src/pillars/journeys/sandbox.ts +2 -5
  7. package/src/pillars/security/discover.ts +17 -1
  8. package/src/pillars/security/run.ts +31 -3
  9. package/src/shared/agent-actor.ts +77 -0
  10. package/src/shared/den-config.ts +122 -0
  11. package/src/shared/edge/status.ts +74 -0
  12. package/src/shared/environment-manager/health.ts +54 -8
  13. package/src/shared/environment-manager/load-applications.ts +31 -1
  14. package/src/shared/environment-manager/log-tail.ts +5 -4
  15. package/src/shared/environment-manager/opctl.ts +2 -1
  16. package/src/shared/environment-manager/service-container.ts +47 -0
  17. package/src/shared/environment-manager/service-runner.ts +16 -1
  18. package/src/shared/environment-manager/sync-targets.ts +66 -0
  19. package/src/shared/environment-manager/up-plan.ts +25 -1
  20. package/src/shared/manifest/merge-override.ts +75 -0
  21. package/src/shared/manifest/placement.ts +321 -0
  22. package/src/shared/manifest/read-manifest.ts +156 -38
  23. package/src/shared/manifest/schema.ts +14 -0
  24. package/src/shared/manifest/validate-layers.ts +229 -0
  25. package/src/shared/manifest/validate-manifest.ts +66 -6
  26. package/src/shared/paths.ts +12 -0
  27. package/src/shared/redact.ts +107 -0
  28. package/src/shared/stacks/manifest-commands.ts +30 -0
  29. package/src/shared/stacks/status.ts +99 -0
  30. package/src/shared/types/index.ts +23 -0
  31. package/src/ui/cli/applications-command.ts +34 -2
  32. package/src/ui/cli/edge-command.ts +9 -19
  33. package/src/ui/cli/graph-command.ts +50 -4
  34. package/src/ui/cli/index.ts +131 -45
  35. package/src/ui/cli/mcp-main.ts +13 -0
  36. package/src/ui/cli/mcp-stdout-guard.ts +10 -0
  37. package/src/ui/cli/stack-command.ts +13 -40
  38. package/src/ui/cli/test-runner.ts +8 -1
  39. package/src/ui/mcp/api.ts +284 -0
  40. package/src/ui/mcp/guards.ts +141 -0
  41. package/src/ui/mcp/server.ts +147 -0
  42. package/src/ui/mcp/tools.ts +565 -0
  43. package/src/ui/server/audit.ts +71 -0
  44. package/src/ui/server/changelog-parser.ts +11 -0
  45. package/src/ui/server/graph-prefs.ts +89 -0
  46. package/src/ui/server/health-row.ts +15 -0
  47. package/src/ui/server/index.ts +175 -61
  48. package/src/ui/server/log-history.ts +4 -3
  49. package/src/ui/server/public/environments.html +4 -2
  50. package/src/ui/server/public/graph-model.js +416 -0
  51. package/src/ui/server/public/graph.css +976 -0
  52. package/src/ui/server/public/graph.js +986 -0
  53. package/src/ui/server/public/header.js +7 -5
  54. package/src/ui/server/public/local-dev.html +130 -2191
  55. package/src/ui/server/public/logs.html +11 -4
  56. package/src/ui/server/public/panel.js +787 -0
  57. package/src/ui/server/public/services.html +6 -1
  58. package/src/ui/server/readonly-routes.ts +200 -0
  59. package/src/ui/server/ui-assets.ts +73 -0
  60. package/src/ui/server/workspace-routes.ts +39 -8
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Who asked: the headers the MCP server (`<cli> mcp`) sends with every web
3
+ * API call, so the server can record an action as an agent's. The client
4
+ * name and version are what the MCP client reported about itself at
5
+ * initialize: a label for the audit trail, never used for authorization.
6
+ */
7
+ import type { IncomingHttpHeaders } from "http";
8
+
9
+ export const AUDIT_HEADERS = {
10
+ actor: "x-den-actor",
11
+ client: "x-den-mcp-client",
12
+ clientVersion: "x-den-mcp-client-version",
13
+ pid: "x-den-mcp-pid",
14
+ session: "x-den-mcp-session",
15
+ } as const;
16
+
17
+ export interface AgentInfo {
18
+ /** The MCP client's self-reported name (clientInfo.name). */
19
+ client: string;
20
+ /** The MCP client's self-reported version (clientInfo.version). */
21
+ clientVersion: string;
22
+ /** The MCP server process's pid. */
23
+ mcpPid: number | null;
24
+ /** The MCP server's session id (one per `<cli> mcp` process). */
25
+ session: string;
26
+ }
27
+
28
+ export interface AgentActor extends AgentInfo {
29
+ kind: "agent";
30
+ }
31
+
32
+ /** null: a person in the web UI (no agent headers). */
33
+ export type Actor = AgentActor | null;
34
+
35
+ /** Printable, single-line, bounded: these values land in log lines. */
36
+ function clean(v: unknown, max = 64): string {
37
+ const s = Array.isArray(v) ? v[0] : v;
38
+ return String(s ?? "")
39
+ .replace(/[^\x20-\x7e]/g, "")
40
+ .trim()
41
+ .slice(0, max);
42
+ }
43
+
44
+ /** The headers the MCP client sends (see AUDIT_HEADERS). */
45
+ export function agentAuditHeaders(info: AgentInfo): Record<string, string> {
46
+ return {
47
+ [AUDIT_HEADERS.actor]: "agent",
48
+ [AUDIT_HEADERS.client]: clean(info.client) || "unknown",
49
+ [AUDIT_HEADERS.clientVersion]: clean(info.clientVersion) || "unknown",
50
+ [AUDIT_HEADERS.pid]: info.mcpPid === null ? "" : String(info.mcpPid),
51
+ [AUDIT_HEADERS.session]: clean(info.session).replace(/[^A-Za-z0-9-]/g, ""),
52
+ };
53
+ }
54
+
55
+ /** The actor an incoming request names, or null for a person in the UI. */
56
+ export function actorFromHeaders(
57
+ headers: IncomingHttpHeaders | Record<string, string | undefined>,
58
+ ): Actor {
59
+ const h = headers as Record<string, string | string[] | undefined>;
60
+ if (clean(h[AUDIT_HEADERS.actor]) !== "agent") return null;
61
+ const pid = clean(h[AUDIT_HEADERS.pid], 10);
62
+ return {
63
+ kind: "agent",
64
+ client: clean(h[AUDIT_HEADERS.client]) || "unknown",
65
+ clientVersion: clean(h[AUDIT_HEADERS.clientVersion], 32) || "unknown",
66
+ mcpPid: /^\d+$/.test(pid) ? Number(pid) : null,
67
+ session: clean(h[AUDIT_HEADERS.session]).replace(/[^A-Za-z0-9-]/g, ""),
68
+ };
69
+ }
70
+
71
+ /** "agent <client> <version> (mcp pid <pid>, session <id>)". */
72
+ export function actorLabel(actor: AgentActor): string {
73
+ return (
74
+ `agent ${actor.client} ${actor.clientVersion} ` +
75
+ `(mcp pid ${actor.mcpPid ?? "?"}, session ${actor.session || "?"})`
76
+ );
77
+ }
@@ -178,6 +178,12 @@ export interface DenConfig {
178
178
  secrets?: SecretsConfig;
179
179
  /** Environments enabled for this deployment. */
180
180
  environments: Environment[];
181
+ /**
182
+ * The environments that count as production (default: prod, production).
183
+ * Production is an attribute of an env, never a name check: Den refuses to
184
+ * start, stop, scan or exec a remote service whose target env is listed.
185
+ */
186
+ production_envs?: string[];
181
187
  /**
182
188
  * Root directory for local repo discovery, relative to the den directory.
183
189
  * Defaults to ".." (the parent of den), which covers the common layout where
@@ -227,6 +233,11 @@ export interface DenConfig {
227
233
  env_health_urls?: Record<string, string>;
228
234
  /** Security pillar (OWASP ZAP) policy — see readSecurityOwnedHosts. */
229
235
  security?: SecurityConfig;
236
+ /**
237
+ * The MCP server (`<cli> mcp`). Without this block agents get read tools
238
+ * only; `actions` opts in to named actions. See readMcpConfig.
239
+ */
240
+ mcp?: McpConfig;
230
241
  /**
231
242
  * Shared test accounts (email + secrets-backend item per role) the test
232
243
  * pillars log in as — see src/shared/auth/test-accounts.ts.
@@ -299,6 +310,16 @@ export interface UserConfig {
299
310
  * local and never get committed. Merged field-wise over the team base.
300
311
  */
301
312
  release?: Partial<ReleaseConfig>;
313
+ /**
314
+ * Graph layout preferences (direction, grouping, card density, panel
315
+ * width), set from the Local Dev graph. See src/ui/server/graph-prefs.ts.
316
+ */
317
+ graph?: {
318
+ direction?: string;
319
+ group?: string;
320
+ density?: string;
321
+ panelWidth?: number;
322
+ };
302
323
  }
303
324
 
304
325
  const ALL_ENVIRONMENTS: Environment[] = [
@@ -311,6 +332,11 @@ const ALL_ENVIRONMENTS: Environment[] = [
311
332
  "production",
312
333
  ];
313
334
 
335
+ /** Den's built-in environment names (used when the org config lists none). */
336
+ export function builtinEnvironments(): Environment[] {
337
+ return [...ALL_ENVIRONMENTS];
338
+ }
339
+
314
340
  const DEFAULT_CONFIG: DenConfig = {
315
341
  environments: ALL_ENVIRONMENTS,
316
342
  };
@@ -388,6 +414,7 @@ export function readDenConfig(): DenConfig {
388
414
  environments: envs.filter((e): e is Environment =>
389
415
  ALL_ENVIRONMENTS.includes(e as Environment),
390
416
  ),
417
+ production_envs: parsed?.production_envs,
391
418
  repos_root: parsed?.repos_root,
392
419
  github_org: parsed?.github_org,
393
420
  orgs: parsed?.orgs,
@@ -398,6 +425,7 @@ export function readDenConfig(): DenConfig {
398
425
  service_env: parsed?.service_env,
399
426
  env_health_urls: parsed?.env_health_urls,
400
427
  security: parsed?.security,
428
+ mcp: parsed?.mcp,
401
429
  test_accounts: parsed?.test_accounts,
402
430
  defaults: parsed?.defaults,
403
431
  secrets: parsed?.secrets,
@@ -428,6 +456,39 @@ export function getReposRoot(): string {
428
456
  return path.resolve(ROOT, config.repos_root ?? "..");
429
457
  }
430
458
 
459
+ const DEFAULT_PRODUCTION_ENVS = ["prod", "production"];
460
+
461
+ /** The envs the org counts as production (`production_envs`). */
462
+ export function productionEnvs(config: DenConfig = readDenConfig()): string[] {
463
+ const listed = config.production_envs;
464
+ return Array.isArray(listed) &&
465
+ listed.every((e): e is string => typeof e === "string")
466
+ ? listed
467
+ : DEFAULT_PRODUCTION_ENVS;
468
+ }
469
+
470
+ /**
471
+ * Whether the org counts `env` as production. An unreadable org config falls
472
+ * back to the default list rather than letting a guard throw.
473
+ */
474
+ export function isProductionEnv(env: string, config?: DenConfig): boolean {
475
+ let listed: string[];
476
+ try {
477
+ listed = productionEnvs(config ?? readDenConfig());
478
+ } catch {
479
+ listed = DEFAULT_PRODUCTION_ENVS;
480
+ }
481
+ return listed.includes(env);
482
+ }
483
+
484
+ /**
485
+ * The org's environment names, for validating manifests: the config's
486
+ * `environments` (Den's built-in list when unset), always with `local`.
487
+ */
488
+ export function orgEnvironments(config: DenConfig = readDenConfig()): string[] {
489
+ return [...new Set<string>(["local", ...config.environments])];
490
+ }
491
+
431
492
  /** True when the `local` environment is enabled for this engineer. */
432
493
  export function isLocalEnabled(): boolean {
433
494
  return readDenConfig().environments.includes("local");
@@ -480,6 +541,67 @@ export function readSecurityAutoTargets(
480
541
  return config.security?.auto_targets === true;
481
542
  }
482
543
 
544
+ // ── MCP config (`<cli> mcp`) ───────────────────────────────────────────────────
545
+
546
+ /** The action tools an org can opt in to. Read tools are always on. */
547
+ export const MCP_ACTIONS = [
548
+ "service_up",
549
+ "service_down",
550
+ "service_restart",
551
+ "workspace_sync",
552
+ "run_tests",
553
+ ] as const;
554
+ export type McpAction = (typeof MCP_ACTIONS)[number];
555
+
556
+ /** `mcp:` in the org config. */
557
+ export interface McpConfig {
558
+ /** Action tools to register; none when absent. */
559
+ actions?: string[];
560
+ /** Environments `run_tests` may target (default [local]; production never). */
561
+ run_tests?: { envs?: string[] };
562
+ /** `remote: true` lets the log tools read non-local sources (e.g. CloudWatch). */
563
+ logs?: { remote?: boolean };
564
+ }
565
+
566
+ /** `mcp:` with its defaults applied. */
567
+ export interface ResolvedMcpConfig {
568
+ actions: McpAction[];
569
+ runTestsEnvs: Environment[];
570
+ logsRemote: boolean;
571
+ }
572
+
573
+ /**
574
+ * The effective MCP config. An unknown action or environment name throws:
575
+ * a typo must not silently turn a guard off (or an action on).
576
+ */
577
+ export function readMcpConfig(
578
+ config: DenConfig = readDenConfig(),
579
+ ): ResolvedMcpConfig {
580
+ const raw = config.mcp ?? {};
581
+ const actions = (Array.isArray(raw.actions) ? raw.actions : []).map((a) => {
582
+ if (!(MCP_ACTIONS as readonly string[]).includes(String(a)))
583
+ throw new Error(
584
+ `mcp.actions: unknown action "${String(a)}" (known: ${MCP_ACTIONS.join(", ")})`,
585
+ );
586
+ return a as McpAction;
587
+ });
588
+ const envList = raw.run_tests?.envs;
589
+ const runTestsEnvs = (Array.isArray(envList) ? envList : ["local"]).map(
590
+ (e) => {
591
+ if (!ALL_ENVIRONMENTS.includes(e as Environment))
592
+ throw new Error(
593
+ `mcp.run_tests.envs: unknown environment "${String(e)}" (known: ${ALL_ENVIRONMENTS.join(", ")})`,
594
+ );
595
+ return e as Environment;
596
+ },
597
+ );
598
+ return {
599
+ actions: [...new Set(actions)],
600
+ runTestsEnvs: [...new Set(runTestsEnvs)],
601
+ logsRemote: raw.logs?.remote === true,
602
+ };
603
+ }
604
+
483
605
  // ── Release config (Release tab) ──────────────────────────────────────────────
484
606
 
485
607
  /**
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Read-only edge status: what `edge status` shows, as data. The CLI prints
3
+ * it; the web server serves it at /api/edge (and through it the MCP
4
+ * `edge_status` tool).
5
+ */
6
+ import * as fs from "fs";
7
+ import * as path from "path";
8
+ import { certNames, edgeDomains, edgeNames, routeUrl } from "./render.js";
9
+ import { caTrusted, containerState } from "./runtime.js";
10
+ import type { EdgeConfig, EdgePorts } from "./types.js";
11
+
12
+ export interface EdgeStatus {
13
+ org: string;
14
+ container: string;
15
+ /** Docker's container state ("running", "exited", …) or "absent". */
16
+ state: string;
17
+ up: boolean;
18
+ /** Published host ports, or null when another proxy fronts the edge. */
19
+ ports: EdgePorts | null;
20
+ network: string;
21
+ domains: string[];
22
+ routes: { name: string; url: string; upstream: string }[];
23
+ /** Names on the issued cert, or the planned ones when none is issued yet. */
24
+ certNames: string[];
25
+ certIssued: boolean;
26
+ /** Whether the CA is trusted on this machine (null: can't tell). */
27
+ caTrusted: boolean | null;
28
+ }
29
+
30
+ export interface EdgeStatusDeps {
31
+ containerState: (name: string) => string;
32
+ caTrusted: (cfg: EdgeConfig) => boolean | null;
33
+ /** Names on the issued cert (its stamp file), or null when none is issued. */
34
+ issuedNames: (cfg: EdgeConfig) => string[] | null;
35
+ }
36
+
37
+ /** The names stamp `ensureCerts` writes beside the cert. */
38
+ export function issuedCertNames(cfg: EdgeConfig): string[] | null {
39
+ const stamp = path.join(cfg.stateDir, "certs", "names.txt");
40
+ if (!fs.existsSync(stamp)) return null;
41
+ return fs.readFileSync(stamp, "utf-8").split("\n").filter(Boolean);
42
+ }
43
+
44
+ const defaultDeps: EdgeStatusDeps = {
45
+ containerState,
46
+ caTrusted,
47
+ issuedNames: issuedCertNames,
48
+ };
49
+
50
+ export function edgeStatus(
51
+ cfg: EdgeConfig,
52
+ deps: EdgeStatusDeps = defaultDeps,
53
+ ): EdgeStatus {
54
+ const n = edgeNames(cfg.org);
55
+ const state = deps.containerState(n.container);
56
+ const issued = deps.issuedNames(cfg);
57
+ return {
58
+ org: cfg.org,
59
+ container: n.container,
60
+ state,
61
+ up: state === "running",
62
+ ports: cfg.ports,
63
+ network: n.network,
64
+ domains: edgeDomains(cfg),
65
+ routes: Object.entries(cfg.routes).map(([name, upstream]) => ({
66
+ name,
67
+ url: routeUrl(name, cfg),
68
+ upstream,
69
+ })),
70
+ certNames: issued ?? certNames(cfg),
71
+ certIssued: issued !== null,
72
+ caTrusted: deps.caTrusted(cfg),
73
+ };
74
+ }
@@ -14,8 +14,18 @@ import * as fs from "fs";
14
14
  import * as path from "path";
15
15
  import * as dns from "dns";
16
16
  import { spawn } from "child_process";
17
- import { HEALTH_CHECK_TIMEOUT_MS, CLI_SPAWN_TIMEOUT_MS } from "../constants.js";
17
+ import {
18
+ HEALTH_CHECK_TIMEOUT_MS,
19
+ HOSTED_HEALTH_CHECK_TIMEOUT_MS,
20
+ CLI_SPAWN_TIMEOUT_MS,
21
+ } from "../constants.js";
18
22
  import { readManifest } from "../manifest/read-manifest.js";
23
+ import {
24
+ expectMatches,
25
+ resolveHealthCheck,
26
+ type Expect,
27
+ type Placement,
28
+ } from "../manifest/placement.js";
19
29
  import { readCatalog } from "./catalog.js";
20
30
  import {
21
31
  getActiveWorkspace,
@@ -42,6 +52,9 @@ export interface ServiceHealthResult {
42
52
  branch: string | null;
43
53
  health: "ok" | "error" | "unknown";
44
54
  healthDetail?: string;
55
+ /** Where the service runs for the env checked (R13). */
56
+ runs?: Placement["runs"];
57
+ target?: Placement["target"];
45
58
  }
46
59
 
47
60
  // ── Name resolution ───────────────────────────────────────────────────────────
@@ -288,7 +301,16 @@ const LOCALHOST_FALLBACK_TIMEOUT_MS = 1_500;
288
301
  export async function pingHttp(
289
302
  url: string,
290
303
  timeoutMs: number = HEALTH_CHECK_TIMEOUT_MS,
304
+ expect?: Expect,
291
305
  ): Promise<PingResult> {
306
+ // A remote target's probe (target.url): up only on the expected codes, and
307
+ // never a localhost fallback (it is not on this machine).
308
+ if (expect !== undefined) {
309
+ const r = await pingOnce(url, timeoutMs);
310
+ return r.status === undefined
311
+ ? r
312
+ : { ok: expectMatches(r.status, expect), status: r.status };
313
+ }
292
314
  const result = await pingOnce(url, timeoutMs);
293
315
  if (!result.ok) {
294
316
  // Fall back to localhost for Docker-style hostnames regardless of how the
@@ -432,12 +454,25 @@ export async function checkServiceHealth(
432
454
  getFocusedWorkspaceName() ?? "default",
433
455
  )
434
456
  : null;
435
- const healthCfg =
436
- manifest.services?.[resolved.subsystem]?.health?.[env] ??
437
- (composeHealth
438
- ? { type: "shell" as const, cmd: composeHealth }
439
- : undefined);
457
+ const plan = resolveHealthCheck(
458
+ manifest,
459
+ resolved.subsystem,
460
+ env,
461
+ composeHealth,
462
+ );
463
+ const placed: Pick<ServiceHealthResult, "runs" | "target"> = {
464
+ runs: plan.placement.runs,
465
+ target: plan.placement.target,
466
+ };
467
+ const healthCfg = plan.check;
440
468
  if (!healthCfg) {
469
+ if (plan.placement.runs === "remote")
470
+ return {
471
+ ...base,
472
+ ...placed,
473
+ health: "unknown",
474
+ healthDetail: "no health check configured",
475
+ };
441
476
  // Conventional local discovery: opctl exposes each system on the host at
442
477
  // http://<repo> (port 80), and stack services serve /healthz. When the
443
478
  // PRIMARY service (entrypoint, or a repo's only service) declares no local
@@ -460,6 +495,7 @@ export async function checkServiceHealth(
460
495
  if (probe.ok && probe.status !== undefined && probe.status < 300) {
461
496
  return {
462
497
  ...base,
498
+ ...placed,
463
499
  health: "ok",
464
500
  healthDetail: `conventional ${conventional}`,
465
501
  };
@@ -467,6 +503,7 @@ export async function checkServiceHealth(
467
503
  }
468
504
  return {
469
505
  ...base,
506
+ ...placed,
470
507
  health: "unknown",
471
508
  healthDetail: "no health check configured",
472
509
  };
@@ -475,10 +512,17 @@ export async function checkServiceHealth(
475
512
  const result =
476
513
  healthCfg.type === "shell"
477
514
  ? await pingShell(healthCfg.cmd, {}, resolved.repoPath)
478
- : await pingHttp(healthCfg.url);
515
+ : await pingHttp(
516
+ healthCfg.url,
517
+ env === "local" && plan.placement.runs !== "remote"
518
+ ? HEALTH_CHECK_TIMEOUT_MS
519
+ : HOSTED_HEALTH_CHECK_TIMEOUT_MS,
520
+ plan.expect,
521
+ );
479
522
 
480
523
  return {
481
524
  ...base,
525
+ ...placed,
482
526
  health: result.ok ? "ok" : "error",
483
527
  healthDetail:
484
528
  result.error ??
@@ -554,8 +598,10 @@ export function rollupRepoHealth(
554
598
  ? rows.find((r) => r.subsystem === entrypoint)
555
599
  : undefined;
556
600
  if (epRow) {
557
- // Show the entrypoint's health under the clean repo-name address.
601
+ // Show the entrypoint's health under the clean repo-name address. A
602
+ // remote service is not a sidecar: it keeps its own row (R13).
558
603
  out.push({ ...epRow, address: repoName });
604
+ out.push(...rows.filter((r) => r !== epRow && r.runs === "remote"));
559
605
  } else {
560
606
  // No entrypoint → infra provider; keep the subsystem breakdown.
561
607
  out.push(...rows);
@@ -8,6 +8,12 @@ import { deriveGraph } from "./derivation/derive-graph.js";
8
8
  import type { DerivedGraph } from "./derivation/types.js";
9
9
  import { getWorkspaceServices } from "./service-runner.js";
10
10
  import { readOrg, type Org } from "../org.js";
11
+ import {
12
+ rollupPlacement,
13
+ servicePlacement,
14
+ type Placement,
15
+ type RollupPlacement,
16
+ } from "../manifest/placement.js";
11
17
 
12
18
  export function loadApplications(workspace?: string): Application[] {
13
19
  const services = getWorkspaceServices(workspace);
@@ -47,6 +53,12 @@ export interface ApplicationView extends Application {
47
53
  addresses: string[];
48
54
  /** Repos the team config lists for it that are not in this workspace. */
49
55
  absent: string[];
56
+ /** address → where that service runs locally, and where a remote one is. */
57
+ placements: Record<string, Placement>;
58
+ /** local | remote | hybrid | none, over its services (R13). */
59
+ placement: RollupPlacement;
60
+ /** "shop-frontend local · shop-core remote (dev)" */
61
+ summary: string;
50
62
  }
51
63
 
52
64
  /** Org > Application > Repo > Services for a workspace (CLI `apps`, web UI). */
@@ -55,9 +67,18 @@ export function loadApplicationTree(workspace?: string): {
55
67
  applications: ApplicationView[];
56
68
  } {
57
69
  const byRepo = new Map<string, string[]>();
70
+ const placed = new Map<
71
+ string,
72
+ Placement & { repo: string; service: string }
73
+ >();
58
74
  for (const s of getWorkspaceServices(workspace)) {
59
75
  if (!byRepo.has(s.repoName)) byRepo.set(s.repoName, []);
60
76
  byRepo.get(s.repoName)!.push(s.address);
77
+ placed.set(s.address, {
78
+ repo: s.repoName,
79
+ service: s.subsystem,
80
+ ...servicePlacement(readManifest(s.repoPath), s.subsystem, "local"),
81
+ });
61
82
  }
62
83
  const config = readDenConfig().applications ?? {};
63
84
  const org = readOrg();
@@ -67,11 +88,20 @@ export function loadApplicationTree(workspace?: string): {
67
88
  const services = Object.fromEntries(
68
89
  a.repos.map((r) => [r, byRepo.get(r) ?? []]),
69
90
  );
91
+ const addresses = Object.values(services).flat();
92
+ const rows = addresses.flatMap((addr) => placed.get(addr) ?? []);
70
93
  return {
71
94
  ...a,
72
95
  services,
73
- addresses: Object.values(services).flat(),
96
+ addresses,
74
97
  absent: (config[a.name]?.repos ?? []).filter((r) => !byRepo.has(r)),
98
+ placements: Object.fromEntries(
99
+ addresses.flatMap((addr) => {
100
+ const p = placed.get(addr);
101
+ return p ? [[addr, { runs: p.runs, target: p.target }]] : [];
102
+ }),
103
+ ),
104
+ ...rollupPlacement(rows),
75
105
  };
76
106
  }),
77
107
  };
@@ -2,8 +2,9 @@
2
2
  * Live log tailing for a local service — the CLI-side counterpart of the web
3
3
  * /api/service/taillog SSE endpoint. Source resolution, in order:
4
4
  * 1. The service's manifest `logs.local` shell command (e.g. `docker logs -f`)
5
- * 2. Conventional container derivation: a running `opctl_<base>_<hash>`
6
- * container → `docker logs -f` — real runtime logs with zero manifest config
5
+ * 2. The service's running container → `docker logs -f`: a compose stack's
6
+ * `<stack>-<workspace>-<service>-1`, else an `opctl_<base>_<hash>` one —
7
+ * real runtime logs with zero manifest config
7
8
  * 3. The den daemon startup log (~/.den/logs/<repo>/<subsystem>.log) —
8
9
  * last resort, covers host-run processes with no container
9
10
  * A source whose command exits within the quick-exit window (container not
@@ -13,7 +14,7 @@
13
14
  import * as fs from "fs";
14
15
  import { DAEMON_QUICK_EXIT_MS, LIVE_TAIL_LINES } from "../constants.js";
15
16
  import { readManifest } from "../manifest/read-manifest.js";
16
- import { findConventionalContainer } from "./opctl.js";
17
+ import { findServiceContainer } from "./service-container.js";
17
18
  import {
18
19
  getServiceLogPath,
19
20
  spawnStreaming,
@@ -85,7 +86,7 @@ export function tailSourceCandidates(
85
86
  export function resolveTailSources(resolved: ResolvedService): TailSource[] {
86
87
  const manifest = readManifest(resolved.repoPath);
87
88
  const logSource = manifest?.services?.[resolved.subsystem]?.logs?.local;
88
- const container = findConventionalContainer(
89
+ const container = findServiceContainer(
89
90
  resolved.repoName,
90
91
  resolved.repoPath,
91
92
  resolved.subsystem,
@@ -100,7 +100,8 @@ export function isOpctlContainerFor(name: string, base: string): boolean {
100
100
  return suffix.length > 0 && !suffix.includes("_");
101
101
  }
102
102
 
103
- function runningDockerNames(): string[] {
103
+ /** Names of the running docker containers (throws when docker is unavailable). */
104
+ export function runningDockerNames(): string[] {
104
105
  return execSync('docker ps --format "{{.Names}}"', {
105
106
  encoding: "utf-8",
106
107
  stdio: ["ignore", "pipe", "ignore"],
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The running container that holds a service's logs. A compose-stack service
3
+ * runs as `<stack>-<workspace>-<service>-1` (the stacks naming helpers); any
4
+ * other container-run service is found by opctl's naming convention.
5
+ */
6
+ import { readManifest } from "../manifest/read-manifest.js";
7
+ import { composeServiceContainers } from "../stacks/manifest-commands.js";
8
+ import { resolveStackWorkspaces } from "../stacks/status.js";
9
+ import { findConventionalContainer, runningDockerNames } from "./opctl.js";
10
+
11
+ /** The stack's container for `subsystem` when one is running, else null. */
12
+ export function findStackServiceContainer(
13
+ repoName: string,
14
+ repoPath: string | undefined,
15
+ subsystem: string | undefined,
16
+ ): string | null {
17
+ if (!repoPath || !subsystem) return null;
18
+ const manifest = readManifest(repoPath);
19
+ if (!manifest?.compose) return null;
20
+ let candidates: string[];
21
+ let running: string[];
22
+ try {
23
+ const { stackWorkspace } = resolveStackWorkspaces();
24
+ candidates = composeServiceContainers(
25
+ manifest,
26
+ subsystem,
27
+ repoName,
28
+ stackWorkspace,
29
+ );
30
+ running = runningDockerNames();
31
+ } catch {
32
+ return null; // bad workspace name, or docker unavailable
33
+ }
34
+ return candidates.find((c) => running.includes(c)) ?? null;
35
+ }
36
+
37
+ /** A service's running container: its compose stack's, else opctl's. */
38
+ export function findServiceContainer(
39
+ repoName: string,
40
+ repoPath?: string,
41
+ subsystem?: string,
42
+ ): string | null {
43
+ return (
44
+ findStackServiceContainer(repoName, repoPath, subsystem) ??
45
+ findConventionalContainer(repoName, repoPath, subsystem)
46
+ );
47
+ }
@@ -16,7 +16,8 @@ import {
16
16
  spawnInteractive,
17
17
  getServiceLogPath,
18
18
  } from "../process/spawn-streaming.js";
19
- import { readUserConfig } from "../den-config.js";
19
+ import { isProductionEnv, readUserConfig } from "../den-config.js";
20
+ import { placementRefusal, servicePlacement } from "../manifest/placement.js";
20
21
  import { lookupTab, recordTab, forgetTab } from "../cli-tabs.js";
21
22
  import {
22
23
  closeTab,
@@ -1056,6 +1057,20 @@ export function runServiceCommand(
1056
1057
 
1057
1058
  const { repoPath, subsystem } = resolved;
1058
1059
 
1060
+ // R13: Den never runs a command on a remote service. A down stops nothing
1061
+ // here, which is a success; anything else is refused.
1062
+ const refusal = placementRefusal(
1063
+ resolved.address,
1064
+ servicePlacement(readManifest(repoPath), subsystem, "local"),
1065
+ (e) => isProductionEnv(e) === true,
1066
+ command === "down" ? "stop" : "start",
1067
+ );
1068
+ if (refusal) {
1069
+ onLine(refusal);
1070
+ closeAsync(command === "down" ? 0 : 1);
1071
+ return dummy();
1072
+ }
1073
+
1059
1074
  // ── Up: share-if-identical / hard-block on conflict ─────────────────────────
1060
1075
  if (command === "up" || command === "restart") {
1061
1076
  let existing = findDaemonByAddress(resolved.address);