@monster-orange/den 0.2.7 → 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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,114 @@ All notable changes to Den are documented here. Each release lists the
4
4
  config and manifest changes a distribution must make to move to it under
5
5
  **Migrations**.
6
6
 
7
+ ## [0.2.8] - 2026-11-05
8
+
9
+ ### Added
10
+
11
+ - **A scoped MCP server for cooperating agents** (0.2.8-dev.0, AG-2a-core).
12
+ `<cli> mcp --workspace <ws> --actor agent:<id>` pins one workspace: a tool
13
+ that takes a workspace gets `<ws>`, and a call naming another is refused
14
+ before it reaches the web server and recorded in the activity trail.
15
+ Lists are filtered to `<ws>`: `workspaces` shows it alone, the log tools
16
+ and `manifest_validate` take only its services and repos, `workspace_sync`
17
+ syncs it, and the test tools need it to be the focused workspace. Every
18
+ web API call is stamped with the actor (`x-den-mcp-agent`,
19
+ `x-den-mcp-workspace`), so each exec, run and sync in the activity trail
20
+ and the server log names `agent:<id>` and its workspace. Both flags or
21
+ neither; a bad value is a usage error (exit 2). `--list` and `--call` take
22
+ them too. Without them the server is unchanged. **Limit:** it binds
23
+ cooperating agents only. The scope lives in the MCP process, and the web
24
+ server's HTTP API does not check it yet (WS-3a, 0.4); `mcp connect` still
25
+ registers the unscoped server.
26
+ - **`<cli> setup --plan -w <ws>` and `--all-active`** (0.2.8-dev.0). `-w` is
27
+ repeatable and plans those workspaces, active or not; `--all-active` plans
28
+ every active one; on a fresh machine `-w` narrows the blueprint's plan. A
29
+ closing hint names the repos only workspaces outside the plan hold. `-w`
30
+ and `--all-active` go with `--plan` only.
31
+ - **Orgs are Den's own; sources are bindings** (0.2.8-dev.1, ORG-1d). An
32
+ org is any key of `orgs:`. `orgs.<id>.sources: [{host, owner|group}]`
33
+ claims git-host owners for it (an org can span two GitHub owners), and
34
+ `orgs.<id>.repos: [...]` lists repos outright (one owner can be split
35
+ across orgs). Catalog entries take `remote: <git url>` for any host;
36
+ `gh: owner/repo` stays the GitHub shorthand. A repo's org is the org
37
+ listing it, else the one whose source claims its owner, else its GitHub
38
+ owner; inference, `/api/orgs` and `org list` use it, and a claimed owner
39
+ is not listed as its own org. A repo clones from its source (GitHub with
40
+ `gh` as credential helper), or with no source from its `remote:` through
41
+ plain git and the user's own credential helper (SEC-0); `setup --plan`
42
+ shows that URL. Discovery (`register`) and the Release page skip an org
43
+ with no GitHub source. A bad value names its key; a repo or owner claimed
44
+ by two orgs is an error naming both. See docs/distributions.md, Orgs.
45
+ - **`<cli> register <path> -w <ws>`** (0.2.8-dev.0) writes that workspace,
46
+ active or not, and leaves focus alone. It used to fail with "-w/--workspace
47
+ is not valid with register".
48
+
49
+ ### Changed
50
+
51
+ - **`setup --plan` and `up --plan` show a dev-managed repo's real branch**
52
+ (0.2.8-dev.0): the one its checkout has out (read from `.git/HEAD`, no git
53
+ process), or `(branch unknown)`, where they printed `main` for every
54
+ checkout.
55
+ - **`up --plan` exits 1 when nothing resolves** (0.2.8-dev.0): a name no
56
+ workspace has, or a workspace with nothing to start. It exited 0.
57
+ - **`register` with no terminal exits 2** (0.2.8-dev.0) with one usage line
58
+ that names `-w`: `register needs a terminal (stdin is not a TTY); without
59
+ one: <cli> register <path> [--name <repo>] [--gh <owner/repo>] [-w
60
+ <workspace>]`. It went on to prompt with nothing to answer.
61
+
62
+ ### Fixed
63
+
64
+ - **A config that fails validation stops every command** (0.2.8-dev.2).
65
+ A repo or owner claimed by two orgs (or any other bad ORG-1d value) is
66
+ checked before any command that touches orgs, the catalog, clones or
67
+ releases runs, and it exits 1 naming both keys. `sync` over existing
68
+ clones exited 0 and pulled, because it reads only workspaces.yml and the
69
+ catalog; `register <path> -w <ws>` wrote workspaces.yml and the catalog,
70
+ `workspace create` wrote workspaces.yml, and `workspace use` /
71
+ `add-service` exited 0. Now each stops before any git call or write. `web`, `mcp`, `validate`, `create`,
72
+ `config`, `local-dev` and `view-results` still run.
73
+ - **The web API answers a config that fails validation as JSON**
74
+ (0.2.8-dev.2). Every API route that reads the config answers 422
75
+ `{ ok: false, error: "<the message naming the keys>", code:
76
+ "config_invalid" }`, where `/api/orgs`, `/api/services` and others
77
+ answered a text/plain 500 "internal server error" and some routes turned
78
+ it into a 400 or an empty list. The error names config keys, never
79
+ values. `/api/server-info`, `/api/server-log`, `/api/changelog` and
80
+ `/api/web/restart` still answer, and pages still load.
81
+ - **A scoped MCP `manifest_validate` refused on another workspace's path
82
+ shows in that repo's activity** (0.2.8-dev.2). The refusal is recorded
83
+ under the catalog repo whose checkout holds the path (in any workspace),
84
+ as `logs_tail` refusals are since 0.2.8-dev.1. The entry is addressed by
85
+ the repo, not the path; its detail is the refusal message, which quotes
86
+ the path.
87
+ - **A scoped MCP refusal of a non-lifecycle tool shows on its service**
88
+ (0.2.8-dev.1). A refused `logs_tail`, `logs_search` or other non-lifecycle tool
89
+ was stored with an empty detail and no service, so the service panel's
90
+ activity (`GET /api/activity?address=<repo>/<svc>`) never showed it. The
91
+ entry's detail is now the refusal, and it carries `addresses`: the repo
92
+ or `repo/service` the call named, or every target of a `logs_search`
93
+ (one entry, listed under each).
94
+ - **`up --plan -w <ws>` with nothing to start is a usage error**
95
+ (0.2.8-dev.1): one line, exit 2, naming `up -a -w <ws>` (the whole
96
+ workspace) and `up <repo> -w <ws>`. It printed "No services specified and
97
+ no invocation target resolvable".
98
+ - **Test workers inherit no credentials** (0.2.8-dev.0). `tests/setup.ts`
99
+ strips credential-shaped variables (by name, family or token shape) from
100
+ the environment before any suite runs, so a failing assertion on a
101
+ spawn env can no longer print a developer's tokens.
102
+
103
+ ### Migrations
104
+
105
+ - None. A script that ran `up --plan` and ignored its exit code may now see
106
+ 1 when the plan is empty (2 for `up --plan -w <ws>` with no repo or
107
+ `-a`).
108
+ - ORG-1d (`orgs.<id>.sources`, `orgs.<id>.repos`, catalog `remote:`) is
109
+ optional: no change is needed, and a config without those keys behaves
110
+ exactly as on 0.2.7. A den.yml that uses them needs Den 0.2.8 or later.
111
+ - A client of the web API that reads a config error should check for
112
+ status 422 and `code: "config_invalid"` (0.2.8-dev.2); it was a
113
+ text/plain 500.
114
+
7
115
  ## [0.2.7] - 2026-11-05
