@ldlework/workmark 1.3.1 → 1.4.1
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/README.md +22 -24
- package/dist/cli.js +9 -2
- package/dist/lib/define.d.ts +71 -2
- package/dist/lib/define.js +87 -1
- package/dist/lib/helpers.d.ts +3 -0
- package/dist/lib/helpers.js +20 -2
- package/dist/lib/load.d.ts +3 -2
- package/dist/lib/load.js +428 -55
- package/dist/lib/parse.d.ts +2 -4
- package/dist/lib/parse.js +42 -10
- package/dist/lib/project.d.ts +10 -5
- package/dist/lib/project.js +45 -12
- package/dist/lib/registry.d.ts +15 -0
- package/dist/lib/registry.js +64 -0
- package/dist/lib/types.d.ts +86 -35
- package/dist/lib/types.js +5 -1
- package/dist/lib/workspace.d.ts +6 -3
- package/dist/lib/workspace.js +61 -24
- package/package.json +1 -1
- package/src/cli.ts +8 -2
- package/src/lib/define.ts +180 -2
- package/src/lib/helpers.ts +24 -2
- package/src/lib/load.ts +556 -60
- package/src/lib/parse.ts +47 -10
- package/src/lib/project.ts +58 -13
- package/src/lib/registry.ts +80 -0
- package/src/lib/types.ts +99 -41
- package/src/lib/workspace.ts +67 -30
package/src/lib/load.ts
CHANGED
|
@@ -1,20 +1,164 @@
|
|
|
1
|
-
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { basename, extname, join } from "node:path";
|
|
3
3
|
import { createJiti } from "jiti";
|
|
4
4
|
import { z } from "zod";
|
|
5
|
-
import
|
|
5
|
+
import { execAsync, ok, fail, shSeq } from "./helpers.js";
|
|
6
|
+
import type {
|
|
7
|
+
BaseCtx,
|
|
8
|
+
HandlerReturn,
|
|
9
|
+
IProject,
|
|
10
|
+
IWorkspace,
|
|
11
|
+
NeedsCtx,
|
|
12
|
+
ProjectResult,
|
|
13
|
+
ResolvedCommand,
|
|
14
|
+
ResolvedHandler,
|
|
15
|
+
RunOptions,
|
|
16
|
+
SchemaFields,
|
|
17
|
+
SelectMode,
|
|
18
|
+
Trait,
|
|
19
|
+
} from "./types.js";
|
|
20
|
+
import { FROM_ARGS, FROM_WORKSPACE } from "./types.js";
|
|
21
|
+
import type { StaticCommandDef } from "./define.js";
|
|
22
|
+
import type { Workspace } from "./workspace.js";
|
|
23
|
+
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
6
24
|
import { jitiOptions } from "./jiti-options.js";
|
|
7
25
|
|
|
8
|
-
|
|
26
|
+
// --- Metadata derivation -------------------------------------------------
|
|
27
|
+
|
|
9
28
|
function titleCase(s: string): string {
|
|
10
|
-
return s
|
|
29
|
+
return s
|
|
30
|
+
.split(/[_\s-]+/)
|
|
31
|
+
.filter(Boolean)
|
|
32
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
33
|
+
.join(" ");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function extractLeadingJsDoc(source: string): string | undefined {
|
|
37
|
+
const exportIdx = source.indexOf("export default");
|
|
38
|
+
const before = exportIdx < 0 ? source : source.slice(0, exportIdx);
|
|
39
|
+
const blocks = [...before.matchAll(/\/\*\*([\s\S]*?)\*\//g)];
|
|
40
|
+
if (blocks.length === 0) return undefined;
|
|
41
|
+
const last = blocks[blocks.length - 1][1];
|
|
42
|
+
return last
|
|
43
|
+
.split("\n")
|
|
44
|
+
.map((line) => line.replace(/^\s*\*\s?/, "").trimEnd())
|
|
45
|
+
.join(" ")
|
|
46
|
+
.trim() || undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface DerivedMeta {
|
|
50
|
+
name: string;
|
|
51
|
+
label: string;
|
|
52
|
+
description: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function deriveMetadata(def: StaticCommandDef, sourceFile: string): DerivedMeta {
|
|
56
|
+
const filenameName = basename(sourceFile, extname(sourceFile));
|
|
57
|
+
const meta = def.meta ?? {};
|
|
58
|
+
const name = meta.name ?? filenameName;
|
|
59
|
+
const label = meta.label ?? titleCase(name);
|
|
60
|
+
const description =
|
|
61
|
+
meta.description ?? extractLeadingJsDoc(readFileSync(sourceFile, "utf-8")) ?? "";
|
|
62
|
+
return { name, label, description };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// --- fromWorkspace / fromArgs resolution --------------------------------
|
|
66
|
+
|
|
67
|
+
type FromWsMarker = { [FROM_WORKSPACE]?: (ws: IWorkspace) => z.ZodType };
|
|
68
|
+
type FromArgsMarker = { [FROM_ARGS]?: (ws: IWorkspace, args: Record<string, unknown>) => z.ZodType };
|
|
69
|
+
|
|
70
|
+
/** Detect whether a schema (or any of its wrappers) carries a FROM_ARGS marker.
|
|
71
|
+
* Such fields are resolved at invocation time, not load time. */
|
|
72
|
+
function hasFromArgs(field: z.ZodType | Record<string, unknown>): boolean {
|
|
73
|
+
if (!(field instanceof z.ZodType)) return false;
|
|
74
|
+
return walkForFromArgs(field);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function walkForFromArgs(node: z.ZodType): boolean {
|
|
78
|
+
if ((node as unknown as FromArgsMarker)[FROM_ARGS]) return true;
|
|
79
|
+
if (node instanceof z.ZodOptional) return walkForFromArgs(node.unwrap() as unknown as z.ZodType);
|
|
80
|
+
if (node instanceof z.ZodNullable) return walkForFromArgs(node.unwrap() as unknown as z.ZodType);
|
|
81
|
+
if (node instanceof z.ZodDefault) return walkForFromArgs(node.unwrap() as unknown as z.ZodType);
|
|
82
|
+
if (node instanceof z.ZodArray) return walkForFromArgs(node.element as unknown as z.ZodType);
|
|
83
|
+
return false;
|
|
11
84
|
}
|
|
12
85
|
|
|
13
|
-
function
|
|
14
|
-
|
|
86
|
+
function resolveFromWorkspace(field: z.ZodType | Record<string, unknown>, ws: IWorkspace): z.ZodType | Record<string, unknown> {
|
|
87
|
+
if (!(field instanceof z.ZodType)) return field;
|
|
88
|
+
return walk(field, ws);
|
|
15
89
|
}
|
|
16
90
|
|
|
17
|
-
|
|
91
|
+
function walk(node: z.ZodType, ws: IWorkspace): z.ZodType {
|
|
92
|
+
// Leave FROM_ARGS markers alone — they resolve per-invocation.
|
|
93
|
+
if ((node as unknown as FromArgsMarker)[FROM_ARGS]) return node;
|
|
94
|
+
|
|
95
|
+
const marked = (node as unknown as FromWsMarker)[FROM_WORKSPACE];
|
|
96
|
+
if (marked) return marked(ws);
|
|
97
|
+
|
|
98
|
+
if (node instanceof z.ZodOptional) return walk(node.unwrap() as unknown as z.ZodType, ws).optional();
|
|
99
|
+
if (node instanceof z.ZodNullable) return walk(node.unwrap() as unknown as z.ZodType, ws).nullable();
|
|
100
|
+
if (node instanceof z.ZodDefault) {
|
|
101
|
+
const def = (node._def as { defaultValue: unknown }).defaultValue;
|
|
102
|
+
return walk(node.unwrap() as unknown as z.ZodType, ws).default(def as never);
|
|
103
|
+
}
|
|
104
|
+
if (node instanceof z.ZodArray) {
|
|
105
|
+
return z.array(walk(node.element as unknown as z.ZodType, ws));
|
|
106
|
+
}
|
|
107
|
+
return node;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function resolveFields(fields: SchemaFields | undefined, ws: IWorkspace): SchemaFields | undefined {
|
|
111
|
+
if (!fields) return fields;
|
|
112
|
+
const out: SchemaFields = {};
|
|
113
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
114
|
+
out[k] = resolveFromWorkspace(v, ws);
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Resolve FROM_ARGS markers in a walked schema tree against a specific args object. */
|
|
120
|
+
function walkWithArgs(node: z.ZodType, ws: IWorkspace, args: Record<string, unknown>): z.ZodType {
|
|
121
|
+
const marked = (node as unknown as FromArgsMarker)[FROM_ARGS];
|
|
122
|
+
if (marked) return marked(ws, args);
|
|
123
|
+
if (node instanceof z.ZodOptional) return walkWithArgs(node.unwrap() as unknown as z.ZodType, ws, args).optional();
|
|
124
|
+
if (node instanceof z.ZodNullable) return walkWithArgs(node.unwrap() as unknown as z.ZodType, ws, args).nullable();
|
|
125
|
+
if (node instanceof z.ZodDefault) {
|
|
126
|
+
const def = (node._def as { defaultValue: unknown }).defaultValue;
|
|
127
|
+
return walkWithArgs(node.unwrap() as unknown as z.ZodType, ws, args).default(def as never);
|
|
128
|
+
}
|
|
129
|
+
if (node instanceof z.ZodArray) {
|
|
130
|
+
return z.array(walkWithArgs(node.element as unknown as z.ZodType, ws, args));
|
|
131
|
+
}
|
|
132
|
+
return node;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Per-invocation: validate any FROM_ARGS fields against the resolved schema.
|
|
136
|
+
* Returns the parsed args (with fromArgs fields replaced by validated values). */
|
|
137
|
+
function validateFromArgsFields(
|
|
138
|
+
args: Record<string, unknown>,
|
|
139
|
+
fromArgsFields: Array<{ name: string; schema: z.ZodType }>,
|
|
140
|
+
ws: IWorkspace,
|
|
141
|
+
): Record<string, unknown> {
|
|
142
|
+
if (fromArgsFields.length === 0) return args;
|
|
143
|
+
const out = { ...args };
|
|
144
|
+
for (const { name, schema } of fromArgsFields) {
|
|
145
|
+
const resolved = walkWithArgs(schema, ws, args);
|
|
146
|
+
const raw = args[name];
|
|
147
|
+
if (raw === undefined) continue;
|
|
148
|
+
const result = resolved.safeParse(raw);
|
|
149
|
+
if (!result.success) {
|
|
150
|
+
const issues = result.error.issues
|
|
151
|
+
.map((i) => ` ${i.path.join(".") || "(root)"}: ${i.message}`)
|
|
152
|
+
.join("\n");
|
|
153
|
+
throw new Error(`Field "${name}" failed validation:\n${issues}`);
|
|
154
|
+
}
|
|
155
|
+
out[name] = result.data;
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// --- Schema merging + project-arg synthesis ------------------------------
|
|
161
|
+
|
|
18
162
|
function fieldToJsonSchema(field: z.ZodType | Record<string, unknown>): Record<string, unknown> {
|
|
19
163
|
if (field instanceof z.ZodType) {
|
|
20
164
|
return z.toJSONSchema(field) as Record<string, unknown>;
|
|
@@ -22,25 +166,68 @@ function fieldToJsonSchema(field: z.ZodType | Record<string, unknown>): Record<s
|
|
|
22
166
|
return field;
|
|
23
167
|
}
|
|
24
168
|
|
|
25
|
-
/** A field is required unless it has a default or was wrapped with .optional(). */
|
|
26
169
|
function isRequired(field: z.ZodType | Record<string, unknown>, schema: Record<string, unknown>): boolean {
|
|
27
170
|
if ("default" in schema) return false;
|
|
28
171
|
if (field instanceof z.ZodType && field.safeParse(undefined).success) return false;
|
|
29
172
|
return true;
|
|
30
173
|
}
|
|
31
174
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
175
|
+
interface BuiltSchema {
|
|
176
|
+
inputSchema: Record<string, unknown>;
|
|
177
|
+
positional: string[];
|
|
178
|
+
projectArgName: string | null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function projectArgFor(
|
|
182
|
+
needs: readonly Trait[],
|
|
183
|
+
select: SelectMode,
|
|
184
|
+
workspace: IWorkspace,
|
|
185
|
+
): { schema: z.ZodType; required: boolean; eligible: string[] } | null {
|
|
186
|
+
if (needs.length === 0) return null;
|
|
187
|
+
if (select === "all") return null; // framework selects; no user input
|
|
188
|
+
|
|
189
|
+
const eligible = workspace.projects
|
|
190
|
+
.filter((p) => needs.every((t) => p.hasTrait(t)))
|
|
191
|
+
.map((p) => p.name);
|
|
192
|
+
if (eligible.length === 0) {
|
|
193
|
+
const traits = needs.map((t) => t.name).join(", ");
|
|
194
|
+
throw new Error(
|
|
195
|
+
`Command requires needs [${traits}] but no projects fulfill all of them`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
const enum_ = z.enum(eligible as [string, ...string[]]);
|
|
199
|
+
if (select === "one-or-many") {
|
|
200
|
+
// accept string or array; we'll coerce at parse time
|
|
201
|
+
return { schema: z.union([enum_, z.array(enum_)]), required: false, eligible };
|
|
202
|
+
}
|
|
203
|
+
return { schema: enum_, required: true, eligible };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function buildSchema(
|
|
207
|
+
args: SchemaFields | undefined,
|
|
208
|
+
flags: SchemaFields | undefined,
|
|
209
|
+
projectArg: { schema: z.ZodType; required: boolean } | null,
|
|
210
|
+
): BuiltSchema {
|
|
37
211
|
const properties: Record<string, unknown> = {};
|
|
38
212
|
const required: string[] = [];
|
|
39
213
|
const positional: string[] = [];
|
|
214
|
+
let projectArgName: string | null = null;
|
|
40
215
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
216
|
+
if (projectArg) {
|
|
217
|
+
projectArgName = "project";
|
|
218
|
+
properties.project = fieldToJsonSchema(projectArg.schema);
|
|
219
|
+
positional.push("project");
|
|
220
|
+
if (projectArg.required) required.push("project");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const collect = (src: SchemaFields | undefined, isPositional: boolean) => {
|
|
224
|
+
if (!src) return;
|
|
225
|
+
for (const [name, field] of Object.entries(src)) {
|
|
226
|
+
if (name === "project" && projectArgName) {
|
|
227
|
+
throw new Error(
|
|
228
|
+
`Field "project" collides with framework-injected ctx.project.\n hint: rename the field; ctx.project is always present under needs`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
44
231
|
const schema = fieldToJsonSchema(field);
|
|
45
232
|
properties[name] = schema;
|
|
46
233
|
if (isPositional) positional.push(name);
|
|
@@ -51,67 +238,376 @@ function mergeSchemas(
|
|
|
51
238
|
collect(args, true);
|
|
52
239
|
collect(flags, false);
|
|
53
240
|
|
|
54
|
-
const inputSchema: Record<string, unknown> = {
|
|
55
|
-
|
|
56
|
-
|
|
241
|
+
const inputSchema: Record<string, unknown> = { type: "object", properties };
|
|
242
|
+
if (required.length > 0) inputSchema.required = required;
|
|
243
|
+
return { inputSchema, positional, projectArgName };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// --- Context construction ------------------------------------------------
|
|
247
|
+
|
|
248
|
+
/** Invocation host: looks up commands for ctx.invoke. Set during loadCommands. */
|
|
249
|
+
interface InvocationHost {
|
|
250
|
+
invoke: (name: string, args: Record<string, unknown>) => Promise<CallToolResult>;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function makeBaseCtx(
|
|
254
|
+
ws: IWorkspace,
|
|
255
|
+
cwdResolver: () => string,
|
|
256
|
+
host: InvocationHost,
|
|
257
|
+
): BaseCtx {
|
|
258
|
+
return {
|
|
259
|
+
workspace: ws,
|
|
260
|
+
ok,
|
|
261
|
+
fail,
|
|
262
|
+
sh: (cmd, opts) => {
|
|
263
|
+
const options = { cwd: cwdResolver(), timeout: opts?.timeout, env: opts?.env };
|
|
264
|
+
return Array.isArray(cmd)
|
|
265
|
+
? shSeq(cmd, options)
|
|
266
|
+
: execAsync(cmd as string, options);
|
|
267
|
+
},
|
|
268
|
+
exec: (cmd, opts) => execAsync(cmd, opts),
|
|
269
|
+
invoke: host.invoke,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function makeNeedsCtx(
|
|
274
|
+
ws: IWorkspace,
|
|
275
|
+
project: IProject,
|
|
276
|
+
needs: readonly Trait[],
|
|
277
|
+
host: InvocationHost,
|
|
278
|
+
): NeedsCtx<Record<string, unknown>> {
|
|
279
|
+
const traits: Record<string, unknown> = {};
|
|
280
|
+
for (const t of needs) traits[t.name] = project.trait(t as Trait<string, unknown>);
|
|
281
|
+
return {
|
|
282
|
+
...makeBaseCtx(ws, () => project.dir, host),
|
|
283
|
+
project,
|
|
284
|
+
traits,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// --- Handler wrapping ----------------------------------------------------
|
|
289
|
+
|
|
290
|
+
function stripProjectArg(args: Record<string, unknown>): Record<string, unknown> {
|
|
291
|
+
const { project: _drop, ...rest } = args;
|
|
292
|
+
return rest;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function isCallToolResult(v: unknown): v is CallToolResult {
|
|
296
|
+
return typeof v === "object" && v !== null && "content" in (v as object);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function runHandler(
|
|
300
|
+
raw: StaticCommandDef["handler"],
|
|
301
|
+
args: Record<string, unknown>,
|
|
302
|
+
ctx: BaseCtx | NeedsCtx<Record<string, unknown>>,
|
|
303
|
+
): Promise<CallToolResult> {
|
|
304
|
+
try {
|
|
305
|
+
const out = await raw(args, ctx);
|
|
306
|
+
if (isCallToolResult(out)) return out;
|
|
307
|
+
return { content: [{ type: "text", text: String(out) }] };
|
|
308
|
+
} catch (e) {
|
|
309
|
+
return fail(e);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function resolveProjects(
|
|
314
|
+
needs: readonly Trait[],
|
|
315
|
+
select: SelectMode,
|
|
316
|
+
workspace: IWorkspace,
|
|
317
|
+
argsValue: unknown,
|
|
318
|
+
): IProject[] {
|
|
319
|
+
const eligible = workspace.projects.filter((p) => needs.every((t) => p.hasTrait(t)));
|
|
320
|
+
const eligibleList = eligible.map((p) => p.name).join(", ");
|
|
321
|
+
const traitList = needs.map((t) => t.name).join(", ");
|
|
322
|
+
|
|
323
|
+
if (select === "all") return eligible;
|
|
324
|
+
|
|
325
|
+
const names: string[] = Array.isArray(argsValue)
|
|
326
|
+
? (argsValue as string[])
|
|
327
|
+
: typeof argsValue === "string"
|
|
328
|
+
? [argsValue]
|
|
329
|
+
: [];
|
|
330
|
+
|
|
331
|
+
if (names.length === 0) {
|
|
332
|
+
if (select === "one") {
|
|
333
|
+
throw new Error(
|
|
334
|
+
`Missing project argument.\n needs: [${traitList}]\n eligible: ${eligibleList}`,
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
throw new Error(
|
|
338
|
+
`Missing project argument — select "${select}" requires 1+ project names.\n needs: [${traitList}]\n eligible: ${eligibleList}`,
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const resolved: IProject[] = [];
|
|
343
|
+
for (const n of names) {
|
|
344
|
+
let p: IProject;
|
|
345
|
+
try {
|
|
346
|
+
p = workspace.get(n);
|
|
347
|
+
} catch {
|
|
348
|
+
throw new Error(
|
|
349
|
+
`Unknown project "${n}".\n available: ${workspace.projects.map((p) => p.name).join(", ")}\n eligible for this command: ${eligibleList}`,
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
const missing = needs.filter((t) => !p.hasTrait(t)).map((t) => t.name);
|
|
353
|
+
if (missing.length > 0) {
|
|
354
|
+
throw new Error(
|
|
355
|
+
`Project "${p.name}" does not fulfill required trait(s) [${missing.join(", ")}].\n eligible projects: ${eligibleList}`,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
resolved.push(p);
|
|
359
|
+
}
|
|
360
|
+
return resolved;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function runMultiple(
|
|
364
|
+
raw: StaticCommandDef["handler"],
|
|
365
|
+
args: Record<string, unknown>,
|
|
366
|
+
projects: IProject[],
|
|
367
|
+
needs: readonly Trait[],
|
|
368
|
+
workspace: IWorkspace,
|
|
369
|
+
run: RunOptions | undefined,
|
|
370
|
+
host: InvocationHost,
|
|
371
|
+
): Promise<CallToolResult> {
|
|
372
|
+
const callerArgs = stripProjectArg(args);
|
|
373
|
+
|
|
374
|
+
const runOne = async (p: IProject): Promise<ProjectResult> => {
|
|
375
|
+
const ctx = makeNeedsCtx(workspace, p, needs, host);
|
|
376
|
+
const res = await runHandler(raw, callerArgs, ctx);
|
|
377
|
+
const text = res.content.map((c) => (c.type === "text" ? c.text : "")).join("\n");
|
|
378
|
+
return { project: p.name, ok: !res.isError, output: text, error: res.isError ? text : undefined };
|
|
57
379
|
};
|
|
58
|
-
|
|
59
|
-
|
|
380
|
+
|
|
381
|
+
// serial when requested; otherwise parallel with concurrency cap
|
|
382
|
+
const serial = run?.order === "serial";
|
|
383
|
+
const concurrency = serial ? 1 : (run?.concurrency ?? 4);
|
|
384
|
+
const results: ProjectResult[] = new Array(projects.length);
|
|
385
|
+
let idx = 0;
|
|
386
|
+
const workers = Array.from({ length: Math.min(concurrency, projects.length) }, async () => {
|
|
387
|
+
while (true) {
|
|
388
|
+
const i = idx++;
|
|
389
|
+
if (i >= projects.length) return;
|
|
390
|
+
results[i] = await runOne(projects[i]);
|
|
391
|
+
if (run?.stopOnFailure && !results[i].ok) {
|
|
392
|
+
idx = projects.length;
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
await Promise.all(workers);
|
|
398
|
+
|
|
399
|
+
const realResults = results.filter(Boolean);
|
|
400
|
+
|
|
401
|
+
if (run?.reduce) {
|
|
402
|
+
return run.reduce(realResults);
|
|
60
403
|
}
|
|
61
404
|
|
|
62
|
-
|
|
405
|
+
// default aggregation — if only one ran, return its output directly
|
|
406
|
+
if (realResults.length === 1) {
|
|
407
|
+
const r = realResults[0];
|
|
408
|
+
return r.ok ? ok(r.output ?? "") : fail(r.error ?? r.output ?? "");
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const parts: string[] = [];
|
|
412
|
+
let anyError = false;
|
|
413
|
+
for (const r of realResults) {
|
|
414
|
+
parts.push(`--- ${r.project} ---\n${r.output ?? ""}`);
|
|
415
|
+
if (!r.ok) anyError = true;
|
|
416
|
+
}
|
|
417
|
+
const text = parts.join("\n\n");
|
|
418
|
+
return anyError ? fail(text) : ok(text);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// --- Main resolve --------------------------------------------------------
|
|
422
|
+
|
|
423
|
+
function collectFromArgsFields(fields: SchemaFields | undefined): Array<{ name: string; schema: z.ZodType }> {
|
|
424
|
+
const out: Array<{ name: string; schema: z.ZodType }> = [];
|
|
425
|
+
if (!fields) return out;
|
|
426
|
+
for (const [name, field] of Object.entries(fields)) {
|
|
427
|
+
if (field instanceof z.ZodType && hasFromArgs(field)) out.push({ name, schema: field });
|
|
428
|
+
}
|
|
429
|
+
return out;
|
|
63
430
|
}
|
|
64
431
|
|
|
65
|
-
function resolve(
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
432
|
+
function resolve(
|
|
433
|
+
def: StaticCommandDef,
|
|
434
|
+
workspace: Workspace,
|
|
435
|
+
group: string,
|
|
436
|
+
sourceFile: string,
|
|
437
|
+
host: InvocationHost,
|
|
438
|
+
): ResolvedCommand {
|
|
439
|
+
const meta = deriveMetadata(def, sourceFile);
|
|
440
|
+
const rawNeeds = def.needs ?? [];
|
|
441
|
+
const needs: Trait[] = rawNeeds.map((t) =>
|
|
442
|
+
typeof t === "string" ? workspace.traits.require(t) : t as Trait,
|
|
443
|
+
);
|
|
444
|
+
const select: SelectMode = def.select ?? (needs.length > 0 ? "one-or-many" : "one");
|
|
69
445
|
|
|
70
|
-
if (
|
|
71
|
-
|
|
72
|
-
flags = def.flags;
|
|
73
|
-
handler = def.handler;
|
|
74
|
-
} else {
|
|
75
|
-
const result = def.factory(workspace);
|
|
76
|
-
args = result.args;
|
|
77
|
-
flags = result.flags;
|
|
78
|
-
handler = result.handler;
|
|
446
|
+
if (needs.length === 0 && select !== "one") {
|
|
447
|
+
throw new Error(`Command "${meta.name}": select requires needs (at ${sourceFile})`);
|
|
79
448
|
}
|
|
80
449
|
|
|
81
|
-
|
|
82
|
-
|
|
450
|
+
// `for` binds this command to a named project. No project arg is exposed on
|
|
451
|
+
// any surface; ctx.project is the bound project.
|
|
452
|
+
let boundProject: IProject | null = null;
|
|
453
|
+
if (def.for !== undefined) {
|
|
454
|
+
if (needs.length === 0) {
|
|
455
|
+
throw new Error(
|
|
456
|
+
`Command "${meta.name}": \`for\` requires \`needs\` (a fixed project without required traits is meaningless)\n at ${sourceFile}`,
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
try {
|
|
460
|
+
boundProject = workspace.get(def.for);
|
|
461
|
+
} catch {
|
|
462
|
+
throw new Error(
|
|
463
|
+
`Command "${meta.name}": \`for: "${def.for}"\` — project not found\n at ${sourceFile}\n available: ${workspace.projects.map(p => p.name).join(", ")}`,
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
for (const t of needs) {
|
|
467
|
+
if (!boundProject.hasTrait(t)) {
|
|
468
|
+
throw new Error(
|
|
469
|
+
`Command "${meta.name}": bound project "${def.for}" does not fulfill trait "${t.name}"\n at ${sourceFile}`,
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const resolvedArgs = resolveFields(def.args, workspace);
|
|
476
|
+
const resolvedFlags = resolveFields(def.flags, workspace);
|
|
477
|
+
|
|
478
|
+
// Collect invocation-time-resolved fields for per-call validation.
|
|
479
|
+
const fromArgsFields = [
|
|
480
|
+
...collectFromArgsFields(resolvedArgs),
|
|
481
|
+
...collectFromArgsFields(resolvedFlags),
|
|
482
|
+
];
|
|
483
|
+
|
|
484
|
+
// `for` suppresses the user-facing project arg.
|
|
485
|
+
const projectArg = boundProject ? null : projectArgFor(needs, select, workspace);
|
|
486
|
+
const { inputSchema, positional } = buildSchema(resolvedArgs, resolvedFlags, projectArg);
|
|
487
|
+
|
|
488
|
+
const handler: ResolvedHandler = async (args) => {
|
|
489
|
+
const validated = validateFromArgsFields(args, fromArgsFields, workspace);
|
|
490
|
+
|
|
491
|
+
if (needs.length === 0) {
|
|
492
|
+
const ctx = makeBaseCtx(workspace, () => workspace.root, host);
|
|
493
|
+
return runHandler(def.handler, validated, ctx);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (boundProject) {
|
|
497
|
+
const ctx = makeNeedsCtx(workspace, boundProject, needs, host);
|
|
498
|
+
return runHandler(def.handler, validated, ctx);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const projects = resolveProjects(needs, select, workspace, validated.project);
|
|
502
|
+
|
|
503
|
+
if (select === "one") {
|
|
504
|
+
const ctx = makeNeedsCtx(workspace, projects[0], needs, host);
|
|
505
|
+
return runHandler(def.handler, stripProjectArg(validated), ctx);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
return runMultiple(def.handler, validated, projects, needs, workspace, def.run, host);
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
return {
|
|
512
|
+
name: meta.name,
|
|
513
|
+
label: meta.label,
|
|
514
|
+
group,
|
|
515
|
+
description: meta.description,
|
|
516
|
+
inputSchema,
|
|
517
|
+
positional,
|
|
518
|
+
handler,
|
|
519
|
+
sourceFile,
|
|
520
|
+
select,
|
|
521
|
+
needs: needs.map((t) => t.name),
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// --- Discovery -----------------------------------------------------------
|
|
526
|
+
|
|
527
|
+
async function importCommand(
|
|
528
|
+
jiti: ReturnType<typeof createJiti>,
|
|
529
|
+
filePath: string,
|
|
530
|
+
): Promise<StaticCommandDef | undefined> {
|
|
531
|
+
const mod = (await jiti.import(filePath)) as { default?: StaticCommandDef };
|
|
532
|
+
return mod.default;
|
|
83
533
|
}
|
|
84
534
|
|
|
85
|
-
|
|
535
|
+
function isCommandFile(name: string): boolean {
|
|
536
|
+
return name.endsWith(".ts") && !name.endsWith(".d.ts");
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
interface Discovered {
|
|
540
|
+
filePath: string;
|
|
541
|
+
resolvedName: string; // colon-joined path relative to commands root (minus extension)
|
|
542
|
+
group: string;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** Walk the commands dir recursively. Maps paths to colon-joined command names. */
|
|
546
|
+
function walkCommands(root: string): Discovered[] {
|
|
547
|
+
const out: Discovered[] = [];
|
|
548
|
+
function walk(dir: string, prefix: string[]): void {
|
|
549
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
550
|
+
const full = join(dir, entry.name);
|
|
551
|
+
if (entry.isDirectory()) {
|
|
552
|
+
walk(full, [...prefix, entry.name]);
|
|
553
|
+
} else if (isCommandFile(entry.name)) {
|
|
554
|
+
const base = entry.name.replace(/\.ts$/, "");
|
|
555
|
+
if (base.includes(":")) {
|
|
556
|
+
throw new Error(`Command filename contains ':' which is reserved: ${full}`);
|
|
557
|
+
}
|
|
558
|
+
const parts = base === "index" ? prefix : [...prefix, base];
|
|
559
|
+
if (parts.length === 0) continue; // bare root index.ts not allowed
|
|
560
|
+
const resolvedName = parts.join(":");
|
|
561
|
+
const group = prefix.length > 0 ? titleCase(prefix[0]) : "";
|
|
562
|
+
out.push({ filePath: full, resolvedName, group });
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
walk(root, []);
|
|
567
|
+
return out;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
export async function loadCommands(workspace: Workspace): Promise<ResolvedCommand[]> {
|
|
86
571
|
const commandsDir = join(workspace.root, ".wm", "commands");
|
|
87
572
|
if (!existsSync(commandsDir)) return [];
|
|
88
573
|
|
|
89
574
|
const jiti = createJiti(commandsDir, jitiOptions());
|
|
90
575
|
const commands: ResolvedCommand[] = [];
|
|
576
|
+
const byName = new Map<string, ResolvedCommand>();
|
|
91
577
|
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// Grouped commands (subdirectories)
|
|
103
|
-
for (const entry of readdirSync(commandsDir)) {
|
|
104
|
-
const entryPath = join(commandsDir, entry);
|
|
105
|
-
const stat = statSync(entryPath);
|
|
106
|
-
if (stat.isDirectory()) {
|
|
107
|
-
const group = titleCase(entry);
|
|
108
|
-
for (const file of readdirSync(entryPath)) {
|
|
109
|
-
if (!file.endsWith(".ts") || file.endsWith(".d.ts")) continue;
|
|
110
|
-
const filePath = join(entryPath, file);
|
|
111
|
-
const mod = await jiti.import(filePath) as { default: CommandDef };
|
|
112
|
-
commands.push(resolve(mod.default, workspace, group, filePath));
|
|
578
|
+
// Invocation host: commands map is populated progressively; cycle-detected per-call.
|
|
579
|
+
const activeStack = new Set<string>();
|
|
580
|
+
const host: InvocationHost = {
|
|
581
|
+
invoke: async (name, args) => {
|
|
582
|
+
if (activeStack.has(name)) {
|
|
583
|
+
const trace = [...activeStack, name].join(" → ");
|
|
584
|
+
return fail(`Cyclic invocation: ${trace}`);
|
|
113
585
|
}
|
|
586
|
+
const target = byName.get(name);
|
|
587
|
+
if (!target) return fail(`Unknown command: "${name}"`);
|
|
588
|
+
activeStack.add(name);
|
|
589
|
+
try {
|
|
590
|
+
return await target.handler(args);
|
|
591
|
+
} finally {
|
|
592
|
+
activeStack.delete(name);
|
|
593
|
+
}
|
|
594
|
+
},
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
const discovered = walkCommands(commandsDir);
|
|
598
|
+
for (const d of discovered) {
|
|
599
|
+
const def = await importCommand(jiti, d.filePath);
|
|
600
|
+
if (!def) continue;
|
|
601
|
+
const cmd = resolve(def, workspace, d.group, d.filePath, host);
|
|
602
|
+
// Override resolved name from path-derived name (filename was the fallback in meta).
|
|
603
|
+
// Meta.name wins if explicitly set; otherwise use the discovered name.
|
|
604
|
+
const userProvidedName = def.meta?.name;
|
|
605
|
+
if (!userProvidedName) cmd.name = d.resolvedName;
|
|
606
|
+
if (byName.has(cmd.name)) {
|
|
607
|
+
throw new Error(`Duplicate command name "${cmd.name}": ${d.filePath} collides with ${byName.get(cmd.name)!.sourceFile}`);
|
|
114
608
|
}
|
|
609
|
+
byName.set(cmd.name, cmd);
|
|
610
|
+
commands.push(cmd);
|
|
115
611
|
}
|
|
116
612
|
|
|
117
613
|
return commands;
|