@monster-orange/den 0.2.6 → 0.2.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.
@@ -3,7 +3,7 @@
3
3
  * Display test results in CLI
4
4
  */
5
5
 
6
- import Table from "cli-table3";
6
+ import { newTable } from "./table.js";
7
7
  import chalk from "chalk";
8
8
  import { resultReader } from "../../shared/results/result-reader.js";
9
9
  import type {
@@ -40,7 +40,7 @@ export class ResultsViewer {
40
40
  for (const pillar of pillars) {
41
41
  console.log(chalk.bold(`\n${this.formatPillarName(pillar)}:`));
42
42
 
43
- const table = new Table({
43
+ const table = newTable({
44
44
  head: [
45
45
  chalk.cyan("Environment"),
46
46
  chalk.cyan("Total"),
@@ -115,7 +115,7 @@ export class ResultsViewer {
115
115
  return;
116
116
  }
117
117
 
118
- const table = new Table({
118
+ const table = newTable({
119
119
  head: [
120
120
  chalk.cyan("Test"),
121
121
  chalk.cyan("Status"),
@@ -191,7 +191,7 @@ export class ResultsViewer {
191
191
  return;
192
192
  }
193
193
 
194
- const table = new Table({
194
+ const table = newTable({
195
195
  head: [
196
196
  chalk.cyan("Journey"),
197
197
  chalk.cyan("Verdict"),
@@ -281,7 +281,7 @@ export class ResultsViewer {
281
281
  return;
282
282
  }
283
283
 
284
- const table = new Table({
284
+ const table = newTable({
285
285
  head: [
286
286
  chalk.cyan("Test"),
287
287
  chalk.cyan("Runs"),
@@ -0,0 +1,20 @@
1
+ /**
2
+ * cli-table3 colours its borders (grey) and head cells (its own default, or
3
+ * the `style` given) through @colors/colors, which ignores Den's colour
4
+ * detection: piped `status` output carried ANSI codes from the table alone.
5
+ * Every CLI table goes through here, and takes no colour when chalk has none
6
+ * (not a TTY, NO_COLOR, FORCE_COLOR=0).
7
+ */
8
+ import chalk from "chalk";
9
+ import Table from "cli-table3";
10
+
11
+ export function newTable(
12
+ opts: Table.TableConstructorOptions = {},
13
+ colour: boolean = chalk.level > 0,
14
+ ): Table.Table {
15
+ if (colour) return new Table(opts);
16
+ return new Table({
17
+ ...opts,
18
+ style: { ...(opts.style ?? {}), head: [], border: [] },
19
+ });
20
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Bare `<cli> validate` in the distribution root: every manifest the
3
+ * distribution uses, without cloning anything.
4
+ *
5
+ * - Each repo of every workspace, once per distinct checkout path: its
6
+ * layered manifest (own or held, then the override, then the merge).
7
+ * - A repo with no checkout on disk (a managed clone not made yet, a
8
+ * dev-managed path that is gone) falls back to its held manifest, marked
9
+ * "held only, no checkout"; with no held manifest either it is a warning,
10
+ * not a failure.
11
+ * - Each held manifest (and override) in manifests/ that no repo above used.
12
+ *
13
+ * The layered validator comes in as `validate` (validateManifestLayers), so
14
+ * this file stays under the typecheck gate (see validate-layers.ts).
15
+ *
16
+ * `--held` checks only manifests/, as if nothing were checked out: a result
17
+ * that does not depend on the machine (an upgrade gate).
18
+ */
19
+ import * as fs from "fs";
20
+ import * as path from "path";
21
+ import { product } from "../../shared/product.js";
22
+ import { readCatalog } from "../../shared/environment-manager/catalog.js";
23
+ import {
24
+ readWorkspacesFile,
25
+ resolveServiceSource,
26
+ } from "../../shared/environment-manager/workspaces.js";
27
+ import {
28
+ heldManifestsDir,
29
+ overrideSuffix,
30
+ } from "../../shared/manifest/read-manifest.js";
31
+ import type { LayeredValidation } from "../../shared/manifest/validate-layers.js";
32
+
33
+ export interface RepoValidation {
34
+ repo: string;
35
+ /** The checkout validated, or null when only manifests/ was read. */
36
+ checkout: string | null;
37
+ /** Workspaces that use this checkout. */
38
+ workspaces: string[];
39
+ /** own | held, plus "+ override" when one layered on. */
40
+ source: string;
41
+ /** Set when there was no checkout and the held manifest stood in. */
42
+ note?: string;
43
+ /** No checkout and no held manifest: reported, not failed. */
44
+ missing?: boolean;
45
+ result: LayeredValidation;
46
+ }
47
+
48
+ /**
49
+ * A path that never exists, named after the repo, so the layer lookup finds
50
+ * only manifests/<repo>… and never a checkout's own manifest.
51
+ */
52
+ function heldOnlyPath(repo: string): string {
53
+ return path.join(heldManifestsDir(), ".held-only", repo);
54
+ }
55
+
56
+ function sourceOf(result: LayeredValidation): string {
57
+ const base = result.layers.find(
58
+ (l) => l.layer === "repo" || l.layer === "held",
59
+ );
60
+ const own = base?.layer === "repo" ? "own" : base ? "held" : "none";
61
+ return result.layers.some((l) => l.layer === "override")
62
+ ? `${own} + override`
63
+ : own;
64
+ }
65
+
66
+ /** The repo keys manifests/ holds a manifest or an override for. */
67
+ function heldKeys(): string[] {
68
+ const dir = heldManifestsDir();
69
+ if (!fs.existsSync(dir)) return [];
70
+ const yml = product().manifestYml;
71
+ const over = overrideSuffix();
72
+ const keys = new Set<string>();
73
+ for (const name of fs.readdirSync(dir)) {
74
+ if (name.endsWith(over)) keys.add(name.slice(0, -over.length));
75
+ else if (name.endsWith(yml)) keys.add(name.slice(0, -yml.length));
76
+ }
77
+ return [...keys].sort();
78
+ }
79
+
80
+ type Validate = (repoPath: string) => LayeredValidation;
81
+
82
+ function heldOnly(repo: string, validate: Validate): RepoValidation {
83
+ const result = validate(heldOnlyPath(repo));
84
+ return {
85
+ repo,
86
+ checkout: null,
87
+ workspaces: [],
88
+ source: sourceOf(result),
89
+ result,
90
+ };
91
+ }
92
+
93
+ export function validateAll(opts: {
94
+ held?: boolean;
95
+ validate: Validate;
96
+ }): RepoValidation[] {
97
+ const { validate } = opts;
98
+ if (opts.held)
99
+ return heldKeys().map((k) => {
100
+ const r = heldOnly(k, validate);
101
+ // An override with no held base layers onto the repo's own manifest,
102
+ // which --held deliberately does not read: noted, not failed.
103
+ const overrideOnly =
104
+ r.result.layers.length > 0 &&
105
+ r.result.layers.every((l) => l.layer === "override");
106
+ return overrideOnly
107
+ ? {
108
+ ...r,
109
+ source: "override",
110
+ missing: true,
111
+ note: "override only: its base is the repo's own manifest (not read with --held)",
112
+ }
113
+ : r;
114
+ });
115
+
116
+ const file = readWorkspacesFile();
117
+ const catalog = readCatalog();
118
+ // repo + checkout path → the workspaces that use it.
119
+ const seen = new Map<
120
+ string,
121
+ { repo: string; path: string; workspaces: string[] }
122
+ >();
123
+ for (const [ws, config] of Object.entries(file.workspaces)) {
124
+ for (const address of Object.keys(config.services ?? {})) {
125
+ const src = resolveServiceSource(address, ws, config, catalog);
126
+ const repo = src?.repoName ?? address.split("/")[0];
127
+ const p = src?.repoPath ? path.resolve(src.repoPath) : "";
128
+ const key = `${repo}\0${p}`;
129
+ const hit = seen.get(key);
130
+ if (hit) {
131
+ if (!hit.workspaces.includes(ws)) hit.workspaces.push(ws);
132
+ } else seen.set(key, { repo, path: p, workspaces: [ws] });
133
+ }
134
+ }
135
+
136
+ const out: RepoValidation[] = [];
137
+ const used = new Set<string>();
138
+ for (const { repo, path: p, workspaces } of seen.values()) {
139
+ used.add(repo);
140
+ if (p && fs.existsSync(p)) {
141
+ const result = validate(p);
142
+ out.push({
143
+ repo,
144
+ checkout: p,
145
+ workspaces,
146
+ source: sourceOf(result),
147
+ result,
148
+ ...(result.layers.length === 0 ? { missing: true } : {}),
149
+ });
150
+ continue;
151
+ }
152
+ const held = heldOnly(repo, validate);
153
+ out.push({
154
+ ...held,
155
+ checkout: p || null,
156
+ workspaces,
157
+ ...(held.result.layers.length
158
+ ? { note: "held only, no checkout" }
159
+ : { missing: true, note: "no checkout and no held manifest" }),
160
+ });
161
+ }
162
+ for (const key of heldKeys())
163
+ if (!used.has(key))
164
+ out.push({ ...heldOnly(key, validate), note: "held, in no workspace" });
165
+ return out;
166
+ }
167
+
168
+ /** Whether the run fails: any layer invalid, ignoring `missing` entries. */
169
+ export function validateAllFailed(results: RepoValidation[]): boolean {
170
+ return results.some((r) => !r.missing && !r.result.valid);
171
+ }
@@ -5,6 +5,7 @@
5
5
  *
6
6
  * stdout carries the protocol only: diagnostics go to stderr.
7
7
  */
8
+ import { z } from "zod";
8
9
  import { randomUUID } from "crypto";
9
10
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
11
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -185,7 +186,29 @@ export async function runOneShot(
185
186
  return 0;
186
187
  }
187
188
  const ctx: ToolContext = { api: opts.api, cfg: opts.cfg, cli: opts.cli };
188
- const r = await callTool(shot.tool, shot.args, ctx);
189
+ // The stdio server validates arguments against each tool's schema (the
190
+ // SDK does it); a one-shot must too, and strictly: it is typed by hand, so
191
+ // an unknown or misspelt key is an error, not silently dropped.
192
+ let args = shot.args;
193
+ const tool = toolsFor(opts.cfg).find((t) => t.name === shot.tool);
194
+ if (tool) {
195
+ const parsed = z.object(tool.shape).strict().safeParse(args);
196
+ if (!parsed.success) {
197
+ const err = opts.err ?? ((t: string) => process.stderr.write(t));
198
+ const lines = parsed.error.issues.map((i) => {
199
+ const at = i.path.length ? i.path.join(".") : "(arguments)";
200
+ return ` ${at}: ${i.message}`;
201
+ });
202
+ const keys = Object.keys(tool.shape);
203
+ err(
204
+ `--call ${shot.tool}: invalid arguments\n${lines.join("\n")}\n` +
205
+ ` takes: ${keys.length ? keys.join(", ") : "no arguments"}\n`,
206
+ );
207
+ return 2;
208
+ }
209
+ args = parsed.data as Record<string, unknown>;
210
+ }
211
+ const r = await callTool(shot.tool, args, ctx);
189
212
  out(r.text.endsWith("\n") ? r.text : `${r.text}\n`);
190
213
  return r.isError ? 1 : 0;
191
214
  }
@@ -11,9 +11,11 @@
11
11
  * so an "error" health result is only acted on after a SECOND
12
12
  * consecutive "error" check separated by a retry delay. "unknown"
13
13
  * results and thrown checks always keep the entry (err on the side of
14
- * keeping). A daemon whose address no longer resolves to any
15
- * workspace/catalog service is process-death-equivalent evidence and is
16
- * removed directly.
14
+ * keeping). A daemon this root registered whose address no longer resolves
15
+ * to any workspace/catalog service is process-death-equivalent evidence and
16
+ * is removed directly. Records another root registered (all roots of a
17
+ * product share the file) are never touched, and an unstamped legacy record
18
+ * is not removed for being unresolvable.
17
19
  */
18
20
 
19
21
  import type { ServiceHealthResult } from "../../shared/environment-manager/health.js";
@@ -41,6 +43,12 @@ export interface DaemonReaperDeps {
41
43
  delay: (ms: number) => Promise<void>;
42
44
  /** Gap between the first "error" result and the confirming re-check. */
43
45
  retryDelayMs: number;
46
+ /**
47
+ * Which root wrote the record (`daemonRootOf`). Every root of a product
48
+ * shares one daemons file: another root's records are never swept here,
49
+ * and an unstamped (pre-0.2.7) one is not dropped for being unresolvable.
50
+ */
51
+ rootOf: (daemon: DaemonRecord) => "here" | "other" | "unstamped";
44
52
  }
45
53
 
46
54
  export interface DaemonReapResult {
@@ -92,9 +100,19 @@ export async function reapDeadDaemons(
92
100
  await Promise.allSettled(
93
101
  deps.listDaemons().map(async (daemon) => {
94
102
  try {
103
+ const root = deps.rootOf(daemon);
104
+ if (root === "other") {
105
+ kept.push(daemon.address);
106
+ return;
107
+ }
95
108
  const service = resolveForDaemon(deps, daemon);
96
109
  if (!service) {
97
- // No longer in any owning workspace/catalog — nothing could restart it.
110
+ // No longer in any owning workspace/catalog — nothing could restart
111
+ // it. An unstamped record may be another root's, so it stays.
112
+ if (root === "unstamped") {
113
+ kept.push(daemon.address);
114
+ return;
115
+ }
98
116
  deps.unregisterDaemon(daemon.address);
99
117
  removed.push(daemon.address);
100
118
  return;
@@ -80,6 +80,7 @@ import {
80
80
  checkServiceHealth,
81
81
  loadDaemonsFromFile,
82
82
  pruneDeadDaemonsAsync,
83
+ daemonRootOf,
83
84
  isInteractiveServiceCommand,
84
85
  rewriteLegacyOpctlKillCommandForContainer,
85
86
  shouldDetachServiceCommand,
@@ -436,6 +437,11 @@ import {
436
437
  send404,
437
438
  } from "./http-helpers.js";
438
439
  import { orgOf, listOrgs } from "../../shared/manifest/service-links.js";
440
+ import {
441
+ readOrgContext,
442
+ resolveAllWorkspaceOrgs,
443
+ withOrgMembership,
444
+ } from "../../shared/environment-manager/workspace-org.js";
439
445
  import {
440
446
  readLatestSummary,
441
447
  securityReportsDir,
@@ -684,9 +690,18 @@ async function handleRequest(
684
690
 
685
691
  // ── API: environments ──────────────────────────────────────────────────────
686
692
  // Orgs the catalog's repos belong to, with config/den.yml display names.
693
+ // Each entry also says whether it is the default org (`github_org`) and
694
+ // which workspaces on this machine belong to it (ORG-1a). Nothing else
695
+ // changes: same entries, order and fields (ORG-SPEC C3).
687
696
  if (pathname === "/api/orgs") {
688
- const catalog = readCatalog();
689
- const orgs = listOrgs(catalog, readDenConfig().orgs ?? {});
697
+ const ctx = readOrgContext();
698
+ const catalog = ctx.catalog;
699
+ const orgs = withOrgMembership(
700
+ listOrgs(catalog, ctx.config.orgs ?? {}),
701
+ resolveAllWorkspaceOrgs(),
702
+ ctx.file,
703
+ ctx.config,
704
+ );
690
705
  if (url.searchParams.get("repos") !== "1") return sendJson(res, orgs);
691
706
  const repos = Object.fromEntries(
692
707
  Object.keys(catalog).map((r) => [r, orgOf(r, catalog)]),
@@ -2375,7 +2390,14 @@ async function handleRequest(
2375
2390
  // ── API: den config ────────────────────────────────────────────────────
2376
2391
  if (pathname === `/api/${product().cli}/config` && req.method === "GET") {
2377
2392
  // `org` resolved (id defaults to edge.org) for the header's org label.
2378
- return sendJson(res, { ...readDenConfig(), org: readOrg() });
2393
+ const config = readDenConfig();
2394
+ // `default_org` (ORG-1a) is the default work org, `github_org`; `org`
2395
+ // stays the instance org (readOrg).
2396
+ return sendJson(res, {
2397
+ ...config,
2398
+ org: readOrg(),
2399
+ default_org: config.github_org?.trim() || null,
2400
+ });
2379
2401
  }
2380
2402
 
2381
2403
  // ── API: server info + staleness (drives the header "stale server" pill) ────
@@ -3184,7 +3206,7 @@ void (async () => {
3184
3206
  await new Promise<void>((resolve) =>
3185
3207
  setTimeout(resolve, DAEMON_SHUTDOWN_GRACE_MS),
3186
3208
  );
3187
- await reapDeadDaemons({
3209
+ const reaped = await reapDeadDaemons({
3188
3210
  listDaemons,
3189
3211
  resolveService,
3190
3212
  getActiveWorkspaces,
@@ -3192,7 +3214,12 @@ void (async () => {
3192
3214
  unregisterDaemon,
3193
3215
  delay: (ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)),
3194
3216
  retryDelayMs: DAEMON_RETRY_DELAY_MS,
3217
+ rootOf: daemonRootOf,
3195
3218
  });
3219
+ if (reaped.removed.length > 0)
3220
+ console.error(
3221
+ `Reaped ${reaped.removed.length} stopped daemon(s): ${reaped.removed.join(", ")}`,
3222
+ );
3196
3223
  })();
3197
3224
 
3198
3225
  const prunedSnapshots = pruneDeadSnapshots();
@@ -19,13 +19,16 @@ import { execFile } from "child_process";
19
19
  import { promisify } from "util";
20
20
  import { GH_API_TIMEOUT_MS } from "../../shared/constants.js";
21
21
  import {
22
+ readExplicitReleaseOrg,
22
23
  readReleaseConfig,
24
+ releaseOwnerFor,
23
25
  type ReleaseConfig,
24
26
  type ReleaseRepo,
25
27
  type ResolvedReleaseTarget,
26
28
  resolveReleaseTarget,
27
29
  } from "../../shared/den-config.js";
28
30
  import { readCatalog } from "../../shared/environment-manager/catalog.js";
31
+ import { orgOf } from "../../shared/manifest/service-links.js";
29
32
  import { listReposWithManifests } from "../../shared/manifest/workspace-manifests.js";
30
33
 
31
34
  import { product } from "../../shared/product.js";
@@ -262,13 +265,23 @@ export class ReleaseService {
262
265
  };
263
266
  }
264
267
 
265
- private org(cfg = this.config()): string {
266
- return cfg.org ?? "";
268
+ /**
269
+ * The owner a repo is released from (REL-1): an explicit `release.org`
270
+ * (den.yml or user.yml) wins; otherwise the repo's own non-empty catalog
271
+ * owner, so a client org's repo releases from its own org; `github_org`
272
+ * (readReleaseConfig's fallback for `cfg.org`) last.
273
+ */
274
+ private ownerFor(repo: string, cfg: ReleaseConfig): string {
275
+ return releaseOwnerFor({
276
+ explicitOrg: readExplicitReleaseOrg(),
277
+ catalogOwner: orgOf(repo, readCatalog()),
278
+ githubOrg: cfg.org,
279
+ });
267
280
  }
268
281
 
269
282
  slug(repo: string, cfg = this.config()): string {
270
283
  const gh = cfg.repos.find((r) => r.name === repo)?.gh;
271
- return gh && gh.includes("/") ? gh : `${this.org(cfg)}/${repo}`;
284
+ return gh && gh.includes("/") ? gh : `${this.ownerFor(repo, cfg)}/${repo}`;
272
285
  }
273
286
 
274
287
  /**
@@ -65,6 +65,7 @@ import {
65
65
  getWaitDeps,
66
66
  } from "../../shared/environment-manager/dag.js";
67
67
  import { deriveGraph } from "../../shared/environment-manager/derivation/derive-graph.js";
68
+ import { githubOrgFor } from "../../shared/environment-manager/workspace-org.js";
68
69
  import {
69
70
  deriveStartTargets,
70
71
  filterClosureForStart,
@@ -2158,7 +2159,9 @@ export async function handleWorkspaceRoutes(
2158
2159
  // flow writes one), which used to read as "not in the catalog" and
2159
2160
  // skip the clone entirely. Derive the conventional slug instead, and
2160
2161
  // persist it so every other flow (CLI up/setup) stops hitting this.
2161
- const ghSlug = resolveRepoGhSlug(svc.repoName);
2162
+ const ghSlug = resolveRepoGhSlug(svc.repoName, readCatalog(), () =>
2163
+ githubOrgFor(ws.name),
2164
+ );
2162
2165
  if (!readCatalog()[svc.repoName]?.gh) {
2163
2166
  upsertCatalogEntry(svc.repoName, { gh: ghSlug });
2164
2167
  }