8
116
 
9
117
  ### Added
@@ -473,8 +473,14 @@ To see what a setup would clone before it clones anything, run
473
473
  `acme setup --plan`: each repo with its org and clone URL, and whether it
474
474
  is cloned yet. On a fresh machine it plans the blueprint (every workspace
475
475
  `acme workspace seed` would create); once seeded, the focused workspace.
476
- `acme up --plan` lists the same lines for the repos it would start. Both
477
- are read-only: nothing is cloned, seeded or written.
476
+ `-w <workspace>` (repeatable) plans those workspaces instead, active or not,
477
+ and `--all-active` every active one; on a fresh machine `-w` narrows the
478
+ blueprint's plan. Repos that only workspaces outside the plan hold are named
479
+ in a closing hint. A dev-managed repo shows the branch its checkout has out
480
+ (read from its `.git/HEAD`), not a configured one. `acme up --plan` lists
481
+ the same lines for the repos it would start, and exits 1 when nothing
482
+ resolves (a name no workspace has, an empty workspace). Both are read-only:
483
+ nothing is cloned, seeded or written.
478
484
 
479
485
  ## Orgs
480
486
 
@@ -523,6 +529,73 @@ its workspace's org is a warning in both lists, never an error.
523
529
  `org:` in the blueprint needs Den 0.2.7 or later: an older Den rejects the
