@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.
- package/CHANGELOG.md +225 -0
- package/bin/den +9 -0
- package/docs/distributions.md +205 -6
- package/package.json +1 -1
- package/service/install.sh +41 -2
- package/src/shared/agent-actor.ts +38 -3
- package/src/shared/config-error.ts +33 -0
- package/src/shared/den-config.ts +84 -5
- package/src/shared/environment-manager/catalog.ts +61 -3
- package/src/shared/environment-manager/default-workspace.ts +53 -14
- package/src/shared/environment-manager/git-remote.ts +49 -0
- package/src/shared/environment-manager/health.ts +51 -8
- package/src/shared/environment-manager/service-runner.ts +51 -5
- package/src/shared/environment-manager/workspace-manager.ts +69 -26
- package/src/shared/environment-manager/workspace-org.ts +279 -0
- package/src/shared/manifest/service-links.ts +20 -8
- package/src/shared/org-sources.ts +309 -0
- package/src/shared/types/index.ts +19 -2
- package/src/ui/cli/clone-plan.ts +374 -0
- package/src/ui/cli/flag-parser.ts +57 -3
- package/src/ui/cli/index.ts +346 -48
- package/src/ui/cli/org-command.ts +172 -0
- package/src/ui/cli/results-viewer.ts +5 -5
- package/src/ui/cli/table.ts +20 -0
- package/src/ui/cli/up-args.ts +15 -0
- package/src/ui/cli/validate-all.ts +171 -0
- package/src/ui/mcp/scope.ts +277 -0
- package/src/ui/mcp/server.ts +94 -9
- package/src/ui/mcp/tools.ts +101 -17
- package/src/ui/server/audit.ts +90 -3
- package/src/ui/server/daemon-reaper.ts +22 -4
- package/src/ui/server/github-service.ts +6 -1
- package/src/ui/server/index.ts +96 -5
- package/src/ui/server/public/panel.js +1 -1
- package/src/ui/server/readonly-routes.ts +12 -3
- package/src/ui/server/release-service.ts +40 -5
- package/src/ui/server/workspace-routes.ts +13 -6
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<cli> org list` (ORG-1a): every org with its label, its workspaces on
|
|
3
|
+
* this machine, whether it is the default (`github_org`), and the org
|
|
4
|
+
* warnings (C1). The orgs are `/api/orgs`'s (catalog owners plus `orgs:`),
|
|
5
|
+
* then the default org and any workspace's org that list does not name.
|
|
6
|
+
*
|
|
7
|
+
* `--org <id>` (on `org list` and `workspace list`) keeps one org's rows.
|
|
8
|
+
*/
|
|
9
|
+
import chalk from "chalk";
|
|
10
|
+
import { product } from "../../shared/product.js";
|
|
11
|
+
import { listOrgs } from "../../shared/manifest/service-links.js";
|
|
12
|
+
import { githubOwnersOf } from "../../shared/org-sources.js";
|
|
13
|
+
import { readDenConfig } from "../../shared/den-config.js";
|
|
14
|
+
import { isConfigError } from "../../shared/config-error.js";
|
|
15
|
+
import {
|
|
16
|
+
defaultOrg,
|
|
17
|
+
githubOrgFor,
|
|
18
|
+
readOrgContext,
|
|
19
|
+
resolveAllWorkspaceOrgs,
|
|
20
|
+
withOrgMembership,
|
|
21
|
+
type OrgContext,
|
|
22
|
+
type WorkspaceOrg,
|
|
23
|
+
} from "../../shared/environment-manager/workspace-org.js";
|
|
24
|
+
import { newTable } from "./table.js";
|
|
25
|
+
|
|
26
|
+
/** `--org <id>`'s value from `args` (also `--org=<id>`), or undefined. */
|
|
27
|
+
export function orgFlag(args: readonly string[]): string | undefined {
|
|
28
|
+
for (let i = 0; i < args.length; i++) {
|
|
29
|
+
const a = args[i];
|
|
30
|
+
if (a === "--org") return args[i + 1];
|
|
31
|
+
if (a.startsWith("--org=")) return a.slice("--org=".length);
|
|
32
|
+
}
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** `args` without `--org <id>` / `--org=<id>`. */
|
|
37
|
+
export function withoutOrgFlag(args: readonly string[]): string[] {
|
|
38
|
+
const out: string[] = [];
|
|
39
|
+
for (let i = 0; i < args.length; i++) {
|
|
40
|
+
if (args[i] === "--org") {
|
|
41
|
+
i++;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (args[i].startsWith("--org=")) continue;
|
|
45
|
+
out.push(args[i]);
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const sameOrg = (a: string | null, b: string): boolean =>
|
|
51
|
+
a !== null && a.toLowerCase() === b.toLowerCase();
|
|
52
|
+
|
|
53
|
+
export interface OrgListRow {
|
|
54
|
+
id: string;
|
|
55
|
+
label: string;
|
|
56
|
+
default: boolean;
|
|
57
|
+
workspaces: string[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The rows `org list` prints, and every workspace's warnings. */
|
|
61
|
+
export function orgListRows(ctx: OrgContext = readOrgContext()): {
|
|
62
|
+
rows: OrgListRow[];
|
|
63
|
+
warnings: string[];
|
|
64
|
+
resolved: WorkspaceOrg[];
|
|
65
|
+
} {
|
|
66
|
+
const resolved = resolveAllWorkspaceOrgs(ctx);
|
|
67
|
+
const listed = withOrgMembership(
|
|
68
|
+
listOrgs(ctx.catalog, ctx.config.orgs ?? {}),
|
|
69
|
+
resolved,
|
|
70
|
+
ctx.file,
|
|
71
|
+
ctx.config,
|
|
72
|
+
).map((o) => ({
|
|
73
|
+
id: o.id,
|
|
74
|
+
label: o.label,
|
|
75
|
+
default: o.default,
|
|
76
|
+
workspaces: o.workspaces,
|
|
77
|
+
}));
|
|
78
|
+
const local = new Set(Object.keys(ctx.file.workspaces));
|
|
79
|
+
const extra = new Map<string, OrgListRow>();
|
|
80
|
+
const add = (id: string, isDefault: boolean): void => {
|
|
81
|
+
if (listed.some((r) => sameOrg(r.id, id))) return;
|
|
82
|
+
const key = id.toLowerCase();
|
|
83
|
+
if (!extra.has(key))
|
|
84
|
+
extra.set(key, { id, label: id, default: isDefault, workspaces: [] });
|
|
85
|
+
};
|
|
86
|
+
const d = defaultOrg(ctx.config);
|
|
87
|
+
if (d) add(d, true);
|
|
88
|
+
for (const w of resolved)
|
|
89
|
+
if (w.org && local.has(w.workspace)) {
|
|
90
|
+
add(w.org, sameOrg(d, w.org));
|
|
91
|
+
extra.get(w.org.toLowerCase())?.workspaces.push(w.workspace);
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
rows: [...listed, ...extra.values()],
|
|
95
|
+
warnings: resolved
|
|
96
|
+
.filter((w) => local.has(w.workspace))
|
|
97
|
+
.flatMap((w) => w.warnings),
|
|
98
|
+
resolved,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The GitHub org `register` lists repos from: `--org <id>`, else the
|
|
104
|
+
* workspace's resolved org. Never Den's own checkout or remote (SEC-0).
|
|
105
|
+
* Undefined when there is none, or it is not a GitHub owner name.
|
|
106
|
+
*/
|
|
107
|
+
export function registerOrg(
|
|
108
|
+
workspace: string,
|
|
109
|
+
orgArg?: string,
|
|
110
|
+
ctx?: OrgContext,
|
|
111
|
+
): string | undefined {
|
|
112
|
+
let org = orgArg?.trim();
|
|
113
|
+
if (org) {
|
|
114
|
+
// ORG-1d: an org id names its first GitHub source's owner; an org with
|
|
115
|
+
// no GitHub source has nothing to list.
|
|
116
|
+
org = githubOwnersOf(org, (ctx?.config ?? readDenConfig()).orgs)[0];
|
|
117
|
+
if (!org) return undefined;
|
|
118
|
+
} else {
|
|
119
|
+
try {
|
|
120
|
+
org = githubOrgFor(workspace, ctx);
|
|
121
|
+
} catch (e) {
|
|
122
|
+
// No org is "nothing to register"; a config that fails validation is not.
|
|
123
|
+
if (isConfigError(e)) throw e;
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return /^[A-Za-z0-9][A-Za-z0-9-]*$/.test(org) ? org : undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Print the org warnings under a listing. */
|
|
131
|
+
export function printOrgWarnings(warnings: readonly string[]): void {
|
|
132
|
+
for (const w of warnings) console.log(chalk.yellow(` ⚠ ${w}`));
|
|
133
|
+
if (warnings.length) console.log();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function cmdOrg(args: string[]): void {
|
|
137
|
+
const sub = args[0] ?? "list";
|
|
138
|
+
if (sub !== "list") {
|
|
139
|
+
console.log(chalk.red(`Usage: ${product().cli} org list [--org <id>]`));
|
|
140
|
+
process.exitCode = 1;
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const only = orgFlag(args.slice(1));
|
|
144
|
+
const { rows, warnings, resolved } = orgListRows();
|
|
145
|
+
const shown = only ? rows.filter((r) => sameOrg(r.id, only)) : rows;
|
|
146
|
+
const table = newTable({
|
|
147
|
+
head: ["Org", "Label", "Default", "Workspaces"],
|
|
148
|
+
style: { head: ["cyan"] },
|
|
149
|
+
});
|
|
150
|
+
for (const r of shown)
|
|
151
|
+
table.push([
|
|
152
|
+
chalk.bold(r.id),
|
|
153
|
+
r.label,
|
|
154
|
+
r.default ? chalk.green("✓") : "",
|
|
155
|
+
r.workspaces.join(", ") || chalk.gray("none"),
|
|
156
|
+
]);
|
|
157
|
+
console.log(table.toString());
|
|
158
|
+
if (only && shown.length === 0)
|
|
159
|
+
console.log(chalk.yellow(` No org ${only}.`));
|
|
160
|
+
const inScope = only
|
|
161
|
+
? new Set(
|
|
162
|
+
resolved.filter((w) => sameOrg(w.org, only)).map((w) => w.workspace),
|
|
163
|
+
)
|
|
164
|
+
: null;
|
|
165
|
+
printOrgWarnings(
|
|
166
|
+
inScope
|
|
167
|
+
? warnings.filter((w) =>
|
|
168
|
+
[...inScope].some((n) => w.startsWith(`workspace ${n}:`)),
|
|
169
|
+
)
|
|
170
|
+
: warnings,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Display test results in CLI
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `up -w <ws>` with no repo, no `-a` and no service repo under the cwd
|
|
3
|
+
* (0.2.8-dev.1). `-w` only picks the workspace; it does not say what in it
|
|
4
|
+
* to start, so the plan used to come back "No services specified and no
|
|
5
|
+
* invocation target resolvable". It is a usage error naming the two forms
|
|
6
|
+
* that do: a repo, or `-a` for the whole workspace.
|
|
7
|
+
*/
|
|
8
|
+
export function upNeedsTarget(cli: string, ws: string, plan: boolean): string {
|
|
9
|
+
const name = /^[A-Za-z0-9._-]{1,64}$/.test(ws) ? ws : "<workspace>";
|
|
10
|
+
const p = plan ? " --plan" : "";
|
|
11
|
+
return (
|
|
12
|
+
`up -w ${name} needs what to start: \`${cli} up -a${p} -w ${name}\` ` +
|
|
13
|
+
`for the whole workspace, or \`${cli} up <repo>${p} -w ${name}\` for one repo and its dependencies.`
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A scoped MCP server (AG-2a-core): `<cli> mcp --workspace <ws> --actor
|
|
3
|
+
* agent:<id>`. It pins one workspace, refuses any call that names another,
|
|
4
|
+
* filters lists to it, and stamps the actor on every web API call (the
|
|
5
|
+
* x-den-mcp-agent / x-den-mcp-workspace headers, see shared/agent-actor.ts).
|
|
6
|
+
*
|
|
7
|
+
* It binds cooperating agents only: the scope lives in this process, and the
|
|
8
|
+
* web server's HTTP API does not check it yet (WS-3a).
|
|
9
|
+
*/
|
|
10
|
+
import * as path from "path";
|
|
11
|
+
import { AGENT_ACTOR_RE, WORKSPACE_NAME_RE } from "../../shared/agent-actor.js";
|
|
12
|
+
import type { DenApi } from "./api.js";
|
|
13
|
+
import { Refusal } from "./guards.js";
|
|
14
|
+
|
|
15
|
+
export interface McpScope {
|
|
16
|
+
/** The one workspace this server serves. */
|
|
17
|
+
workspace: string;
|
|
18
|
+
/** Who acts: `agent:<id>`. */
|
|
19
|
+
actor: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A call reached outside the scoped workspace. Always reported (F2). */
|
|
23
|
+
export class ScopeRefusal extends Refusal {
|
|
24
|
+
constructor(message: string) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = "ScopeRefusal";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** `--workspace` / `--actor` were given wrongly: a usage error (exit 2). */
|
|
31
|
+
export class ScopeUsageError extends Error {
|
|
32
|
+
constructor(message: string) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = "ScopeUsageError";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const FLAGS: Record<string, "workspace" | "actor"> = {
|
|
39
|
+
"--workspace": "workspace",
|
|
40
|
+
"-w": "workspace",
|
|
41
|
+
"--actor": "actor",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Take `--workspace <ws>` (or `-w`) and `--actor agent:<id>` out of `argv`,
|
|
46
|
+
* also in the `--flag=value` form. Both or neither: a scope without an actor
|
|
47
|
+
* would act unattributed, an actor without a scope would look bound when it
|
|
48
|
+
* is not. Returns the scope (null when neither is given) and the rest of
|
|
49
|
+
* argv, unchanged otherwise.
|
|
50
|
+
*/
|
|
51
|
+
export function parseScopeFlags(argv: readonly string[]): {
|
|
52
|
+
scope: McpScope | null;
|
|
53
|
+
rest: string[];
|
|
54
|
+
} {
|
|
55
|
+
const got: Partial<Record<"workspace" | "actor", string>> = {};
|
|
56
|
+
const rest: string[] = [];
|
|
57
|
+
for (let i = 0; i < argv.length; i++) {
|
|
58
|
+
const a = argv[i];
|
|
59
|
+
const eq = a.indexOf("=");
|
|
60
|
+
const name = a.startsWith("-") && eq !== -1 ? a.slice(0, eq) : a;
|
|
61
|
+
const key = FLAGS[name];
|
|
62
|
+
if (!key) {
|
|
63
|
+
rest.push(a);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
let value: string | undefined;
|
|
67
|
+
if (name !== a) value = a.slice(eq + 1);
|
|
68
|
+
else {
|
|
69
|
+
value = argv[i + 1];
|
|
70
|
+
if (value === undefined || value.startsWith("-"))
|
|
71
|
+
throw new ScopeUsageError(`${name} needs a value`);
|
|
72
|
+
i++;
|
|
73
|
+
}
|
|
74
|
+
if (got[key] !== undefined && got[key] !== value)
|
|
75
|
+
throw new ScopeUsageError(`--${key} given twice`);
|
|
76
|
+
got[key] = value;
|
|
77
|
+
}
|
|
78
|
+
if (got.workspace === undefined && got.actor === undefined)
|
|
79
|
+
return { scope: null, rest };
|
|
80
|
+
if (got.workspace === undefined)
|
|
81
|
+
throw new ScopeUsageError(
|
|
82
|
+
"--actor needs --workspace: a scoped server serves one workspace",
|
|
83
|
+
);
|
|
84
|
+
if (got.actor === undefined)
|
|
85
|
+
throw new ScopeUsageError(
|
|
86
|
+
"--workspace needs --actor agent:<id>: a scoped server says who acts",
|
|
87
|
+
);
|
|
88
|
+
if (!WORKSPACE_NAME_RE.test(got.workspace))
|
|
89
|
+
throw new ScopeUsageError(
|
|
90
|
+
`--workspace "${printable(got.workspace)}" is not a workspace name ` +
|
|
91
|
+
"(letters, digits, dots, dashes, underscores; at most 64)",
|
|
92
|
+
);
|
|
93
|
+
if (!AGENT_ACTOR_RE.test(got.actor))
|
|
94
|
+
throw new ScopeUsageError(
|
|
95
|
+
`--actor "${printable(got.actor)}" is not agent:<id> ` +
|
|
96
|
+
"(the id: letters, digits, dots, dashes, underscores; at most 64)",
|
|
97
|
+
);
|
|
98
|
+
return { scope: { workspace: got.workspace, actor: got.actor }, rest };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A value echoed back in an error: one printable line, bounded. */
|
|
102
|
+
function printable(v: string): string {
|
|
103
|
+
return v.replace(/[^\x20-\x7e]/g, "?").slice(0, 80);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The refusal for a call that names `other`. */
|
|
107
|
+
export function otherWorkspace(
|
|
108
|
+
tool: string,
|
|
109
|
+
scope: McpScope,
|
|
110
|
+
other: string,
|
|
111
|
+
): ScopeRefusal {
|
|
112
|
+
return new ScopeRefusal(
|
|
113
|
+
`${tool}: this MCP server is scoped to workspace "${scope.workspace}" ` +
|
|
114
|
+
`(${scope.actor}); "${printable(other)}" is another workspace, so nothing was read or run.`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* A tool's arguments with the workspace pinned: absent, it becomes the
|
|
120
|
+
* scoped one; another one is refused before anything is asked of the API.
|
|
121
|
+
*/
|
|
122
|
+
export function pinWorkspace(
|
|
123
|
+
tool: string,
|
|
124
|
+
args: Record<string, unknown>,
|
|
125
|
+
scope: McpScope,
|
|
126
|
+
): Record<string, unknown> {
|
|
127
|
+
const ws = args.workspace;
|
|
128
|
+
if (ws !== undefined && ws !== null && ws !== "" && ws !== scope.workspace)
|
|
129
|
+
throw otherWorkspace(tool, scope, String(ws));
|
|
130
|
+
return { ...args, workspace: scope.workspace };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** What /api/workspaces answers (the fields the scope reads). */
|
|
134
|
+
interface WorkspacesBody {
|
|
135
|
+
active?: unknown;
|
|
136
|
+
focus?: unknown;
|
|
137
|
+
workspaces?: unknown;
|
|
138
|
+
[k: string]: unknown;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const asRecord = (v: unknown): Record<string, unknown> =>
|
|
142
|
+
v && typeof v === "object" && !Array.isArray(v)
|
|
143
|
+
? (v as Record<string, unknown>)
|
|
144
|
+
: {};
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* /api/workspaces narrowed to the scoped workspace: only its entry, its
|
|
148
|
+
* resolved directory and code-workspace path, `active` holding it or empty,
|
|
149
|
+
* and `focus` only when it is the focus. Everything else is left out.
|
|
150
|
+
*/
|
|
151
|
+
export function filterWorkspacesBody(
|
|
152
|
+
body: unknown,
|
|
153
|
+
scope: McpScope,
|
|
154
|
+
): Record<string, unknown> {
|
|
155
|
+
const b = asRecord(body) as WorkspacesBody;
|
|
156
|
+
const ws = scope.workspace;
|
|
157
|
+
const only = (v: unknown): Record<string, unknown> => {
|
|
158
|
+
const r = asRecord(v);
|
|
159
|
+
return ws in r ? { [ws]: r[ws] } : {};
|
|
160
|
+
};
|
|
161
|
+
return {
|
|
162
|
+
active: Array.isArray(b.active) ? b.active.filter((n) => n === ws) : [],
|
|
163
|
+
...(b.focus === ws ? { focus: ws } : {}),
|
|
164
|
+
workspaces: only(b.workspaces),
|
|
165
|
+
...(b.resolvedDirs !== undefined
|
|
166
|
+
? { resolvedDirs: only(b.resolvedDirs) }
|
|
167
|
+
: {}),
|
|
168
|
+
...(b.codeWorkspacePaths !== undefined
|
|
169
|
+
? { codeWorkspacePaths: only(b.codeWorkspacePaths) }
|
|
170
|
+
: {}),
|
|
171
|
+
scope: { workspace: ws, actor: scope.actor },
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The workspace /api/workspaces says is focused (focus, else active[0]). */
|
|
176
|
+
function focusedOf(body: unknown): string | null {
|
|
177
|
+
const b = asRecord(body) as WorkspacesBody;
|
|
178
|
+
if (typeof b.focus === "string" && b.focus) return b.focus;
|
|
179
|
+
if (Array.isArray(b.active) && typeof b.active[0] === "string")
|
|
180
|
+
return b.active[0];
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Throws unless the scoped workspace exists in this distribution. A missing
|
|
186
|
+
* one must not fall through to the web server's "no workspace" defaults,
|
|
187
|
+
* which list services outside any workspace.
|
|
188
|
+
*/
|
|
189
|
+
export async function assertScopeExists(
|
|
190
|
+
api: DenApi,
|
|
191
|
+
scope: McpScope,
|
|
192
|
+
): Promise<unknown> {
|
|
193
|
+
const body = await api.get("/api/workspaces");
|
|
194
|
+
if (!(scope.workspace in asRecord(asRecord(body).workspaces)))
|
|
195
|
+
throw new ScopeRefusal(
|
|
196
|
+
`this MCP server is scoped to workspace "${scope.workspace}", which does not exist in this distribution.`,
|
|
197
|
+
);
|
|
198
|
+
return body;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Tests run against the focused workspace (the web server has no workspace
|
|
203
|
+
* argument for them): a scoped server runs or lists them only when that is
|
|
204
|
+
* its own.
|
|
205
|
+
*/
|
|
206
|
+
export async function assertScopeFocused(
|
|
207
|
+
tool: string,
|
|
208
|
+
api: DenApi,
|
|
209
|
+
scope: McpScope,
|
|
210
|
+
): Promise<void> {
|
|
211
|
+
const focused = focusedOf(await api.get("/api/workspaces"));
|
|
212
|
+
if (focused !== scope.workspace)
|
|
213
|
+
throw new ScopeRefusal(
|
|
214
|
+
`${tool}: tests run against the focused workspace` +
|
|
215
|
+
`${focused ? ` ("${focused}")` : ""}, and this MCP server is scoped to ` +
|
|
216
|
+
`"${scope.workspace}". Focus "${scope.workspace}" first; nothing was read or run.`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** The fields of an /api/services entry the scope checks read. */
|
|
221
|
+
interface ScopedEntry {
|
|
222
|
+
repo?: unknown;
|
|
223
|
+
service?: unknown;
|
|
224
|
+
repoPath?: unknown;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function scopedEntries(
|
|
228
|
+
api: DenApi,
|
|
229
|
+
scope: McpScope,
|
|
230
|
+
): Promise<ScopedEntry[]> {
|
|
231
|
+
const list = await api.get("/api/services", {
|
|
232
|
+
env: "local",
|
|
233
|
+
workspace: scope.workspace,
|
|
234
|
+
});
|
|
235
|
+
return Array.isArray(list) ? (list as ScopedEntry[]) : [];
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Every `repo/service` must be one of the scoped workspace's services. */
|
|
239
|
+
export async function assertServicesInScope(
|
|
240
|
+
tool: string,
|
|
241
|
+
api: DenApi,
|
|
242
|
+
scope: McpScope,
|
|
243
|
+
targets: readonly { repo: string; service: string }[],
|
|
244
|
+
): Promise<void> {
|
|
245
|
+
const entries = await scopedEntries(api, scope);
|
|
246
|
+
const have = new Set(entries.map((e) => `${e.repo}/${e.service}`));
|
|
247
|
+
const outside = targets
|
|
248
|
+
.map((t) => `${t.repo}/${t.service}`)
|
|
249
|
+
.filter((t) => !have.has(t));
|
|
250
|
+
if (outside.length)
|
|
251
|
+
throw new ScopeRefusal(
|
|
252
|
+
`${tool}: ${outside.map(printable).join(", ")} ${outside.length === 1 ? "is" : "are"} not a service ` +
|
|
253
|
+
`of workspace "${scope.workspace}", the only one this MCP server serves; nothing was read.`,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** A manifest path must be absolute and inside one of the workspace's repos. */
|
|
258
|
+
export async function assertPathInScope(
|
|
259
|
+
tool: string,
|
|
260
|
+
api: DenApi,
|
|
261
|
+
scope: McpScope,
|
|
262
|
+
p: string,
|
|
263
|
+
): Promise<void> {
|
|
264
|
+
if (!path.isAbsolute(p))
|
|
265
|
+
throw new ScopeRefusal(
|
|
266
|
+
`${tool}: a scoped MCP server takes an absolute path inside workspace "${scope.workspace}"'s repos (or the manifest content).`,
|
|
267
|
+
);
|
|
268
|
+
const target = path.resolve(p);
|
|
269
|
+
const roots = (await scopedEntries(api, scope))
|
|
270
|
+
.map((e) => e.repoPath)
|
|
271
|
+
.filter((r): r is string => typeof r === "string" && r.length > 0)
|
|
272
|
+
.map((r) => path.resolve(r));
|
|
273
|
+
if (!roots.some((r) => target === r || target.startsWith(r + path.sep)))
|
|
274
|
+
throw new ScopeRefusal(
|
|
275
|
+
`${tool}: ${printable(p)} is not inside a repo of workspace "${scope.workspace}", the only one this MCP server serves; nothing was read.`,
|
|
276
|
+
);
|
|
277
|
+
}
|