@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 +108 -0
- package/docs/distributions.md +115 -2
- package/package.json +1 -1
- package/src/shared/agent-actor.ts +38 -3
- package/src/shared/config-error.ts +33 -0
- package/src/shared/den-config.ts +46 -4
- package/src/shared/environment-manager/catalog.ts +52 -1
- package/src/shared/environment-manager/default-workspace.ts +10 -2
- package/src/shared/environment-manager/git-remote.ts +49 -0
- package/src/shared/environment-manager/workspace-manager.ts +67 -25
- package/src/shared/environment-manager/workspace-org.ts +26 -11
- package/src/shared/manifest/service-links.ts +20 -8
- package/src/shared/org-sources.ts +309 -0
- package/src/shared/types/index.ts +12 -2
- package/src/ui/cli/clone-plan.ts +154 -23
- package/src/ui/cli/flag-parser.ts +57 -3
- package/src/ui/cli/index.ts +106 -17
- package/src/ui/cli/org-command.ts +12 -2
- package/src/ui/cli/up-args.ts +15 -0
- package/src/ui/mcp/scope.ts +277 -0
- package/src/ui/mcp/server.ts +70 -8
- package/src/ui/mcp/tools.ts +101 -17
- package/src/ui/server/audit.ts +90 -3
- package/src/ui/server/github-service.ts +6 -1
- package/src/ui/server/index.ts +65 -1
- 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 +25 -3
- package/src/ui/server/workspace-routes.ts +10 -6
package/src/ui/cli/clone-plan.ts
CHANGED
|
@@ -5,12 +5,22 @@
|
|
|
5
5
|
*
|
|
6
6
|
* On a fresh machine (no workspace set up yet, a blueprint in the config)
|
|
7
7
|
* the plan is what `<cli> workspace seed` and then setup would do, for every
|
|
8
|
-
* blueprint workspace. Otherwise it is the focused
|
|
9
|
-
* ones setup works on
|
|
8
|
+
* blueprint workspace (`-w` narrows it). Otherwise it is the focused
|
|
9
|
+
* workspace's repos, the ones setup works on, or the `-w` / `--all-active`
|
|
10
|
+
* ones; repos only other workspaces hold are named in a hint.
|
|
10
11
|
*/
|
|
11
12
|
import * as fs from "fs";
|
|
12
13
|
import * as path from "path";
|
|
13
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
readCatalog,
|
|
16
|
+
resolveRepoCloneSource,
|
|
17
|
+
} from "../../shared/environment-manager/catalog.js";
|
|
18
|
+
import {
|
|
19
|
+
isGitRemote,
|
|
20
|
+
redactRemote,
|
|
21
|
+
} from "../../shared/environment-manager/git-remote.js";
|
|
22
|
+
import { readDenConfig } from "../../shared/den-config.js";
|
|
23
|
+
import type { WorkOrgsConfig } from "../../shared/org-sources.js";
|
|
14
24
|
import {
|
|
15
25
|
blueprintWorkspaces,
|
|
16
26
|
catalogFill,
|
|
@@ -32,6 +42,7 @@ import {
|
|
|
32
42
|
} from "../../shared/environment-manager/workspace-org.js";
|
|
33
43
|
import { orgOf } from "../../shared/manifest/service-links.js";
|
|
34
44
|
import type { ServiceCatalog } from "../../shared/types/index.js";
|
|
45
|
+
import { plural } from "../../shared/plural.js";
|
|
35
46
|
|
|
36
47
|
export type ClonePlanState =
|
|
37
48
|
| "cloned"
|
|
@@ -64,6 +75,39 @@ export interface ClonePlan {
|
|
|
64
75
|
/** True when planned from the blueprint (nothing seeded yet). */
|
|
65
76
|
fromBlueprint: boolean;
|
|
66
77
|
workspaces: ClonePlanWorkspace[];
|
|
78
|
+
/** Repos held only by workspaces outside the plan, per workspace. */
|
|
79
|
+
outside?: { workspace: string; repos: string[] }[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Which workspaces `setup --plan` plans (default: the focused one). */
|
|
83
|
+
export interface PlanSelection {
|
|
84
|
+
/** `-w <ws>`, repeatable. */
|
|
85
|
+
workspaces?: readonly string[];
|
|
86
|
+
/** `--all-active`: every active workspace (plus any `-w` one). */
|
|
87
|
+
allActive?: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The branch a checkout has out, read from its HEAD file (no git process):
|
|
92
|
+
* the branch name, `detached <sha7>`, or null when it can't be read.
|
|
93
|
+
*/
|
|
94
|
+
export function checkoutBranch(dir: string): string | null {
|
|
95
|
+
try {
|
|
96
|
+
let gitDir = path.join(dir, ".git");
|
|
97
|
+
if (fs.statSync(gitDir).isFile()) {
|
|
98
|
+
// A worktree or submodule: .git names the real git dir.
|
|
99
|
+
const m = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(gitDir, "utf-8"));
|
|
100
|
+
if (!m) return null;
|
|
101
|
+
gitDir = path.resolve(dir, m[1].trim());
|
|
102
|
+
}
|
|
103
|
+
const head = fs.readFileSync(path.join(gitDir, "HEAD"), "utf-8").trim();
|
|
104
|
+
const ref = /^ref:\s*refs\/heads\/(.+)$/.exec(head);
|
|
105
|
+
if (ref) return ref[1];
|
|
106
|
+
if (/^[0-9a-f]{40,64}$/.test(head)) return `detached ${head.slice(0, 7)}`;
|
|
107
|
+
return null;
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
67
111
|
}
|
|
68
112
|
|
|
69
113
|
const ownerOf = (slug: string): string => slug.slice(0, slug.indexOf("/"));
|
|
@@ -72,22 +116,33 @@ function cloneDir(repo: string, branch: string): string {
|
|
|
72
116
|
return path.join(DEN_SERVICES_DIR, repo, branch);
|
|
73
117
|
}
|
|
74
118
|
|
|
75
|
-
/**
|
|
76
|
-
|
|
119
|
+
/**
|
|
120
|
+
* What setup and up would clone `repo` from (a GitHub slug, or an ORG-1d
|
|
121
|
+
* `remote:`), or null without an org. `owner` gives the slug's owner for a
|
|
122
|
+
* repo with neither `gh` nor `remote`.
|
|
123
|
+
*/
|
|
124
|
+
function sourceFor(
|
|
77
125
|
repo: string,
|
|
78
|
-
workspace: string,
|
|
79
126
|
catalog: ServiceCatalog,
|
|
80
127
|
ctx: OrgContext,
|
|
128
|
+
owner: () => string,
|
|
81
129
|
): string | null {
|
|
82
|
-
const gh = catalog[repo]?.gh?.trim();
|
|
83
|
-
if (gh) return gh;
|
|
84
130
|
try {
|
|
85
|
-
return
|
|
131
|
+
return resolveRepoCloneSource(repo, catalog, owner, ctx.config.orgs);
|
|
86
132
|
} catch {
|
|
87
133
|
return null;
|
|
88
134
|
}
|
|
89
135
|
}
|
|
90
136
|
|
|
137
|
+
function slugFor(
|
|
138
|
+
repo: string,
|
|
139
|
+
workspace: string,
|
|
140
|
+
catalog: ServiceCatalog,
|
|
141
|
+
ctx: OrgContext,
|
|
142
|
+
): string | null {
|
|
143
|
+
return sourceFor(repo, catalog, ctx, () => githubOrgFor(workspace, ctx));
|
|
144
|
+
}
|
|
145
|
+
|
|
91
146
|
function managedRow(
|
|
92
147
|
repo: string,
|
|
93
148
|
branch: string,
|
|
@@ -96,12 +151,13 @@ function managedRow(
|
|
|
96
151
|
): ClonePlanRow {
|
|
97
152
|
const dir = cloneDir(repo, branch);
|
|
98
153
|
const cloned = fs.existsSync(dir);
|
|
154
|
+
const remote = slug !== null && isGitRemote(slug);
|
|
99
155
|
return {
|
|
100
156
|
repo,
|
|
101
157
|
mode: "managed",
|
|
102
158
|
branch,
|
|
103
|
-
owner: slug ? ownerOf(slug)
|
|
104
|
-
url: slug ?
|
|
159
|
+
owner: !slug ? null : remote ? null : ownerOf(slug),
|
|
160
|
+
url: !slug ? null : remote ? redactRemote(slug) : ghCloneUrl(slug),
|
|
105
161
|
state: cloned
|
|
106
162
|
? "cloned"
|
|
107
163
|
: !slug
|
|
@@ -135,7 +191,9 @@ export function workspaceClonePlan(
|
|
|
135
191
|
rows.push({
|
|
136
192
|
repo: repoName,
|
|
137
193
|
mode: "dev-managed",
|
|
138
|
-
branch
|
|
194
|
+
// Den doesn't set a checkout's branch: say what it has out.
|
|
195
|
+
branch:
|
|
196
|
+
(entry.path && checkoutBranch(entry.path)) || "(branch unknown)",
|
|
139
197
|
owner: owner || null,
|
|
140
198
|
url: null,
|
|
141
199
|
state: "checkout",
|
|
@@ -175,28 +233,88 @@ function blueprintClonePlan(ctx: OrgContext): ClonePlanWorkspace[] {
|
|
|
175
233
|
managedRow(
|
|
176
234
|
repo,
|
|
177
235
|
bp.branch,
|
|
178
|
-
ctx.catalog
|
|
236
|
+
sourceFor(repo, ctx.catalog, ctx, () => {
|
|
237
|
+
const slug = fill.get(repo);
|
|
238
|
+
if (!slug) throw new Error(`no org for ${repo}`);
|
|
239
|
+
return ownerOf(slug);
|
|
240
|
+
}),
|
|
179
241
|
true, // seeding writes every blueprint repo into the catalog
|
|
180
242
|
),
|
|
181
243
|
),
|
|
182
244
|
}));
|
|
183
245
|
}
|
|
184
246
|
|
|
185
|
-
/**
|
|
186
|
-
|
|
247
|
+
/**
|
|
248
|
+
* `<cli> setup --plan`: the plan for `names` (every repo when empty) in the
|
|
249
|
+
* selected workspaces. Throws on a workspace that does not exist.
|
|
250
|
+
*/
|
|
251
|
+
export function buildSetupPlan(
|
|
252
|
+
names: readonly string[] = [],
|
|
253
|
+
sel: PlanSelection = {},
|
|
254
|
+
): ClonePlan {
|
|
187
255
|
const ctx = readOrgContext();
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
256
|
+
const want = [...new Set(sel.workspaces ?? [])];
|
|
257
|
+
if (isFreshWorkspaceSetup(ctx.file) && blueprintWorkspaces().length > 0) {
|
|
258
|
+
const all = blueprintClonePlan(ctx);
|
|
259
|
+
const unknown = want.filter((n) => !all.some((w) => w.name === n));
|
|
260
|
+
if (unknown.length)
|
|
261
|
+
throw new Error(
|
|
262
|
+
`no blueprint workspace named ${unknown.join(", ")} (the blueprint has ${all.map((w) => w.name).join(", ")})`,
|
|
263
|
+
);
|
|
264
|
+
return {
|
|
265
|
+
fromBlueprint: true,
|
|
266
|
+
workspaces: want.length ? all.filter((w) => want.includes(w.name)) : all,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
const focused = getActiveWorkspace()?.name;
|
|
270
|
+
const planned = [
|
|
271
|
+
...new Set(
|
|
272
|
+
sel.allActive
|
|
273
|
+
? [...ctx.file.active, ...want]
|
|
274
|
+
: want.length
|
|
275
|
+
? want
|
|
276
|
+
: focused
|
|
277
|
+
? [focused]
|
|
278
|
+
: [],
|
|
279
|
+
),
|
|
280
|
+
];
|
|
281
|
+
const unknown = planned.filter((n) => !ctx.file.workspaces[n]);
|
|
282
|
+
if (unknown.length)
|
|
283
|
+
throw new Error(
|
|
284
|
+
`no workspace named ${unknown.join(", ")} (workspaces: ${Object.keys(ctx.file.workspaces).join(", ") || "none"})`,
|
|
285
|
+
);
|
|
286
|
+
const workspaces = planned.map((n) =>
|
|
287
|
+
workspaceClonePlan(n, names.length ? names : undefined, ctx),
|
|
288
|
+
);
|
|
192
289
|
return {
|
|
193
290
|
fromBlueprint: false,
|
|
194
|
-
workspaces
|
|
195
|
-
|
|
196
|
-
],
|
|
291
|
+
workspaces,
|
|
292
|
+
outside: outsideRepos(ctx, planned, workspaces),
|
|
197
293
|
};
|
|
198
294
|
}
|
|
199
295
|
|
|
296
|
+
/** Repos that only workspaces outside `planned` hold, per workspace. */
|
|
297
|
+
function outsideRepos(
|
|
298
|
+
ctx: OrgContext,
|
|
299
|
+
planned: readonly string[],
|
|
300
|
+
workspaces: readonly ClonePlanWorkspace[],
|
|
301
|
+
): { workspace: string; repos: string[] }[] {
|
|
302
|
+
const inPlan = new Set(workspaces.flatMap((w) => w.rows.map((r) => r.repo)));
|
|
303
|
+
const out: { workspace: string; repos: string[] }[] = [];
|
|
304
|
+
for (const [name, ws] of Object.entries(ctx.file.workspaces)) {
|
|
305
|
+
if (planned.includes(name)) continue;
|
|
306
|
+
const repos = [
|
|
307
|
+
...new Set(
|
|
308
|
+
Object.keys(ws?.services ?? {}).map(
|
|
309
|
+
(a) => parseAddress(a, ctx.catalog).repoName,
|
|
310
|
+
),
|
|
311
|
+
),
|
|
312
|
+
].filter((r) => !inPlan.has(r));
|
|
313
|
+
if (repos.length) out.push({ workspace: name, repos });
|
|
314
|
+
}
|
|
315
|
+
return out;
|
|
316
|
+
}
|
|
317
|
+
|
|
200
318
|
const STATE_TEXT: Record<ClonePlanState, string> = {
|
|
201
319
|
cloned: "cloned",
|
|
202
320
|
"would-clone": "would clone",
|
|
@@ -210,6 +328,7 @@ export function renderClonePlan(
|
|
|
210
328
|
plan: ClonePlan,
|
|
211
329
|
cli: string,
|
|
212
330
|
catalog: ServiceCatalog = readCatalog(),
|
|
331
|
+
orgs: WorkOrgsConfig | undefined = readDenConfig().orgs,
|
|
213
332
|
): string[] {
|
|
214
333
|
const out: string[] = [];
|
|
215
334
|
if (plan.fromBlueprint)
|
|
@@ -230,7 +349,7 @@ export function renderClonePlan(
|
|
|
230
349
|
? ` ${r.path}`
|
|
231
350
|
: ""
|
|
232
351
|
: "";
|
|
233
|
-
const owner = r.owner ?? orgOf(r.repo, catalog) ?? "";
|
|
352
|
+
const owner = r.owner ?? orgOf(r.repo, catalog, orgs) ?? "";
|
|
234
353
|
out.push(
|
|
235
354
|
` ${r.repo} ${r.mode} ${r.branch} org: ${owner || "(unknown)"}` +
|
|
236
355
|
`${r.url ? ` ${r.url}` : ""} ${STATE_TEXT[r.state]}${where}`,
|
|
@@ -238,6 +357,18 @@ export function renderClonePlan(
|
|
|
238
357
|
}
|
|
239
358
|
for (const w of ws.warnings) out.push(` ⚠ ${w}`);
|
|
240
359
|
}
|
|
360
|
+
const outside = plan.outside ?? [];
|
|
361
|
+
if (outside.length) {
|
|
362
|
+
const n = new Set(outside.flatMap((o) => o.repos)).size;
|
|
363
|
+
out.push(
|
|
364
|
+
"",
|
|
365
|
+
`Not in this plan: ${plural(n, "repo")} in other workspaces: ` +
|
|
366
|
+
outside
|
|
367
|
+
.map((o) => `${o.workspace} (${o.repos.join(", ")})`)
|
|
368
|
+
.join(", ") +
|
|
369
|
+
`. Plan them with -w <workspace> or --all-active.`,
|
|
370
|
+
);
|
|
371
|
+
}
|
|
241
372
|
out.push("", "Plan only: nothing was cloned, seeded or written.");
|
|
242
373
|
return out;
|
|
243
374
|
}
|
|
@@ -61,11 +61,25 @@ const WORKSPACE_READ_COMMANDS = new Set([
|
|
|
61
61
|
"stack",
|
|
62
62
|
]);
|
|
63
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Commands that edit one workspace's entries without changing what runs:
|
|
66
|
+
* `-w` picks the workspace to write, active or not, and leaves focus alone.
|
|
67
|
+
*/
|
|
68
|
+
const WORKSPACE_EDIT_COMMANDS = new Set(["register"]);
|
|
69
|
+
|
|
64
70
|
const WORKSPACE_COMMANDS = new Set([
|
|
65
71
|
...WORKSPACE_MUTATION_COMMANDS,
|
|
66
72
|
...WORKSPACE_READ_COMMANDS,
|
|
73
|
+
...WORKSPACE_EDIT_COMMANDS,
|
|
67
74
|
]);
|
|
68
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Commands that read `-w`/`--workspace` themselves, so it is left in their
|
|
78
|
+
* args: `setup --plan -w a -w b` takes several, and `mcp --workspace <ws>`
|
|
79
|
+
* scopes the MCP server (it never sets the CLI's workspace override).
|
|
80
|
+
*/
|
|
81
|
+
const OWN_WORKSPACE_FLAG_COMMANDS = new Set(["setup", "mcp"]);
|
|
82
|
+
|
|
69
83
|
/** Whether `-w <name>` on this command requires `<name>` to be active. */
|
|
70
84
|
export function workspaceFlagRequiresActive(command: string): boolean {
|
|
71
85
|
return WORKSPACE_MUTATION_COMMANDS.has(command);
|
|
@@ -180,7 +194,7 @@ const COMMAND_FLAGS: Record<string, readonly string[]> = {
|
|
|
180
194
|
],
|
|
181
195
|
restart: ["-A", "--app", "-n", "--dry-run"],
|
|
182
196
|
logs: [],
|
|
183
|
-
setup: ["-f", "--force", "--plan"],
|
|
197
|
+
setup: ["-f", "--force", "--plan", "-w", "--workspace", "--all-active"],
|
|
184
198
|
sync: [],
|
|
185
199
|
};
|
|
186
200
|
|
|
@@ -200,8 +214,9 @@ export function unknownCommandFlags(command: string, args: string[]): string[] {
|
|
|
200
214
|
(a) =>
|
|
201
215
|
a.startsWith("-") &&
|
|
202
216
|
!allowed.includes(a) &&
|
|
203
|
-
// `--app=<name>`
|
|
204
|
-
!(allowed.includes("--app") && a.startsWith("--app="))
|
|
217
|
+
// `--app=<name>` and `-w=`/`--workspace=` also take the = form.
|
|
218
|
+
!(allowed.includes("--app") && a.startsWith("--app=")) &&
|
|
219
|
+
!(allowed.includes("--workspace") && /^(-w|--workspace)=/.test(a)),
|
|
205
220
|
);
|
|
206
221
|
}
|
|
207
222
|
|
|
@@ -228,6 +243,16 @@ export function parseCliArgs(raw: string[]): ParsedCliArgs {
|
|
|
228
243
|
continue;
|
|
229
244
|
}
|
|
230
245
|
|
|
246
|
+
if (
|
|
247
|
+
parsed.command &&
|
|
248
|
+
OWN_WORKSPACE_FLAG_COMMANDS.has(parsed.command) &&
|
|
249
|
+
/^(-w|--workspace)(=|$)/.test(token)
|
|
250
|
+
) {
|
|
251
|
+
parsed.args.push(token);
|
|
252
|
+
i += 1;
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
|
|
231
256
|
const nextIndex = applyFlag(raw, i, parsed.flags);
|
|
232
257
|
if (nextIndex !== null) {
|
|
233
258
|
i = nextIndex;
|
|
@@ -245,3 +270,32 @@ export function parseCliArgs(raw: string[]): ParsedCliArgs {
|
|
|
245
270
|
validateFlagScopes(parsed);
|
|
246
271
|
return parsed;
|
|
247
272
|
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Every `-w <ws>` / `--workspace <ws>` (and `=` form) in `args`, in order and
|
|
276
|
+
* repeatable, and the args without them. For a command that reads `-w`
|
|
277
|
+
* itself (OWN_WORKSPACE_FLAG_COMMANDS).
|
|
278
|
+
*/
|
|
279
|
+
export function takeWorkspaceFlags(args: readonly string[]): {
|
|
280
|
+
workspaces: string[];
|
|
281
|
+
rest: string[];
|
|
282
|
+
} {
|
|
283
|
+
const workspaces: string[] = [];
|
|
284
|
+
const rest: string[] = [];
|
|
285
|
+
for (let i = 0; i < args.length; i++) {
|
|
286
|
+
const a = args[i];
|
|
287
|
+
const m = /^(-w|--workspace)(?:=(.*))?$/.exec(a);
|
|
288
|
+
if (!m) {
|
|
289
|
+
rest.push(a);
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (m[2] !== undefined) {
|
|
293
|
+
if (!m[2]) throw new Error(`${m[1]} requires a value`);
|
|
294
|
+
workspaces.push(m[2]);
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
workspaces.push(consumeFlagValue([...args], i, m[1]));
|
|
298
|
+
i++;
|
|
299
|
+
}
|
|
300
|
+
return { workspaces, rest };
|
|
301
|
+
}
|
package/src/ui/cli/index.ts
CHANGED
|
@@ -32,10 +32,12 @@ import { moduleScanner } from "../../shared/test-discovery/module-scanner.js";
|
|
|
32
32
|
import { resolvePerformanceModules } from "./module-selection.js";
|
|
33
33
|
import {
|
|
34
34
|
readCatalog,
|
|
35
|
-
|
|
35
|
+
needsGhRepair,
|
|
36
|
+
resolveRepoCloneSource,
|
|
36
37
|
upsertCatalogEntry,
|
|
37
38
|
} from "../../shared/environment-manager/catalog.js";
|
|
38
39
|
import { seedSecretsFromSibling } from "../../shared/environment-manager/secrets-check.js";
|
|
40
|
+
import { upNeedsTarget } from "./up-args.js";
|
|
39
41
|
import {
|
|
40
42
|
readWorkspacesFile,
|
|
41
43
|
writeWorkspacesFile,
|
|
@@ -109,6 +111,7 @@ import {
|
|
|
109
111
|
} from "../../shared/environment-manager/service-runner.js";
|
|
110
112
|
import { getGitHubCoords } from "../../shared/smoke/git-remote.js";
|
|
111
113
|
import {
|
|
114
|
+
assertDenConfigValid,
|
|
112
115
|
getReposRoot,
|
|
113
116
|
mergeUserConfig,
|
|
114
117
|
readDenConfig,
|
|
@@ -191,6 +194,7 @@ import { cmdGraph } from "./graph-command.js";
|
|
|
191
194
|
import { resolveLogsTarget, tailServiceLogs } from "./logs-command.js";
|
|
192
195
|
import {
|
|
193
196
|
parseCliArgs,
|
|
197
|
+
takeWorkspaceFlags,
|
|
194
198
|
unknownCommandFlags,
|
|
195
199
|
workspaceFlagRequiresActive,
|
|
196
200
|
type ParsedCliArgs,
|
|
@@ -315,15 +319,17 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
|
|
|
315
319
|
lines: ["List all registered services in the catalog."],
|
|
316
320
|
},
|
|
317
321
|
register: {
|
|
318
|
-
usage: "register [path] [--org <id>]",
|
|
322
|
+
usage: "register [path] [--org <id>] [-w <workspace>]",
|
|
319
323
|
lines: [
|
|
320
324
|
"With a path: add that checkout (dev-managed) under its manifest name.",
|
|
321
325
|
"Without one: pick the repo interactively. A managed clone is picked",
|
|
322
326
|
"from the focused workspace's org; --org <id> picks from another.",
|
|
327
|
+
"-w writes that workspace (active or not) and leaves focus alone.",
|
|
323
328
|
],
|
|
324
329
|
},
|
|
325
330
|
setup: {
|
|
326
|
-
usage:
|
|
331
|
+
usage:
|
|
332
|
+
"setup [service...] [--force|-f] [--plan [-w <workspace>]... [--all-active]]",
|
|
327
333
|
lines: [
|
|
328
334
|
"Run 'make setup' once per system (clones missing managed repos first).",
|
|
329
335
|
"Default targets MANAGED clones only — setup's codegen/installs",
|
|
@@ -331,6 +337,8 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
|
|
|
331
337
|
"--force (-f) includes dev-managed repos; naming a service always works.",
|
|
332
338
|
"--plan prints each repo's org and clone URL and stops: nothing is",
|
|
333
339
|
"cloned, seeded or written (on a fresh machine: the blueprint's plan).",
|
|
340
|
+
"It plans the focused workspace; -w <workspace> (repeatable) plans those,",
|
|
341
|
+
"--all-active every active one. Repos only other workspaces hold are named.",
|
|
334
342
|
],
|
|
335
343
|
},
|
|
336
344
|
org: {
|
|
@@ -487,6 +495,27 @@ const TELEMETRY_COMMANDS = new Set<string>([
|
|
|
487
495
|
"service",
|
|
488
496
|
]);
|
|
489
497
|
|
|
498
|
+
/**
|
|
499
|
+
* Commands that run with a config that fails validation: the web and MCP
|
|
500
|
+
* servers (each route or tool reports it), and those that read neither orgs,
|
|
501
|
+
* the catalog nor clones (manifest validate, create, the per-engineer
|
|
502
|
+
* config and local-dev toggles, saved results). Every other registered
|
|
503
|
+
* command checks the config first.
|
|
504
|
+
*/
|
|
505
|
+
const CONFIG_FREE_COMMANDS = new Set<string>([
|
|
506
|
+
"web",
|
|
507
|
+
"mcp",
|
|
508
|
+
"validate",
|
|
509
|
+
"create",
|
|
510
|
+
"config",
|
|
511
|
+
"local-dev",
|
|
512
|
+
"view-results",
|
|
513
|
+
]);
|
|
514
|
+
|
|
515
|
+
function configCheckedCommand(command: string): boolean {
|
|
516
|
+
return TELEMETRY_COMMANDS.has(command) && !CONFIG_FREE_COMMANDS.has(command);
|
|
517
|
+
}
|
|
518
|
+
|
|
490
519
|
/** Map a raw command token to a PII-safe, registered telemetry command name. */
|
|
491
520
|
function telemetryCommandName(command?: string): string {
|
|
492
521
|
if (!command) return "none";
|
|
@@ -709,6 +738,12 @@ class DenCLI {
|
|
|
709
738
|
}
|
|
710
739
|
|
|
711
740
|
try {
|
|
741
|
+
// A config that fails validation stops every command that touches
|
|
742
|
+
// orgs, the catalog, clones or releases before it reads or writes
|
|
743
|
+
// anything: some (sync over existing clones, register, workspace
|
|
744
|
+
// create) never read `orgs:` and used to carry on (ORG-1d).
|
|
745
|
+
if (command && configCheckedCommand(command)) assertDenConfigValid();
|
|
746
|
+
|
|
712
747
|
switch (command) {
|
|
713
748
|
case "run":
|
|
714
749
|
await this.runTestsCommand(args.slice(1));
|
|
@@ -1142,10 +1177,11 @@ class DenCLI {
|
|
|
1142
1177
|
try {
|
|
1143
1178
|
// An entry with `gh: ""` clones from the workspace's org, as `up`
|
|
1144
1179
|
// does (and `setup --plan` says), instead of failing on an empty slug.
|
|
1145
|
-
|
|
1180
|
+
// ORG-1d: a repo with no source and a `remote:` clones from it.
|
|
1181
|
+
const ghSlug = resolveRepoCloneSource(repoName, catalog, () =>
|
|
1146
1182
|
githubOrgFor(ws.name),
|
|
1147
1183
|
);
|
|
1148
|
-
if (
|
|
1184
|
+
if (needsGhRepair(catalogEntry, ghSlug))
|
|
1149
1185
|
upsertCatalogEntry(repoName, { ...catalogEntry, gh: ghSlug });
|
|
1150
1186
|
ensureManagedServiceClone(repoName, ghSlug, branch);
|
|
1151
1187
|
} catch (err) {
|
|
@@ -1248,10 +1284,12 @@ class DenCLI {
|
|
|
1248
1284
|
// Catalog entries written by the dev-managed add flow carry `gh: ""`, so
|
|
1249
1285
|
// an entry can exist yet be unclonable. Derive the conventional slug and
|
|
1250
1286
|
// persist the repair rather than cloning from an empty URL.
|
|
1251
|
-
|
|
1287
|
+
// ORG-1d: a repo with no source and a `remote:` clones from it.
|
|
1288
|
+
const ghSlug = resolveRepoCloneSource(repoName, catalog, () =>
|
|
1252
1289
|
githubOrgFor(ws.name),
|
|
1253
1290
|
);
|
|
1254
|
-
if (
|
|
1291
|
+
if (needsGhRepair(catalog[repoName], ghSlug))
|
|
1292
|
+
upsertCatalogEntry(repoName, { ...catalog[repoName], gh: ghSlug });
|
|
1255
1293
|
try {
|
|
1256
1294
|
// runSetup=false: clone only — setup is run below, hidden + non-interactive.
|
|
1257
1295
|
ensureManagedServiceClone(repoName, ghSlug, branch, false);
|
|
@@ -1488,6 +1526,8 @@ class DenCLI {
|
|
|
1488
1526
|
* in order, and run nothing. Read-only on every path.
|
|
1489
1527
|
*/
|
|
1490
1528
|
dryRun?: boolean;
|
|
1529
|
+
/** `--plan`: a preview that would run nothing exits 1. */
|
|
1530
|
+
plan?: boolean;
|
|
1491
1531
|
},
|
|
1492
1532
|
): Promise<void> {
|
|
1493
1533
|
// How long to show startup output before handing control back to the menu
|
|
@@ -1599,6 +1639,7 @@ class DenCLI {
|
|
|
1599
1639
|
);
|
|
1600
1640
|
}
|
|
1601
1641
|
if (previews.some((p) => p.kind === "error")) process.exitCode = 1;
|
|
1642
|
+
if (opts?.plan && runs.length === 0) process.exitCode = 1;
|
|
1602
1643
|
console.log(chalk.gray(` (--dry-run: not ${doing} anything)\n`));
|
|
1603
1644
|
return;
|
|
1604
1645
|
}
|
|
@@ -2042,6 +2083,7 @@ class DenCLI {
|
|
|
2042
2083
|
`\n ${chalk.bold(seed)} has no derived dependencies to start.\n`,
|
|
2043
2084
|
),
|
|
2044
2085
|
);
|
|
2086
|
+
if (plan) process.exitCode = 1;
|
|
2045
2087
|
return;
|
|
2046
2088
|
}
|
|
2047
2089
|
// Systems the user toggled to auto-start OFF in this workspace are not
|
|
@@ -2283,6 +2325,7 @@ class DenCLI {
|
|
|
2283
2325
|
"\n No services in the active workspace — nothing to start.\n",
|
|
2284
2326
|
),
|
|
2285
2327
|
);
|
|
2328
|
+
if (plan) process.exitCode = 1;
|
|
2286
2329
|
return;
|
|
2287
2330
|
}
|
|
2288
2331
|
const { rawOrder, graph } = target;
|
|
@@ -2714,10 +2757,10 @@ class DenCLI {
|
|
|
2714
2757
|
}
|
|
2715
2758
|
|
|
2716
2759
|
private async cmdServiceUp(args: string[]): Promise<void> {
|
|
2717
|
-
await this.ensureWorkspaceSeeded();
|
|
2718
2760
|
// Tokens after a `--` terminator belong to a child command — they must
|
|
2719
2761
|
// never be read as up's own flags (`den up -- -a` is NOT `up -a`).
|
|
2720
2762
|
const terminator = args.indexOf("--");
|
|
2763
|
+
await this.ensureWorkspaceSeeded();
|
|
2721
2764
|
const { app, rest } = takeAppFlag(
|
|
2722
2765
|
terminator === -1 ? args : args.slice(0, terminator),
|
|
2723
2766
|
);
|
|
@@ -2781,6 +2824,7 @@ class DenCLI {
|
|
|
2781
2824
|
await this.cmdServiceUpManifest(
|
|
2782
2825
|
ownArgs.filter((a) => !a.startsWith("-")),
|
|
2783
2826
|
dryRun || plan,
|
|
2827
|
+
plan,
|
|
2784
2828
|
);
|
|
2785
2829
|
}
|
|
2786
2830
|
|
|
@@ -2788,6 +2832,7 @@ class DenCLI {
|
|
|
2788
2832
|
private async cmdServiceUpManifest(
|
|
2789
2833
|
addresses: string[],
|
|
2790
2834
|
dryRun = false,
|
|
2835
|
+
plan = false,
|
|
2791
2836
|
): Promise<void> {
|
|
2792
2837
|
// Resolve targets BEFORE the secrets preflight — otherwise running
|
|
2793
2838
|
// `den up` from a non-service dir (e.g. the den repo root) triggers
|
|
@@ -2806,6 +2851,16 @@ class DenCLI {
|
|
|
2806
2851
|
` or run \`${product().cli} graph\` to see the workspace.\n`,
|
|
2807
2852
|
),
|
|
2808
2853
|
);
|
|
2854
|
+
} else if (process.env.DEN_WORKSPACE_OVERRIDE) {
|
|
2855
|
+
// `up -w <ws>` with nothing to start: -w picks the workspace, not
|
|
2856
|
+
// what in it to start. A usage error, not an empty plan.
|
|
2857
|
+
console.error(
|
|
2858
|
+
chalk.red(
|
|
2859
|
+
`\n ${upNeedsTarget(product().cli, process.env.DEN_WORKSPACE_OVERRIDE, plan)}\n`,
|
|
2860
|
+
),
|
|
2861
|
+
);
|
|
2862
|
+
process.exitCode = 2;
|
|
2863
|
+
return;
|
|
2809
2864
|
} else {
|
|
2810
2865
|
console.log(
|
|
2811
2866
|
chalk.yellow(
|
|
@@ -2813,12 +2868,15 @@ class DenCLI {
|
|
|
2813
2868
|
),
|
|
2814
2869
|
);
|
|
2815
2870
|
}
|
|
2871
|
+
// A plan that resolves nothing is a failed plan (scripts check it).
|
|
2872
|
+
if (plan) process.exitCode = 1;
|
|
2816
2873
|
return;
|
|
2817
2874
|
}
|
|
2818
2875
|
if (!dryRun) preflightSecrets();
|
|
2819
2876
|
await this.runServicesCommand(targets, "up", undefined, {
|
|
2820
2877
|
named: addresses,
|
|
2821
2878
|
dryRun,
|
|
2879
|
+
plan,
|
|
2822
2880
|
});
|
|
2823
2881
|
}
|
|
2824
2882
|
|
|
@@ -3173,15 +3231,29 @@ class DenCLI {
|
|
|
3173
3231
|
private async cmdRegister(args: string[]): Promise<void> {
|
|
3174
3232
|
if (args.includes("-h") || args.includes("--help")) {
|
|
3175
3233
|
console.log(
|
|
3176
|
-
`\n ${product().cli} register [path] [--name <repo>] [--gh <owner/repo>]\n\n` +
|
|
3234
|
+
`\n ${product().cli} register [path] [--name <repo>] [--gh <owner/repo>] [-w <workspace>]\n\n` +
|
|
3177
3235
|
" With a path: add that checkout to the catalog and the focused\n" +
|
|
3178
3236
|
" workspace as your own (dev-managed) copy, without prompts. The name\n" +
|
|
3179
3237
|
" defaults to the manifest's name, then the folder name; the GitHub\n" +
|
|
3180
|
-
" slug to the git remote
|
|
3238
|
+
" slug to the git remote. -w writes that workspace (active or not)\n" +
|
|
3239
|
+
" and leaves focus alone.\n" +
|
|
3181
3240
|
" Without a path: choose a local checkout or a managed clone interactively.\n",
|
|
3182
3241
|
);
|
|
3183
3242
|
return;
|
|
3184
3243
|
}
|
|
3244
|
+
const repoPathArg = args.find(
|
|
3245
|
+
(a, i) =>
|
|
3246
|
+
!a.startsWith("-") && !/^--(name|gh|org)$/.test(args[i - 1] ?? ""),
|
|
3247
|
+
);
|
|
3248
|
+
// Without a path, register prompts: with no terminal, say how to name one.
|
|
3249
|
+
if (!repoPathArg && !process.stdin.isTTY) {
|
|
3250
|
+
process.stderr.write(
|
|
3251
|
+
`register needs a terminal (stdin is not a TTY); without one: ` +
|
|
3252
|
+
`${product().cli} register <path> [--name <repo>] [--gh <owner/repo>] [-w <workspace>]\n`,
|
|
3253
|
+
);
|
|
3254
|
+
process.exitCode = 2;
|
|
3255
|
+
return;
|
|
3256
|
+
}
|
|
3185
3257
|
const ws = getActiveWorkspace();
|
|
3186
3258
|
if (!ws) {
|
|
3187
3259
|
console.log(
|
|
@@ -3192,10 +3264,6 @@ class DenCLI {
|
|
|
3192
3264
|
return;
|
|
3193
3265
|
}
|
|
3194
3266
|
|
|
3195
|
-
const repoPathArg = args.find(
|
|
3196
|
-
(a, i) =>
|
|
3197
|
-
!a.startsWith("-") && !/^--(name|gh|org)$/.test(args[i - 1] ?? ""),
|
|
3198
|
-
);
|
|
3199
3267
|
if (repoPathArg) {
|
|
3200
3268
|
const flag = (n: string) => {
|
|
3201
3269
|
const i = args.indexOf(n);
|
|
@@ -3306,7 +3374,8 @@ class DenCLI {
|
|
|
3306
3374
|
console.log(
|
|
3307
3375
|
chalk.red(
|
|
3308
3376
|
`\n ❌ No GitHub org to list: set github_org in ${product().configFile}, ` +
|
|
3309
|
-
`org: on workspace ${ws.name}, or pass --org <id
|
|
3377
|
+
`org: on workspace ${ws.name}, or pass --org <id> ` +
|
|
3378
|
+
`(an org with no GitHub source in orgs.<id>.sources has none)\n`,
|
|
3310
3379
|
),
|
|
3311
3380
|
);
|
|
3312
3381
|
return;
|
|
@@ -3559,18 +3628,38 @@ class DenCLI {
|
|
|
3559
3628
|
console.log(chalk.gray(` revert: ${revert}\n`));
|
|
3560
3629
|
}
|
|
3561
3630
|
|
|
3562
|
-
private async cmdSetup(
|
|
3631
|
+
private async cmdSetup(rawArgs: string[]): Promise<void> {
|
|
3632
|
+
// `-w <ws>` (repeatable) and `--all-active` pick the workspaces to plan.
|
|
3633
|
+
const { workspaces: planWs, rest: args } = takeWorkspaceFlags(rawArgs);
|
|
3634
|
+
const allActive = args.includes("--all-active");
|
|
3563
3635
|
const force = args.includes("--force") || args.includes("-f");
|
|
3564
3636
|
const names = args.filter((a) => !a.startsWith("-"));
|
|
3565
3637
|
|
|
3566
3638
|
// `--plan`: each repo's org and clone URL, then stop. Read-only.
|
|
3567
3639
|
if (args.includes("--plan")) {
|
|
3640
|
+
let plan: ReturnType<typeof buildSetupPlan>;
|
|
3641
|
+
try {
|
|
3642
|
+
plan = buildSetupPlan(names, { workspaces: planWs, allActive });
|
|
3643
|
+
} catch (e) {
|
|
3644
|
+
console.error(chalk.red(`\n ${(e as Error).message}\n`));
|
|
3645
|
+
process.exitCode = 1;
|
|
3646
|
+
return;
|
|
3647
|
+
}
|
|
3568
3648
|
console.log(chalk.bold("\n Setup plan (no changes will be made):\n"));
|
|
3569
|
-
for (const line of renderClonePlan(
|
|
3649
|
+
for (const line of renderClonePlan(plan, product().cli))
|
|
3570
3650
|
console.log(` ${line}`.trimEnd());
|
|
3571
3651
|
console.log();
|
|
3572
3652
|
return;
|
|
3573
3653
|
}
|
|
3654
|
+
if (planWs.length || allActive) {
|
|
3655
|
+
console.error(
|
|
3656
|
+
chalk.red(
|
|
3657
|
+
`\n -w and --all-active go with --plan: setup itself works on the focused workspace.\n`,
|
|
3658
|
+
),
|
|
3659
|
+
);
|
|
3660
|
+
process.exitCode = 1;
|
|
3661
|
+
return;
|
|
3662
|
+
}
|
|
3574
3663
|
|
|
3575
3664
|
// Clone any managed repos that aren't on disk yet before reading manifests.
|
|
3576
3665
|
this.cloneMissingManagedRepos();
|