524
530
  unknown key. Pin 0.2.7 before you commit it.
525
531
 
532
+ ### Sources and repos (0.2.8)
533
+
534
+ An org is Den's own: its id is any key of `orgs:`, and it needs no git
535
+ host. Two optional keys bind it to where its repos live:
536
+
537
+ ```yaml
538
+ github_org: acme
539
+ orgs:
540
+ acme-group: # one org, two GitHub owners
541
+ label: Acme Group
542
+ sources:
543
+ - { host: github, owner: acme }
544
+ - { host: github, owner: acme-labs }
545
+ globex-billing: # one owner split across two orgs
546
+ repos: [globex-invoices]
547
+ globex:
548
+ sources: [{ host: github, owner: globex }]
549
+ initech: # no git host at all
550
+ sources: []
551
+ repos: [widget]
552
+ ```
553
+
554
+ - `orgs.<id>.sources` is a list of `{host, owner}` (or `{host, group}`). A
555
+ source claims that owner's repos for the org. `host: github` is built
556
+ in; any other host id (`gitlab`, …) claims repos whose `remote:` is on
557
+ `<host>.com` or a `<host>.` hostname, and clones them from that remote.
558
+ - `orgs.<id>.repos` lists repos that belong to the org whatever their
559
+ owner.
560
+ - An entry with neither key is what it was before 0.2.8: the GitHub owner
561
+ of its own name.
562
+
563
+ The catalog (`config/local/service-catalog.yml`) takes a `remote:` for any
564
+ host, beside `gh: owner/repo`, which stays as the GitHub shorthand:
565
+
566
+ ```yaml
567
+ widget: { remote: git@git.initech.example:tools/widget.git }
568
+ ```
569
+
570
+ A repo's org is the org whose `repos` lists it, else the org with a source
571
+ that claims its owner, else its GitHub owner as before. That org is what
572
+ `/api/orgs?repos=1` maps the repo to and what infers a workspace's org (step
573
+ 3 above). A claimed owner is not listed as an org of its own, and a
574
+ `github_org` that names one makes the claiming org the default. Service rows
575
+ (`/api/services`, `/api/health`) keep `org` as the repo's GitHub owner.
576
+
577
+ A repo clones from its source: a GitHub source gives
578
+ `https://github.com/<owner>/<repo>.git` with your `gh` as credential helper.
579
+ A repo with no source that claims it clones and pulls from its `remote:`
580
+ through plain git, with your own git credential helper; Den never hands it
581
+ `gh` or any credential of its own. A remote must be `https://`, `ssh://`,
582
+ `git://`, `file://`, `user@host:path` or an absolute path. `setup --plan`
583
+ shows it as the clone URL, with any user info masked.
584
+
585
+ An org with no GitHub source has no repo discovery (`register` lists
586
+ nothing for it) and its repos are left off the Release page.
587
+
588
+ These keys are checked when den.yml is read, and a bad value is an error
589
+ that names its key (`orgs.initech.sources[0].owner must be …`). A repo
590
+ listed by two orgs, or an owner claimed by two orgs' sources (or by a
591
+ source and an org with no `sources` of that name), is an error naming both
592
+ keys. Such an error stops every command that touches orgs, the catalog,
593
+ clones or releases (exit 1, before any git call or write), and every web
594
+ API route that reads the config answers it as 422 `{ "ok": false, "error":
595
+ "<message>", "code": "config_invalid" }`. Without the new keys nothing
596
+ changes: `/api/orgs` is byte for byte what 0.2.7 answered. They need Den
597
+ 0.2.8 or later.
598
+
526
599
  ## Releases
