@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,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<cli> setup --plan` and the clone half of `<cli> up --plan` (ORG-1a):
|
|
3
|
+
* each repo's resolved org and the URL it would be cloned from. Read-only:
|
|
4
|
+
* nothing is cloned, seeded or written (the catalog included).
|
|
5
|
+
*
|
|
6
|
+
* On a fresh machine (no workspace set up yet, a blueprint in the config)
|
|
7
|
+
* the plan is what `<cli> workspace seed` and then setup would do, for every
|
|
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.
|
|
11
|
+
*/
|
|
12
|
+
import * as fs from "fs";
|
|
13
|
+
import * as path from "path";
|
|
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";
|
|
24
|
+
import {
|
|
25
|
+
blueprintWorkspaces,
|
|
26
|
+
catalogFill,
|
|
27
|
+
isFreshWorkspaceSetup,
|
|
28
|
+
isMultiBlueprint,
|
|
29
|
+
} from "../../shared/environment-manager/default-workspace.js";
|
|
30
|
+
import {
|
|
31
|
+
DEN_SERVICES_DIR,
|
|
32
|
+
getActiveWorkspace,
|
|
33
|
+
parseAddress,
|
|
34
|
+
} from "../../shared/environment-manager/workspaces.js";
|
|
35
|
+
import { ghCloneUrl } from "../../shared/environment-manager/workspace-manager.js";
|
|
36
|
+
import {
|
|
37
|
+
githubOrgFor,
|
|
38
|
+
readOrgContext,
|
|
39
|
+
resolveWorkspaceOrg,
|
|
40
|
+
type OrgContext,
|
|
41
|
+
type OrgSource,
|
|
42
|
+
} from "../../shared/environment-manager/workspace-org.js";
|
|
43
|
+
import { orgOf } from "../../shared/manifest/service-links.js";
|
|
44
|
+
import type { ServiceCatalog } from "../../shared/types/index.js";
|
|
45
|
+
import { plural } from "../../shared/plural.js";
|
|
46
|
+
|
|
47
|
+
export type ClonePlanState =
|
|
48
|
+
| "cloned"
|
|
49
|
+
| "would-clone"
|
|
50
|
+
| "not-in-catalog"
|
|
51
|
+
| "checkout"
|
|
52
|
+
| "no-org";
|
|
53
|
+
|
|
54
|
+
export interface ClonePlanRow {
|
|
55
|
+
repo: string;
|
|
56
|
+
mode: "managed" | "dev-managed";
|
|
57
|
+
branch: string;
|
|
58
|
+
/** The GitHub owner the repo clones from (its slug's owner), or null. */
|
|
59
|
+
owner: string | null;
|
|
60
|
+
url: string | null;
|
|
61
|
+
state: ClonePlanState;
|
|
62
|
+
/** A dev-managed checkout's path, or a managed clone's directory. */
|
|
63
|
+
path?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ClonePlanWorkspace {
|
|
67
|
+
name: string;
|
|
68
|
+
org: string | null;
|
|
69
|
+
source: OrgSource | null;
|
|
70
|
+
warnings: string[];
|
|
71
|
+
rows: ClonePlanRow[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ClonePlan {
|
|
75
|
+
/** True when planned from the blueprint (nothing seeded yet). */
|
|
76
|
+
fromBlueprint: boolean;
|
|
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
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const ownerOf = (slug: string): string => slug.slice(0, slug.indexOf("/"));
|
|
114
|
+
|
|
115
|
+
function cloneDir(repo: string, branch: string): string {
|
|
116
|
+
return path.join(DEN_SERVICES_DIR, repo, branch);
|
|
117
|
+
}
|
|
118
|
+
|
|
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(
|
|
125
|
+
repo: string,
|
|
126
|
+
catalog: ServiceCatalog,
|
|
127
|
+
ctx: OrgContext,
|
|
128
|
+
owner: () => string,
|
|
129
|
+
): string | null {
|
|
130
|
+
try {
|
|
131
|
+
return resolveRepoCloneSource(repo, catalog, owner, ctx.config.orgs);
|
|
132
|
+
} catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
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
|
+
|
|
146
|
+
function managedRow(
|
|
147
|
+
repo: string,
|
|
148
|
+
branch: string,
|
|
149
|
+
slug: string | null,
|
|
150
|
+
inCatalog: boolean,
|
|
151
|
+
): ClonePlanRow {
|
|
152
|
+
const dir = cloneDir(repo, branch);
|
|
153
|
+
const cloned = fs.existsSync(dir);
|
|
154
|
+
const remote = slug !== null && isGitRemote(slug);
|
|
155
|
+
return {
|
|
156
|
+
repo,
|
|
157
|
+
mode: "managed",
|
|
158
|
+
branch,
|
|
159
|
+
owner: !slug ? null : remote ? null : ownerOf(slug),
|
|
160
|
+
url: !slug ? null : remote ? redactRemote(slug) : ghCloneUrl(slug),
|
|
161
|
+
state: cloned
|
|
162
|
+
? "cloned"
|
|
163
|
+
: !slug
|
|
164
|
+
? "no-org"
|
|
165
|
+
: inCatalog
|
|
166
|
+
? "would-clone"
|
|
167
|
+
: "not-in-catalog",
|
|
168
|
+
path: dir,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The plan for one workspace on this machine, limited to `repos` when given. */
|
|
173
|
+
export function workspaceClonePlan(
|
|
174
|
+
name: string,
|
|
175
|
+
repos?: readonly string[],
|
|
176
|
+
ctx: OrgContext = readOrgContext(),
|
|
177
|
+
): ClonePlanWorkspace {
|
|
178
|
+
const resolved = resolveWorkspaceOrg(name, ctx);
|
|
179
|
+
const ws = ctx.file.workspaces[name];
|
|
180
|
+
const want = repos ? new Set(repos) : null;
|
|
181
|
+
const rows: ClonePlanRow[] = [];
|
|
182
|
+
const seen = new Set<string>();
|
|
183
|
+
for (const [address, entry] of Object.entries(ws?.services ?? {})) {
|
|
184
|
+
const { repoName } = parseAddress(address, ctx.catalog);
|
|
185
|
+
if (seen.has(repoName)) continue;
|
|
186
|
+
if (want && !want.has(repoName) && !want.has(address)) continue;
|
|
187
|
+
seen.add(repoName);
|
|
188
|
+
const branch = entry.branch ?? "main";
|
|
189
|
+
if (entry.mode === "dev-managed") {
|
|
190
|
+
const owner = orgOf(repoName, ctx.catalog);
|
|
191
|
+
rows.push({
|
|
192
|
+
repo: repoName,
|
|
193
|
+
mode: "dev-managed",
|
|
194
|
+
// Den doesn't set a checkout's branch: say what it has out.
|
|
195
|
+
branch:
|
|
196
|
+
(entry.path && checkoutBranch(entry.path)) || "(branch unknown)",
|
|
197
|
+
owner: owner || null,
|
|
198
|
+
url: null,
|
|
199
|
+
state: "checkout",
|
|
200
|
+
...(entry.path ? { path: entry.path } : {}),
|
|
201
|
+
});
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
rows.push(
|
|
205
|
+
managedRow(
|
|
206
|
+
repoName,
|
|
207
|
+
branch,
|
|
208
|
+
slugFor(repoName, name, ctx.catalog, ctx),
|
|
209
|
+
Boolean(ctx.catalog[repoName]),
|
|
210
|
+
),
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
return { ...resolved, name, rows };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** What `<cli> workspace seed` and then setup would clone, per blueprint workspace. */
|
|
217
|
+
function blueprintClonePlan(ctx: OrgContext): ClonePlanWorkspace[] {
|
|
218
|
+
const multi = isMultiBlueprint();
|
|
219
|
+
const bps = blueprintWorkspaces().map((b) => ({
|
|
220
|
+
...b,
|
|
221
|
+
name: multi ? b.name : "default",
|
|
222
|
+
}));
|
|
223
|
+
let fill = new Map<string, string>();
|
|
224
|
+
try {
|
|
225
|
+
fill = catalogFill(bps, ctx.catalog, ctx);
|
|
226
|
+
} catch {
|
|
227
|
+
// No org for some repo: those rows say so below.
|
|
228
|
+
}
|
|
229
|
+
return bps.map((bp) => ({
|
|
230
|
+
...resolveWorkspaceOrg(bp.name, ctx),
|
|
231
|
+
name: bp.name,
|
|
232
|
+
rows: bp.repos.map((repo) =>
|
|
233
|
+
managedRow(
|
|
234
|
+
repo,
|
|
235
|
+
bp.branch,
|
|
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
|
+
}),
|
|
241
|
+
true, // seeding writes every blueprint repo into the catalog
|
|
242
|
+
),
|
|
243
|
+
),
|
|
244
|
+
}));
|
|
245
|
+
}
|
|
246
|
+
|
|
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 {
|
|
255
|
+
const ctx = readOrgContext();
|
|
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
|
+
);
|
|
289
|
+
return {
|
|
290
|
+
fromBlueprint: false,
|
|
291
|
+
workspaces,
|
|
292
|
+
outside: outsideRepos(ctx, planned, workspaces),
|
|
293
|
+
};
|
|
294
|
+
}
|
|
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
|
+
|
|
318
|
+
const STATE_TEXT: Record<ClonePlanState, string> = {
|
|
319
|
+
cloned: "cloned",
|
|
320
|
+
"would-clone": "would clone",
|
|
321
|
+
"not-in-catalog": "not in the catalog: setup skips it, up clones it",
|
|
322
|
+
checkout: "your checkout (setup skips it without --force)",
|
|
323
|
+
"no-org": "no org: set github_org, or org: on the workspace",
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
/** The plan as plain lines (the caller colours and prints them). */
|
|
327
|
+
export function renderClonePlan(
|
|
328
|
+
plan: ClonePlan,
|
|
329
|
+
cli: string,
|
|
330
|
+
catalog: ServiceCatalog = readCatalog(),
|
|
331
|
+
orgs: WorkOrgsConfig | undefined = readDenConfig().orgs,
|
|
332
|
+
): string[] {
|
|
333
|
+
const out: string[] = [];
|
|
334
|
+
if (plan.fromBlueprint)
|
|
335
|
+
out.push(
|
|
336
|
+
`From the blueprint: nothing is set up yet. \`${cli} workspace seed\` creates these workspaces.`,
|
|
337
|
+
"",
|
|
338
|
+
);
|
|
339
|
+
if (plan.workspaces.length === 0) out.push("No workspace to plan.");
|
|
340
|
+
for (const ws of plan.workspaces) {
|
|
341
|
+
out.push(
|
|
342
|
+
`Workspace ${ws.name} org: ${ws.org ?? "(none)"}${ws.source ? ` (${ws.source})` : ""}`,
|
|
343
|
+
);
|
|
344
|
+
if (ws.rows.length === 0) out.push(" (no repos)");
|
|
345
|
+
for (const r of ws.rows) {
|
|
346
|
+
const where =
|
|
347
|
+
r.state === "cloned" || r.state === "checkout"
|
|
348
|
+
? r.path
|
|
349
|
+
? ` ${r.path}`
|
|
350
|
+
: ""
|
|
351
|
+
: "";
|
|
352
|
+
const owner = r.owner ?? orgOf(r.repo, catalog, orgs) ?? "";
|
|
353
|
+
out.push(
|
|
354
|
+
` ${r.repo} ${r.mode} ${r.branch} org: ${owner || "(unknown)"}` +
|
|
355
|
+
`${r.url ? ` ${r.url}` : ""} ${STATE_TEXT[r.state]}${where}`,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
for (const w of ws.warnings) out.push(` ⚠ ${w}`);
|
|
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
|
+
}
|
|
372
|
+
out.push("", "Plan only: nothing was cloned, seeded or written.");
|
|
373
|
+
return out;
|
|
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"],
|
|
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
|
+
}
|