@monster-orange/den 0.2.6 → 0.2.8

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 (37) hide show
  1. package/CHANGELOG.md +225 -0
  2. package/bin/den +9 -0
  3. package/docs/distributions.md +205 -6
  4. package/package.json +1 -1
  5. package/service/install.sh +41 -2
  6. package/src/shared/agent-actor.ts +38 -3
  7. package/src/shared/config-error.ts +33 -0
  8. package/src/shared/den-config.ts +84 -5
  9. package/src/shared/environment-manager/catalog.ts +61 -3
  10. package/src/shared/environment-manager/default-workspace.ts +53 -14
  11. package/src/shared/environment-manager/git-remote.ts +49 -0
  12. package/src/shared/environment-manager/health.ts +51 -8
  13. package/src/shared/environment-manager/service-runner.ts +51 -5
  14. package/src/shared/environment-manager/workspace-manager.ts +69 -26
  15. package/src/shared/environment-manager/workspace-org.ts +279 -0
  16. package/src/shared/manifest/service-links.ts +20 -8
  17. package/src/shared/org-sources.ts +309 -0
  18. package/src/shared/types/index.ts +19 -2
  19. package/src/ui/cli/clone-plan.ts +374 -0
  20. package/src/ui/cli/flag-parser.ts +57 -3
  21. package/src/ui/cli/index.ts +346 -48
  22. package/src/ui/cli/org-command.ts +172 -0
  23. package/src/ui/cli/results-viewer.ts +5 -5
  24. package/src/ui/cli/table.ts +20 -0
  25. package/src/ui/cli/up-args.ts +15 -0
  26. package/src/ui/cli/validate-all.ts +171 -0
  27. package/src/ui/mcp/scope.ts +277 -0
  28. package/src/ui/mcp/server.ts +94 -9
  29. package/src/ui/mcp/tools.ts +101 -17
  30. package/src/ui/server/audit.ts +90 -3
  31. package/src/ui/server/daemon-reaper.ts +22 -4
  32. package/src/ui/server/github-service.ts +6 -1
  33. package/src/ui/server/index.ts +96 -5
  34. package/src/ui/server/public/panel.js +1 -1
  35. package/src/ui/server/readonly-routes.ts +12 -3
  36. package/src/ui/server/release-service.ts +40 -5
  37. package/src/ui/server/workspace-routes.ts +13 -6