527
600
 
528
601
  The Release page dispatches each repo's CD workflow on GitHub. A repo's
@@ -736,6 +809,46 @@ everyone's checkout is in the same place.
736
809
  `--port <n>` (on `mcp` or `mcp connect`) points it at a web server on another
737
810
  port.
738
811
 
812
+ ### Scoped to one workspace
813
+
814
+ ```sh
815
+ ./bin/acme mcp --workspace smb --actor agent:planner
816
+ ```
817
+
818
+ `--workspace <ws>` and `--actor agent:<id>` (both or neither) scope the server
819
+ to one workspace for one agent:
820
+
821
+ - **Pinned.** A tool that takes a `workspace` gets `<ws>` when none is given.
822
+ A call naming another workspace is refused (`Refused: <tool>: this MCP
823
+ server is scoped to workspace "<ws>" (agent:<id>); "<other>" is another
824
+ workspace`) before anything reaches the web server. A refused call is
825
+ recorded in the activity trail, read tools included.
826
+ - **Filtered.** `workspaces` lists `<ws>` only. `logs_search` and `logs_tail`
827
+ take only `<ws>`'s services, `manifest_validate` takes only an absolute path
828
+ inside one of its repos (or the content), and `workspace_sync` syncs `<ws>`.
829
+ Tests run against the focused workspace, so `tests_available` and
830
+ `run_tests` are refused unless `<ws>` is the focused one.
831
+ - **Not workspace data**, served as unscoped: `environments`,
832
+ `security_latest`, `release_status` and `edge_status` (the org's and the
833
+ distribution's, not a workspace's).
834
+ - **Stamped.** Every web API call carries `x-den-mcp-agent: agent:<id>` and
835
+ `x-den-mcp-workspace: <ws>` beside the usual audit headers. The activity
836
+ entry gains `agent` and `workspace`, and the log line reads `· by
837
+ agent:<id> <client> <version> (workspace <ws>, mcp pid <pid>, session
838
+ <id>)` for every exec, run and sync.
839
+ - `<ws>` must exist in this distribution; until it does, every tool says so.
840
+ The id is letters, digits, dots, dashes and underscores (at most 64). A bad
841
+ or half-given scope is a usage error, exit 2. `--list` and `--call` take
842
+ the scope too.
843
+
844
+ **It binds cooperating agents only.** The scope lives in the MCP server's
845
+ process. The web server's HTTP API does not check it yet, so an agent that
846
+ calls the API itself (curl, a browser) is not held to its workspace. Enforcing
847
+ the scope in the HTTP server is WS-3a, planned for 0.4. `mcp connect` still
848
+ registers the unscoped server; to hand one agent a scoped server, add it to
849
+ that agent's config yourself, for example in a project's `.mcp.json`:
850
+ `"args": ["mcp", "--workspace", "smb", "--actor", "agent:planner"]`.
851
+
739
852
  ### One call from a shell
740
853
 