@@ -50,6 +50,7 @@ import {
50
50
  getWorkspaceByName,
51
51
  isWorkspaceActive,
52
52
  listWorkspaceServices,
53
+ readWorkspacesFile,
53
54
  resolveDepAddress,
54
55
  parseAddress,
55
56
  readManifestAnyBranch,
@@ -80,6 +81,7 @@ import {
80
81
  checkServiceHealth,
81
82
  loadDaemonsFromFile,
82
83
  pruneDeadDaemonsAsync,
84
+ daemonRootOf,
83
85
  isInteractiveServiceCommand,
84
86
  rewriteLegacyOpctlKillCommandForContainer,
85
87
  shouldDetachServiceCommand,
@@ -95,6 +97,7 @@ import {
95
97
  getServiceLogPath,
96
98
  } from "../../shared/process/spawn-streaming.js";
97
99
  import {
100
+ assertDenConfigValid,
98
101
  readDenConfig,
99
102
  isProductionEnv,
100
103
  isLocalEnabled,
@@ -105,6 +108,11 @@ import {
105
108
  writeUserReleaseConfig,
106
109
  type ReleaseConfig,
107
110
  } from "../../shared/den-config.js";
111
+ import {
112
+ CONFIG_ERROR_STATUS,
113
+ configErrorBody,
114
+ isConfigError,
115
+ } from "../../shared/config-error.js";
108
116
  import { readOrg } from "../../shared/org.js";
109
117
  import { engineVersion, getDistRoot } from "../../shared/paths.js";
110
118
  import { enginePath } from "../../shared/engine.js";
@@ -142,6 +150,7 @@ import {
142
150
  actorSuffix,
143
151
  recordAction,
144
152
  recordAgentRefusal,
153
+ repoAtPath,
145
154
  setAuditSink,
146
155
  } from "./audit.js";
147
156
  import { handleReadonlyRoutes } from "./readonly-routes.js";
@@ -416,6 +425,26 @@ const STATIC_EXTS = new Set([
416
425
  ".woff2",
417
426
  ]);
418
427
 
428
+ /** Every workspace's checkouts (active or not), for repoAtPath. */
429
+ function allWorkspaceCheckouts(): ResolvedService[] {
430
+ const catalog = readCatalog();
431
+ return Object.entries(readWorkspacesFile().workspaces).flatMap(
432
+ ([name, config]) => listWorkspaceServices(config, catalog, name),
433
+ );
434
+ }
435
+
436
+ /**
437
+ * API routes that answer whatever the config holds: the identity probe the
438
+ * CLI and MCP use to find this server, its own log (where a config error is
439
+ * also written), the changelog, and restarting it.
440
+ */
441
+ const CONFIG_FREE_ROUTES = new Set([
442
+ "/api/server-info",
443
+ "/api/server-log",
444
+ "/api/changelog",
445
+ "/api/web/restart",
446
+ ]);
447
+
419
448
  // ── Sudo password store ───────────────────────────────────────────────────────
420
449
  import {
421
450
  setSudoPassword,
@@ -436,6 +465,11 @@ import {
436
465
  send404,
437
466
  } from "./http-helpers.js";
438
467
  import { orgOf, listOrgs } from "../../shared/manifest/service-links.js";
468
+ import {
469
+ readOrgContext,
470
+ resolveAllWorkspaceOrgs,
471
+ withOrgMembership,
472
+ } from "../../shared/environment-manager/workspace-org.js";
439
473
  import {
440
474
  readLatestSummary,
441
475
  securityReportsDir,
@@ -603,6 +637,13 @@ async function handleRequest(
603
637
  }
604
638
  }
605
639
 
640
+ // ── Config validation ──────────────────────────────────────────────────────
641
+ // A config that fails validation answers every API route that reads it as
642
+ // the ConfigError the handler below maps to 422 JSON, before any route's own
643
+ // catch can turn it into a 400, a 500 or an empty list. Pages still load.
644
+ if (pathname.startsWith("/api/") && !CONFIG_FREE_ROUTES.has(pathname))
645
+ assertDenConfigValid();
646
+
606
647
  // ── Static UI ──────────────────────────────────────────────────────────────
607
648
  // Local Dev is the default page when local environment is enabled.
608
649
  if (
@@ -684,12 +725,24 @@ async function handleRequest(
684
725
 
685
726
  // ── API: environments ──────────────────────────────────────────────────────
686
727
  // Orgs the catalog's repos belong to, with config/den.yml display names.
728
+ // ORG-1d: a repo's org is an org listing it, else one whose source claims
729
+ // its owner, else its GitHub owner; a claimed owner is not listed. Service
730
+ // rows keep `org` as the repo's GitHub owner (C3).
731
+ // Each entry also says whether it is the default org (`github_org`) and
732
+ // which workspaces on this machine belong to it (ORG-1a). Nothing else
733
+ // changes: same entries, order and fields (ORG-SPEC C3).
687
734
  if (pathname === "/api/orgs") {
688
- const catalog = readCatalog();
689
- const orgs = listOrgs(catalog, readDenConfig().orgs ?? {});
735
+ const ctx = readOrgContext();
736
+ const catalog = ctx.catalog;
737
+ const orgs = withOrgMembership(
738
+ listOrgs(catalog, ctx.config.orgs ?? {}),
739
+ resolveAllWorkspaceOrgs(),
740
+ ctx.file,
741
+ ctx.config,
742
+ );
690
743
  if (url.searchParams.get("repos") !== "1") return sendJson(res, orgs);
691
744
  const repos = Object.fromEntries(
692
- Object.keys(catalog).map((r) => [r, orgOf(r, catalog)]),
745
+ Object.keys(catalog).map((r) => [r, orgOf(r, catalog, ctx.config.orgs)]),
693
746
  );
694
747
  return sendJson(res, { orgs, repos });
695
748
  }
@@ -794,6 +847,17 @@ async function handleRequest(
794
847
  } catch {
795
848
  return send400(res, "invalid JSON");
796
849
  }
850
+ // A call that named a path (manifest_validate) is recorded under the
851
+ // catalog repo that path is inside, across every workspace's checkouts.
852
+ if (typeof report.repo !== "string" && report.path !== undefined) {
853
+ let repo: string | null = null;
854
+ try {
855
+ repo = repoAtPath(report.path, allWorkspaceCheckouts());
856
+ } catch {
857
+ /* unresolvable workspaces: recorded under the tool only */
858
+ }
859
+ if (repo) report = { ...report, repo };
860
+ }
797
861
  const entry = recordAgentRefusal(actor, report);
798
862
  return sendJson(res, { recorded: true, ts: entry.ts });
799
863
  }
@@ -2375,7 +2439,14 @@ async function handleRequest(
2375
2439
  // ── API: den config ────────────────────────────────────────────────────
2376
2440
  if (pathname === `/api/${product().cli}/config` && req.method === "GET") {
2377
2441
  // `org` resolved (id defaults to edge.org) for the header's org label.
2378
- return sendJson(res, { ...readDenConfig(), org: readOrg() });
2442
+ const config = readDenConfig();
2443
+ // `default_org` (ORG-1a) is the default work org, `github_org`; `org`
2444
+ // stays the instance org (readOrg).
2445
+ return sendJson(res, {
2446
+ ...config,
2447
+ org: readOrg(),
2448
+ default_org: config.github_org?.trim() || null,
2449
+ });
2379
2450
  }
2380
2451
 
2381
2452
  // ── API: server info + staleness (drives the header "stale server" pill) ────
@@ -3130,6 +3201,21 @@ const server = http.createServer((req, res) => {
3130
3201
  // rejection — which CRASHES the whole server on modern Node (freezing every
3131
3202
  // health dot) and leaves the request hanging. Fail the one request instead.
3132
3203
  handleRequest(req, res).catch((err) => {
3204
+ // A config that fails validation is the user's to fix: name it to the
3205
+ // caller as JSON (the {ok, error, code} frame), not a bare 500.
3206
+ if (isConfigError(err)) {
3207
+ serverLog(
3208
+ ` ⚠ ${req.method} ${(req.url ?? "").split("?")[0]}: ${err.message}`,
3209
+ );
3210
+ try {
3211
+ if (!res.headersSent)
3212
+ return sendJson(res, configErrorBody(err), CONFIG_ERROR_STATUS);
3213
+ res.end();
3214
+ } catch {
3215
+ /* socket already gone */
3216
+ }
3217
+ return;
3218
+ }
3133
3219
  console.error(`[server] ${req.method} ${req.url} failed:`, err);
3134
3220
  try {
3135
3221
  if (!res.headersSent) {
@@ -3184,7 +3270,7 @@ void (async () => {
3184
3270
  await new Promise<void>((resolve) =>
3185
3271
  setTimeout(resolve, DAEMON_SHUTDOWN_GRACE_MS),
3186
3272
  );
3187
- await reapDeadDaemons({
3273
+ const reaped = await reapDeadDaemons({
3188
3274
  listDaemons,
3189
3275
  resolveService,
3190
3276
  getActiveWorkspaces,
@@ -3192,7 +3278,12 @@ void (async () => {
3192
3278
  unregisterDaemon,
3193
3279
  delay: (ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)),
3194
3280
  retryDelayMs: DAEMON_RETRY_DELAY_MS,
3281
+ rootOf: daemonRootOf,
3195
3282
  });
3283
+ if (reaped.removed.length > 0)
3284
+ console.error(
3285
+ `Reaped ${reaped.removed.length} stopped daemon(s): ${reaped.removed.join(", ")}`,
3286
+ );
3196
3287
  })();
3197
3288
 
3198
3289
  const prunedSnapshots = pruneDeadSnapshots();
@@ -781,7 +781,7 @@ function activityList(x) {
781
781
  .then(({ entries = [] }) => {
782
782
  if (!live) return;
783
783
  el.innerHTML = entries.length
784
- ? `<ol class="act">${entries.map((a) => `<li><time datetime="${esc(a.ts)}">${esc(hhmm(a.ts))}</time><span class="dg-who ${a.actor === "agent" ? "agent" : ""}">${esc(a.actor === "agent" ? "[agent]" + (a.client ? " " + a.client : "") : "you")}</span><span>${esc(a.verb)} ${esc(a.detail)}${a.outcome === "refused" ? ` <em class="refused">refused: ${esc(a.reason || "")}</em>` : ""}</span></li>`).join("")}</ol>`
784
+ ? `<ol class="act">${entries.map((a) => `<li><time datetime="${esc(a.ts)}">${esc(hhmm(a.ts))}</time><span class="dg-who ${a.actor === "agent" ? "agent" : ""}">${esc(a.actor === "agent" ? (a.agent ? "[" + a.agent + "]" : "[agent]") + (a.client ? " " + a.client : "") : "you")}</span><span>${esc(a.verb)} ${esc(a.detail)}${a.outcome === "refused" ? ` <em class="refused">refused: ${esc(a.reason || "")}</em>` : ""}</span></li>`).join("")}</ol>`
785
785
  : none(
786
786
  "No actions yet. Starts, restarts and syncs, yours and agents', show here.",
787
787
  );
@@ -96,13 +96,22 @@ export function activityFor(
96
96
  // and for each of its services.
97
97
  const repoLevel = (e: AuditEntry, repo: string): boolean =>
98
98
  e.verb === "exec" && e.detail.startsWith(repo + " ");
99
+ // A refused non-lifecycle call (logs_tail, logs_search, …) is listed under
100
+ // each service or repo it named.
101
+ const named = (e: AuditEntry, hit: (a: string) => boolean): boolean =>
102
+ (e.addresses ?? []).some(hit);
99
103
  const matches = (e: AuditEntry): boolean => {
100
- if (target.address)
104
+ if (target.address) {
105
+ const address = target.address;
106
+ const repo = address.split("/")[0];
101
107
  return (
102
- (e.verb === "exec" && e.detail.startsWith(target.address + " ")) ||
103
- repoLevel(e, target.address.split("/")[0])
108
+ (e.verb === "exec" && e.detail.startsWith(address + " ")) ||
109
+ repoLevel(e, repo) ||
110
+ named(e, (a) => a === address || a === repo)
104
111
  );
112
+ }
105
113
  const repo = target.repo ?? "";
114
+ if (named(e, (a) => a === repo || a.startsWith(repo + "/"))) return true;
106
115
  if (e.verb === "exec")
107
116
  return e.detail.startsWith(repo + "/") || repoLevel(e, repo);
108
117
  if (e.verb === "sync") return e.detail.split(" ").includes(`repo=${repo}`);
@@ -19,13 +19,22 @@ 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
+ readDenConfig,
23
+ readExplicitReleaseOrg,
22
24
  readReleaseConfig,
25
+ releaseOwnerFor,
23
26
  type ReleaseConfig,
24
27
  type ReleaseRepo,
25
28
  type ResolvedReleaseTarget,
26
29
  resolveReleaseTarget,
27
30
  } from "../../shared/den-config.js";
28
31
  import { readCatalog } from "../../shared/environment-manager/catalog.js";
32
+ import { orgOf } from "../../shared/manifest/service-links.js";
33
+ import {
34
+ ghOwnerOf,
35
+ isSourcedOrg,
36
+ orgHasGithub,
37
+ } from "../../shared/org-sources.js";
29
38
  import { listReposWithManifests } from "../../shared/manifest/workspace-manifests.js";
30
39
 
31
40
  import { product } from "../../shared/product.js";
@@ -255,20 +264,31 @@ export class ReleaseService {
255
264
  const known = new Set(cfg.repos.map((r) => r.name));
256
265
  return {
257
266
  ...cfg,
258
- repos: [
267
+ repos: releasableOnGithub([
259
268
  ...cfg.repos,
260
269
  ...manifestReleaseRepos().filter((r) => !known.has(r.name)),
261
- ],
270
+ ]),
262
271
  };
263
272
  }
264
273
 
265
- private org(cfg = this.config()): string {
266
- return cfg.org ?? "";
274
+ /**
275
+ * The owner a repo is released from (REL-1): an explicit `release.org`
276
+ * (den.yml or user.yml) wins; otherwise the repo's own non-empty catalog
277
+ * owner, so a client org's repo releases from its own org; `github_org`
278
+ * (readReleaseConfig's fallback for `cfg.org`) last.
279
+ */
280
+ private ownerFor(repo: string, cfg: ReleaseConfig): string {
281
+ return releaseOwnerFor({
282
+ explicitOrg: readExplicitReleaseOrg(),
283
+ // The repo's GitHub owner, never its ORG-1d org id (which needn't be one).
284
+ catalogOwner: ghOwnerOf(readCatalog()[repo]),
285
+ githubOrg: cfg.org,
286
+ });
267
287
  }
268
288
 
269
289
  slug(repo: string, cfg = this.config()): string {
270
290
  const gh = cfg.repos.find((r) => r.name === repo)?.gh;
271
- return gh && gh.includes("/") ? gh : `${this.org(cfg)}/${repo}`;
291
+ return gh && gh.includes("/") ? gh : `${this.ownerFor(repo, cfg)}/${repo}`;
272
292
  }
273
293
 
274
294
  /**
@@ -1109,6 +1129,21 @@ export async function readVersionSha(
1109
1129
  }
1110
1130
  }
1111
1131
 
1132
+ /**
1133
+ * Release repos less those of an org with no GitHub source (ORG-1d): their
1134
+ * release flow needs GitHub, so the Release page hides them rather than
1135
+ * failing. With no ORG-1d keys every repo stays.
1136
+ */
1137
+ export function releasableOnGithub(repos: ReleaseRepo[]): ReleaseRepo[] {
1138
+ const orgs = readDenConfig().orgs ?? {};
1139
+ if (!Object.values(orgs).some(isSourcedOrg)) return repos;
1140
+ const catalog = readCatalog();
1141
+ return repos.filter((r) => {
1142
+ const org = orgOf(r.name, catalog, orgs);
1143
+ return !org || orgHasGithub(org, orgs);
1144
+ });
1145
+ }
1146
+
1112
1147
  /** Workspace repos whose manifest has a `release:` block, as release repos. */
1113
1148
  export function manifestReleaseRepos(): ReleaseRepo[] {
1114
1149
  const catalog = readCatalog();
@@ -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,
@@ -85,10 +86,12 @@ import { depService } from "../../shared/manifest/deps.js";
85
86
  import { waitForReady } from "../../shared/environment-manager/readiness.js";
86
87
  import {
87
88
  readCatalog,
88
- resolveRepoGhSlug,
89
+ needsGhRepair,
90
+ resolveRepoCloneSource,
89
91
  upsertCatalogEntry,
90
92
  } from "../../shared/environment-manager/catalog.js";
91
93
  import { getReposRoot, readUserConfig } from "../../shared/den-config.js";
94
+ import { redactRemote } from "../../shared/environment-manager/git-remote.js";
92
95
  import { getDistRoot } from "../../shared/paths.js";
93
96
  import { recordRemovedFromWorkspace } from "../../shared/environment-manager/default-workspace.js";
94
97
  import { getSudoEnv, prewarmSudo } from "./sudo-store.js";
@@ -319,7 +322,7 @@ async function buildAvailableRepos(wsOverride: string | null) {
319
322
  hasManifest: false,
320
323
  alreadyAdded,
321
324
  source: "catalog",
322
- gh: entry.gh,
325
+ gh: entry.gh ?? null,
323
326
  });
324
327
  }
325
328
 
@@ -2158,14 +2161,18 @@ export async function handleWorkspaceRoutes(
2158
2161
  // flow writes one), which used to read as "not in the catalog" and
2159
2162
  // skip the clone entirely. Derive the conventional slug instead, and
2160
2163
  // persist it so every other flow (CLI up/setup) stops hitting this.
2161
- const ghSlug = resolveRepoGhSlug(svc.repoName);
2162
- if (!readCatalog()[svc.repoName]?.gh) {
2163
- upsertCatalogEntry(svc.repoName, { gh: ghSlug });
2164
+ // ORG-1d: a repo with no source and a `remote:` clones from it.
2165
+ const ghSlug = resolveRepoCloneSource(svc.repoName, readCatalog(), () =>
2166
+ githubOrgFor(ws.name),
2167
+ );
2168
+ const entry = readCatalog()[svc.repoName];
2169
+ if (needsGhRepair(entry, ghSlug)) {
2170
+ upsertCatalogEntry(svc.repoName, { ...entry, gh: ghSlug });
2164
2171
  }
2165
2172
  const branch = (svc as { branch?: string | null }).branch ?? "main";
2166
2173
  emit({
2167
2174
  type: "line",
2168
- text: ` ${svc.repoName}: cloning ${ghSlug} (${branch})…`,
2175
+ text: ` ${svc.repoName}: cloning ${redactRemote(ghSlug)} (${branch})…`,
2169
2176
  });
2170
2177
  try {
2171
2178
  // Synchronous (same helper the workspace-add flow uses) — the server