741
854
  ```sh
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monster-orange/den",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "description": "Den: a local multi-repo dev orchestrator, HTTPS edge and E2E harness",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,8 +12,20 @@ export const AUDIT_HEADERS = {
12
12
  clientVersion: "x-den-mcp-client-version",
13
13
  pid: "x-den-mcp-pid",
14
14
  session: "x-den-mcp-session",
15
+ /** A scoped server's actor id (`agent:<id>`, AG-2a-core). */
16
+ agent: "x-den-mcp-agent",
17
+ /** The workspace a scoped server is pinned to (AG-2a-core). */
18
+ workspace: "x-den-mcp-workspace",
15
19
  } as const;
16
20
 
21
+ /**
22
+ * `agent:<id>`: what `<cli> mcp --actor` takes. The id is letters, digits,
23
+ * dots, dashes and underscores, 1-64 long, starting with a letter or digit.
24
+ */
25
+ export const AGENT_ACTOR_RE = /^agent:[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
26
+ /** A workspace name, as the config accepts it (1-64 long here). */
27
+ export const WORKSPACE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
28
+
17
29
  export interface AgentInfo {
18
30
  /** The MCP client's self-reported name (clientInfo.name). */
19
31
  client: string;
@@ -23,6 +35,10 @@ export interface AgentInfo {
23
35
  mcpPid: number | null;
24
36
  /** The MCP server's session id (one per `<cli> mcp` process). */
25
37
  session: string;
38
+ /** A scoped server's actor (`agent:<id>`); absent when unscoped. */
39
+ agent?: string;
40
+ /** The workspace a scoped server is pinned to; absent when unscoped. */
41
+ workspace?: string;
26
42
  }
27
43
 
28
44
  export interface AgentActor extends AgentInfo {
@@ -49,6 +65,13 @@ export function agentAuditHeaders(info: AgentInfo): Record<string, string> {
49
65
  [AUDIT_HEADERS.clientVersion]: clean(info.clientVersion) || "unknown",
50
66
  [AUDIT_HEADERS.pid]: info.mcpPid === null ? "" : String(info.mcpPid),
51
67
  [AUDIT_HEADERS.session]: clean(info.session).replace(/[^A-Za-z0-9-]/g, ""),
68
+ // Only a scoped server sends these: an unscoped one's headers are as before.
69
+ ...(info.agent && AGENT_ACTOR_RE.test(info.agent)
70
+ ? { [AUDIT_HEADERS.agent]: info.agent }
71
+ : {}),
72
+ ...(info.workspace && WORKSPACE_NAME_RE.test(info.workspace)
73
+ ? { [AUDIT_HEADERS.workspace]: info.workspace }
74
+ : {}),
52
75
  };
53
76
  }
54
77
 
@@ -59,19 +82,31 @@ export function actorFromHeaders(
59
82
  const h = headers as Record<string, string | string[] | undefined>;
60
83
  if (clean(h[AUDIT_HEADERS.actor]) !== "agent") return null;
61
84
  const pid = clean(h[AUDIT_HEADERS.pid], 10);
85
+ // Kept only when well-formed: a malformed value is dropped, never cleaned
86
+ // into something that looks like another agent's id.
87
+ const agent = clean(h[AUDIT_HEADERS.agent], 80);
88
+ const workspace = clean(h[AUDIT_HEADERS.workspace], 80);
62
89
  return {
63
90
  kind: "agent",
64
91
  client: clean(h[AUDIT_HEADERS.client]) || "unknown",
65
92
  clientVersion: clean(h[AUDIT_HEADERS.clientVersion], 32) || "unknown",
66
93
  mcpPid: /^\d+$/.test(pid) ? Number(pid) : null,
67
94
  session: clean(h[AUDIT_HEADERS.session]).replace(/[^A-Za-z0-9-]/g, ""),
95
+ ...(AGENT_ACTOR_RE.test(agent) ? { agent } : {}),
96
+ ...(WORKSPACE_NAME_RE.test(workspace) ? { workspace } : {}),
68
97
  };
69
98
  }
70
99
 
71
- /** "agent <client> <version> (mcp pid <pid>, session <id>)". */
100
+ /**
101
+ * "agent <client> <version> (mcp pid <pid>, session <id>)"; a scoped
102
+ * server's actor leads: "agent:<id> <client> <version> (workspace <ws>,
103
+ * mcp pid <pid>, session <id>)".
104
+ */
72
105
  export function actorLabel(actor: AgentActor): string {
106
+ const who = actor.agent ?? "agent";
107
+ const ws = actor.workspace ? `workspace ${actor.workspace}, ` : "";
73
108
  return (
74
- `agent ${actor.client} ${actor.clientVersion} ` +
75
- `(mcp pid ${actor.mcpPid ?? "?"}, session ${actor.session || "?"})`
109
+ `${who} ${actor.client} ${actor.clientVersion} ` +
110
+ `(${ws}mcp pid ${actor.mcpPid ?? "?"}, session ${actor.session || "?"})`
76
111
  );
77
112
  }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * A distribution config that fails validation (ORG-1d's `orgs:` checks, for
3
+ * now). It is the user's to fix, not a crash: the CLI prints it and exits 1
4
+ * before doing anything, and the web server answers it as
5
+ * `{ ok: false, error, code: "config_invalid" }` with status 422.
6
+ *
7
+ * The message names config keys (`orgs.initech.repos`), never values a
8
+ * secret could hide in.
9
+ */
10
+ export class ConfigError extends Error {
11
+ readonly code = "config_invalid";
12
+
13
+ constructor(message: string) {
14
+ super(message);
15
+ this.name = "ConfigError";
16
+ }
17
+ }
18
+
19
+ export function isConfigError(e: unknown): e is ConfigError {
20
+ return e instanceof ConfigError;
21
+ }
22
+
23
+ /** The web server's status for a ConfigError: the request was fine, the config is not. */
24
+ export const CONFIG_ERROR_STATUS = 422;
25
+
26
+ /** The JSON body for a ConfigError (the `{ok, error, code}` frame R26 extends). */
27
+ export function configErrorBody(e: ConfigError): {
28
+ ok: false;
29
+ error: string;
30
+ code: "config_invalid";
31
+ } {
32
+ return { ok: false, error: e.message, code: e.code };
33
+ }
@@ -16,6 +16,8 @@ import type { Environment, ReleaseSourceConfig } from "./types/index.js";
16
16
  import { getDistRoot } from "./paths.js";
17
17
  import type { SecretsConfig } from "./secrets/index.js";
18
18
  import type { OrgConfig } from "./org.js";
19
+ import { validateOrgSources, type WorkOrgsConfig } from "./org-sources.js";
20
+ import { ConfigError, isConfigError } from "./config-error.js";
19
21
  import type { TestAccountsConfig } from "./auth/test-accounts.js";
20
22
 
21
23
  import { product } from "./product.js";
@@ -194,11 +196,13 @@ export interface DenConfig {
194
196
  /** GitHub org slug used to list org repos in the web UI (e.g. "Globex-Corp"). */
195
197
  github_org?: string;
196
198
  /**
197
- * Display names (and an optional colour) for the GitHub orgs repos belong
198
- * to, keyed by GitHub owner. Orgs are worked out from the catalog; this
199
- * only names them.
199
+ * Work orgs, keyed by org id: a display name and an optional colour. An
200
+ * entry with neither `sources` nor `repos` is the GitHub owner of its own
201
+ * name (ORG-1a). ORG-1d: `sources: [{host, owner|group}]` binds the org to
202
+ * git-host owners, and `repos: [...]` lists repos outright. See
203
+ * src/shared/org-sources.ts.
200
204
  */
201
- orgs?: Record<string, { label?: string; color?: string }>;
205
+ orgs?: WorkOrgsConfig;
202
206
  /** Derived-dependency-graph tuning (aliases + edge overrides). */
203
207
  graph?: GraphConfig;
204
208
  /**
@@ -412,6 +416,41 @@ export function requireGithubOrg(config: DenConfig = readDenConfig()): string {
412
416
  return org;
413
417
  }
414
418
 
419
+ let checked: { key: string; error: ConfigError | null } | null = null;
420
+
421
+ /**
422
+ * Throw the config's validation error (a ConfigError naming its keys), if it
423
+ * has one, without reading anything else. The CLI runs this before any
424
+ * command that touches orgs, the catalog, clones or releases, and the web
425
+ * server before every config-reading route, so a config that fails
426
+ * validation stops them all, not only those that happen to read `orgs:`.
427
+ * A missing file, or one that does not parse, is left to readDenConfig.
428
+ * Memoised on the file's mtime and size: the server checks per request.
429
+ */
430
+ export function assertDenConfigValid(): void {
431
+ let key: string;
432
+ try {
433
+ const st = fs.statSync(DEN_CONFIG_PATH);
434
+ key = `${st.mtimeMs}:${st.size}`;
435
+ } catch {
436
+ return;
437
+ }
438
+ if (checked?.key !== key) {
439
+ let error: ConfigError | null = null;
440
+ try {
441
+ const parsed = yaml.parse(fs.readFileSync(DEN_CONFIG_PATH, "utf-8")) as {
442
+ orgs?: unknown;
443
+ } | null;
444
+ validateOrgSources(parsed?.orgs, product().configFile);
445
+ } catch (e) {
446
+ if (!isConfigError(e)) return;
447
+ error = e;
448
+ }
449
+ checked = { key, error };
450
+ }
451
+ if (checked.error) throw new ConfigError(checked.error.message);
452
+ }
453
+
415
454
  /**
416
455
  * Read the effective den config, merging the shared deployment config with
417
456
  * per-engineer overrides from config/local/user.yml.
@@ -422,6 +461,9 @@ export function readDenConfig(): DenConfig {
422
461
  try {
423
462
  const raw = fs.readFileSync(DEN_CONFIG_PATH, "utf-8");
424
463
  const parsed = yaml.parse(raw) as Partial<DenConfig> | null;
464
+ // ORG-1d keys (orgs.<id>.sources / .repos) are checked here, where a bad
465
+ // value throws naming its key; configs without them are not touched.
466
+ validateOrgSources(parsed?.orgs, product().configFile);
425
467
  // `environments` may be malformed (scalar/object) in a hand-edited file —
426
468
  // fall back to the default set rather than throwing on .filter below.
427
469
  const envs = Array.isArray(parsed?.environments)
@@ -14,7 +14,13 @@ import * as path from "path";
14
14
  import * as yaml from "yaml";
15
15
  import type { ServiceCatalog, ServiceCatalogEntry } from "../types/index.js";
16
16
  import { getDistRoot } from "../paths.js";
17
- import { requireGithubOrg } from "../den-config.js";
17
+ import { readDenConfig, requireGithubOrg } from "../den-config.js";
18
+ import {
19
+ parseRemote,
20
+ repoSource,
21
+ type WorkOrgsConfig,
22
+ } from "../org-sources.js";
23
+ import { checkedRemote, isGitRemote } from "./git-remote.js";
18
24
 
19
25
  /** Fallback GitHub org when `config/den.yml` doesn't set `github_org`. */
20
26
 
@@ -77,6 +83,51 @@ export function resolveRepoGhSlug(
77
83
  return `${owner()}/${repoName}`;
78
84
  }
79
85
 
86
+ /**
87
+ * What `repoName` clones from (ORG-1d): a GitHub slug or a git URL (see
88
+ * git-remote.ts). The repo's source decides: a GitHub source gives its slug
89
+ * (`gh`, else `<owner>/<repo>`). A repo with no source and a `remote:` gives
90
+ * that remote, through plain git. Anything else is the legacy GitHub slug
91
+ * (resolveRepoGhSlug), so a catalog without `remote:` clones as before.
92
+ */
93
+ export function resolveRepoCloneSource(
94
+ repoName: string,
95
+ catalog: ServiceCatalog = readCatalog(),
96
+ owner: () => string = () => requireGithubOrg(),
97
+ orgs: WorkOrgsConfig | undefined = readDenConfig().orgs,
98
+ ): string {
99
+ const entry = catalog[repoName];
100
+ const gh = entry?.gh?.trim();
101
+ const remote = entry?.remote?.trim();
102
+ const src = repoSource(repoName, catalog, orgs ?? {});
103
+ if (src?.host === "github") {
104
+ if (gh) return gh;
105
+ const fromRemote = remote ? parseRemote(remote) : null;
106
+ return fromRemote?.host === "github"
107
+ ? `${fromRemote.owner}/${repoName}`
108
+ : `${src.owner ?? ""}/${repoName}`;
109
+ }
110
+ if (remote) return checkedRemote(repoName, remote);
111
+ if (src)
112
+ throw new Error(
113
+ `${repoName}: its org's source is on ${src.host}, which Den clones only ` +
114
+ `from a remote: — set ${repoName}.remote in the service catalog`,
115
+ );
116
+ return resolveRepoGhSlug(repoName, catalog, owner);
117
+ }
118
+
119
+ /**
120
+ * Whether a clone source may be written back as the catalog's `gh` (the
121
+ * empty-slug repair): only a GitHub slug, and only for an entry with no
122
+ * `gh` and no `remote` of its own.
123
+ */
124
+ export function needsGhRepair(
125
+ entry: ServiceCatalogEntry | undefined,
126
+ source: string,
127
+ ): boolean {
128
+ return !isGitRemote(source) && !entry?.gh?.trim() && !entry?.remote?.trim();
129
+ }
130
+
80
131
  export function removeCatalogEntry(repoName: string): void {
81
132
  const catalog = readCatalog();
82
133
  delete catalog[repoName];
@@ -22,8 +22,10 @@ import {
22
22
  import {
23
23
  githubOrgFor,
24
24
  readOrgContext,
25
+ resolveWorkspaceOrg,
25
26
  type OrgContext,
26
27
  } from "./workspace-org.js";
28
+ import { orgHasGithub } from "../org-sources.js";
27
29
  import type {
28
30
  ServiceCatalog,
29
31
  WorkspaceConfig,
@@ -83,11 +85,17 @@ export function catalogFill(
83
85
  ctx: OrgContext = readOrgContext(),
84
86
  ): Map<string, string> {
85
87
  const fill = new Map<string, string>();
86
- for (const ws of workspaces)
88
+ for (const ws of workspaces) {
89
+ // ORG-1d: an org with no GitHub source has no slug to give; its repos
90
+ // clone from their catalog `remote:`.
91
+ const org = resolveWorkspaceOrg(ws.name, ctx).org;
92
+ if (org && !orgHasGithub(org, ctx.config.orgs)) continue;
87
93
  for (const repo of ws.repos) {
88
- if (catalog[repo]?.gh || fill.has(repo)) continue;
94
+ if (catalog[repo]?.gh || catalog[repo]?.remote || fill.has(repo))
95
+ continue;
89
96
  fill.set(repo, `${githubOrgFor(ws.name, ctx)}/${repo}`);
90
97
  }
98
+ }
91
99
  return fill;
92
100
  }
93
101
 
@@ -0,0 +1,49 @@
1
+ /**
2
+ * What a managed clone is made from (ORG-1d): a GitHub slug (`owner/repo`,
3
+ * cloned from github.com with the user's gh as credential helper) or a git
4
+ * URL from the catalog's `remote:` (cloned through plain git and the user's
5
+ * own credential helper, never Den's or gh's: SEC-0).
6
+ *
7
+ * Clone commands run through a shell, so a remote must pass REMOTE_RE: a URL
8
+ * with a known transport, scp-style `user@host:path`, or an absolute path,
9
+ * and nothing a shell or git would read as a command (`ext::`, quotes, `$`,
10
+ * spaces, a leading `-`).
11
+ */
12
+
13
+ /** A GitHub slug, as the clone guards accept it. */
14
+ export const SAFE_SLUG_RE = /^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/;
15
+
16
+ const REMOTE_RE =
17
+ /^(?:(?:https?|ssh|git|file):\/\/[A-Za-z0-9._~@:%+/-]+|[A-Za-z0-9._-]+@[A-Za-z0-9.-]+:[A-Za-z0-9._~+/-]+|\/[A-Za-z0-9._~@+/-]+)$/;
18
+
19
+ /** True when `spec` is a git URL rather than a GitHub slug. */
20
+ export function isGitRemote(spec: string): boolean {
21
+ return !SAFE_SLUG_RE.test(spec);
22
+ }
23
+
24
+ /** True when `remote` is a git URL Den will hand to git. */
25
+ export function isSafeRemote(remote: string): boolean {
26
+ return REMOTE_RE.test(remote) && !remote.includes("..");
27
+ }
28
+
29
+ /**
30
+ * `remote` for display: any user info (`https://user:token@host/…`) masked.
31
+ * Remotes can carry a token, so one is never printed as written.
32
+ */
33
+ export function redactRemote(remote: string): string {
34
+ return remote.replace(/^([a-z+]+:\/\/)[^@/]+@/i, "$1***@");
35
+ }
36
+
37
+ /**
38
+ * The checked remote for `repo`, or a throw naming its catalog key (and not
39
+ * its value, which may hold a token).
40
+ */
41
+ export function checkedRemote(repo: string, remote: string): string {
42
+ const r = remote.trim();
43
+ if (!isSafeRemote(r))
44
+ throw new Error(
45
+ `service catalog: ${repo}.remote is not a git URL Den can clone ` +
46
+ `(https://, ssh://, git://, file://, user@host:path or an absolute path)`,
47
+ );
48
+ return r;
49
+ }