@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/dist/lib/load.js
CHANGED
|
@@ -1,23 +1,140 @@
|
|
|
1
|
-
import { existsSync, readdirSync,
|
|
2
|
-
import { join } from "node:path";
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } 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 { execAsync, ok, fail, shSeq } from "./helpers.js";
|
|
6
|
+
import { FROM_ARGS, FROM_WORKSPACE } from "./types.js";
|
|
5
7
|
import { jitiOptions } from "./jiti-options.js";
|
|
6
|
-
|
|
8
|
+
// --- Metadata derivation -------------------------------------------------
|
|
7
9
|
function titleCase(s) {
|
|
8
|
-
return s
|
|
10
|
+
return s
|
|
11
|
+
.split(/[_\s-]+/)
|
|
12
|
+
.filter(Boolean)
|
|
13
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
14
|
+
.join(" ");
|
|
9
15
|
}
|
|
10
|
-
function
|
|
11
|
-
|
|
16
|
+
function extractLeadingJsDoc(source) {
|
|
17
|
+
const exportIdx = source.indexOf("export default");
|
|
18
|
+
const before = exportIdx < 0 ? source : source.slice(0, exportIdx);
|
|
19
|
+
const blocks = [...before.matchAll(/\/\*\*([\s\S]*?)\*\//g)];
|
|
20
|
+
if (blocks.length === 0)
|
|
21
|
+
return undefined;
|
|
22
|
+
const last = blocks[blocks.length - 1][1];
|
|
23
|
+
return last
|
|
24
|
+
.split("\n")
|
|
25
|
+
.map((line) => line.replace(/^\s*\*\s?/, "").trimEnd())
|
|
26
|
+
.join(" ")
|
|
27
|
+
.trim() || undefined;
|
|
12
28
|
}
|
|
13
|
-
|
|
29
|
+
function deriveMetadata(def, sourceFile) {
|
|
30
|
+
const filenameName = basename(sourceFile, extname(sourceFile));
|
|
31
|
+
const meta = def.meta ?? {};
|
|
32
|
+
const name = meta.name ?? filenameName;
|
|
33
|
+
const label = meta.label ?? titleCase(name);
|
|
34
|
+
const description = meta.description ?? extractLeadingJsDoc(readFileSync(sourceFile, "utf-8")) ?? "";
|
|
35
|
+
return { name, label, description };
|
|
36
|
+
}
|
|
37
|
+
/** Detect whether a schema (or any of its wrappers) carries a FROM_ARGS marker.
|
|
38
|
+
* Such fields are resolved at invocation time, not load time. */
|
|
39
|
+
function hasFromArgs(field) {
|
|
40
|
+
if (!(field instanceof z.ZodType))
|
|
41
|
+
return false;
|
|
42
|
+
return walkForFromArgs(field);
|
|
43
|
+
}
|
|
44
|
+
function walkForFromArgs(node) {
|
|
45
|
+
if (node[FROM_ARGS])
|
|
46
|
+
return true;
|
|
47
|
+
if (node instanceof z.ZodOptional)
|
|
48
|
+
return walkForFromArgs(node.unwrap());
|
|
49
|
+
if (node instanceof z.ZodNullable)
|
|
50
|
+
return walkForFromArgs(node.unwrap());
|
|
51
|
+
if (node instanceof z.ZodDefault)
|
|
52
|
+
return walkForFromArgs(node.unwrap());
|
|
53
|
+
if (node instanceof z.ZodArray)
|
|
54
|
+
return walkForFromArgs(node.element);
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
function resolveFromWorkspace(field, ws) {
|
|
58
|
+
if (!(field instanceof z.ZodType))
|
|
59
|
+
return field;
|
|
60
|
+
return walk(field, ws);
|
|
61
|
+
}
|
|
62
|
+
function walk(node, ws) {
|
|
63
|
+
// Leave FROM_ARGS markers alone — they resolve per-invocation.
|
|
64
|
+
if (node[FROM_ARGS])
|
|
65
|
+
return node;
|
|
66
|
+
const marked = node[FROM_WORKSPACE];
|
|
67
|
+
if (marked)
|
|
68
|
+
return marked(ws);
|
|
69
|
+
if (node instanceof z.ZodOptional)
|
|
70
|
+
return walk(node.unwrap(), ws).optional();
|
|
71
|
+
if (node instanceof z.ZodNullable)
|
|
72
|
+
return walk(node.unwrap(), ws).nullable();
|
|
73
|
+
if (node instanceof z.ZodDefault) {
|
|
74
|
+
const def = node._def.defaultValue;
|
|
75
|
+
return walk(node.unwrap(), ws).default(def);
|
|
76
|
+
}
|
|
77
|
+
if (node instanceof z.ZodArray) {
|
|
78
|
+
return z.array(walk(node.element, ws));
|
|
79
|
+
}
|
|
80
|
+
return node;
|
|
81
|
+
}
|
|
82
|
+
function resolveFields(fields, ws) {
|
|
83
|
+
if (!fields)
|
|
84
|
+
return fields;
|
|
85
|
+
const out = {};
|
|
86
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
87
|
+
out[k] = resolveFromWorkspace(v, ws);
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
/** Resolve FROM_ARGS markers in a walked schema tree against a specific args object. */
|
|
92
|
+
function walkWithArgs(node, ws, args) {
|
|
93
|
+
const marked = node[FROM_ARGS];
|
|
94
|
+
if (marked)
|
|
95
|
+
return marked(ws, args);
|
|
96
|
+
if (node instanceof z.ZodOptional)
|
|
97
|
+
return walkWithArgs(node.unwrap(), ws, args).optional();
|
|
98
|
+
if (node instanceof z.ZodNullable)
|
|
99
|
+
return walkWithArgs(node.unwrap(), ws, args).nullable();
|
|
100
|
+
if (node instanceof z.ZodDefault) {
|
|
101
|
+
const def = node._def.defaultValue;
|
|
102
|
+
return walkWithArgs(node.unwrap(), ws, args).default(def);
|
|
103
|
+
}
|
|
104
|
+
if (node instanceof z.ZodArray) {
|
|
105
|
+
return z.array(walkWithArgs(node.element, ws, args));
|
|
106
|
+
}
|
|
107
|
+
return node;
|
|
108
|
+
}
|
|
109
|
+
/** Per-invocation: validate any FROM_ARGS fields against the resolved schema.
|
|
110
|
+
* Returns the parsed args (with fromArgs fields replaced by validated values). */
|
|
111
|
+
function validateFromArgsFields(args, fromArgsFields, ws) {
|
|
112
|
+
if (fromArgsFields.length === 0)
|
|
113
|
+
return args;
|
|
114
|
+
const out = { ...args };
|
|
115
|
+
for (const { name, schema } of fromArgsFields) {
|
|
116
|
+
const resolved = walkWithArgs(schema, ws, args);
|
|
117
|
+
const raw = args[name];
|
|
118
|
+
if (raw === undefined)
|
|
119
|
+
continue;
|
|
120
|
+
const result = resolved.safeParse(raw);
|
|
121
|
+
if (!result.success) {
|
|
122
|
+
const issues = result.error.issues
|
|
123
|
+
.map((i) => ` ${i.path.join(".") || "(root)"}: ${i.message}`)
|
|
124
|
+
.join("\n");
|
|
125
|
+
throw new Error(`Field "${name}" failed validation:\n${issues}`);
|
|
126
|
+
}
|
|
127
|
+
out[name] = result.data;
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
// --- Schema merging + project-arg synthesis ------------------------------
|
|
14
132
|
function fieldToJsonSchema(field) {
|
|
15
133
|
if (field instanceof z.ZodType) {
|
|
16
134
|
return z.toJSONSchema(field);
|
|
17
135
|
}
|
|
18
136
|
return field;
|
|
19
137
|
}
|
|
20
|
-
/** A field is required unless it has a default or was wrapped with .optional(). */
|
|
21
138
|
function isRequired(field, schema) {
|
|
22
139
|
if ("default" in schema)
|
|
23
140
|
return false;
|
|
@@ -25,15 +142,44 @@ function isRequired(field, schema) {
|
|
|
25
142
|
return false;
|
|
26
143
|
return true;
|
|
27
144
|
}
|
|
28
|
-
|
|
29
|
-
|
|
145
|
+
function projectArgFor(needs, select, workspace) {
|
|
146
|
+
if (needs.length === 0)
|
|
147
|
+
return null;
|
|
148
|
+
if (select === "all")
|
|
149
|
+
return null; // framework selects; no user input
|
|
150
|
+
const eligible = workspace.projects
|
|
151
|
+
.filter((p) => needs.every((t) => p.hasTrait(t)))
|
|
152
|
+
.map((p) => p.name);
|
|
153
|
+
if (eligible.length === 0) {
|
|
154
|
+
const traits = needs.map((t) => t.name).join(", ");
|
|
155
|
+
throw new Error(`Command requires needs [${traits}] but no projects fulfill all of them`);
|
|
156
|
+
}
|
|
157
|
+
const enum_ = z.enum(eligible);
|
|
158
|
+
if (select === "one-or-many") {
|
|
159
|
+
// accept string or array; we'll coerce at parse time
|
|
160
|
+
return { schema: z.union([enum_, z.array(enum_)]), required: false, eligible };
|
|
161
|
+
}
|
|
162
|
+
return { schema: enum_, required: true, eligible };
|
|
163
|
+
}
|
|
164
|
+
function buildSchema(args, flags, projectArg) {
|
|
30
165
|
const properties = {};
|
|
31
166
|
const required = [];
|
|
32
167
|
const positional = [];
|
|
33
|
-
|
|
34
|
-
|
|
168
|
+
let projectArgName = null;
|
|
169
|
+
if (projectArg) {
|
|
170
|
+
projectArgName = "project";
|
|
171
|
+
properties.project = fieldToJsonSchema(projectArg.schema);
|
|
172
|
+
positional.push("project");
|
|
173
|
+
if (projectArg.required)
|
|
174
|
+
required.push("project");
|
|
175
|
+
}
|
|
176
|
+
const collect = (src, isPositional) => {
|
|
177
|
+
if (!src)
|
|
35
178
|
return;
|
|
36
|
-
for (const [name, field] of Object.entries(
|
|
179
|
+
for (const [name, field] of Object.entries(src)) {
|
|
180
|
+
if (name === "project" && projectArgName) {
|
|
181
|
+
throw new Error(`Field "project" collides with framework-injected ctx.project.\n hint: rename the field; ctx.project is always present under needs`);
|
|
182
|
+
}
|
|
37
183
|
const schema = fieldToJsonSchema(field);
|
|
38
184
|
properties[name] = schema;
|
|
39
185
|
if (isPositional)
|
|
@@ -44,32 +190,245 @@ function mergeSchemas(args, flags) {
|
|
|
44
190
|
};
|
|
45
191
|
collect(args, true);
|
|
46
192
|
collect(flags, false);
|
|
47
|
-
const inputSchema = {
|
|
48
|
-
|
|
49
|
-
properties,
|
|
50
|
-
};
|
|
51
|
-
if (required.length > 0) {
|
|
193
|
+
const inputSchema = { type: "object", properties };
|
|
194
|
+
if (required.length > 0)
|
|
52
195
|
inputSchema.required = required;
|
|
196
|
+
return { inputSchema, positional, projectArgName };
|
|
197
|
+
}
|
|
198
|
+
function makeBaseCtx(ws, cwdResolver, host) {
|
|
199
|
+
return {
|
|
200
|
+
workspace: ws,
|
|
201
|
+
ok,
|
|
202
|
+
fail,
|
|
203
|
+
sh: (cmd, opts) => {
|
|
204
|
+
const options = { cwd: cwdResolver(), timeout: opts?.timeout, env: opts?.env };
|
|
205
|
+
return Array.isArray(cmd)
|
|
206
|
+
? shSeq(cmd, options)
|
|
207
|
+
: execAsync(cmd, options);
|
|
208
|
+
},
|
|
209
|
+
exec: (cmd, opts) => execAsync(cmd, opts),
|
|
210
|
+
invoke: host.invoke,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
function makeNeedsCtx(ws, project, needs, host) {
|
|
214
|
+
const traits = {};
|
|
215
|
+
for (const t of needs)
|
|
216
|
+
traits[t.name] = project.trait(t);
|
|
217
|
+
return {
|
|
218
|
+
...makeBaseCtx(ws, () => project.dir, host),
|
|
219
|
+
project,
|
|
220
|
+
traits,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
// --- Handler wrapping ----------------------------------------------------
|
|
224
|
+
function stripProjectArg(args) {
|
|
225
|
+
const { project: _drop, ...rest } = args;
|
|
226
|
+
return rest;
|
|
227
|
+
}
|
|
228
|
+
function isCallToolResult(v) {
|
|
229
|
+
return typeof v === "object" && v !== null && "content" in v;
|
|
230
|
+
}
|
|
231
|
+
async function runHandler(raw, args, ctx) {
|
|
232
|
+
try {
|
|
233
|
+
const out = await raw(args, ctx);
|
|
234
|
+
if (isCallToolResult(out))
|
|
235
|
+
return out;
|
|
236
|
+
return { content: [{ type: "text", text: String(out) }] };
|
|
237
|
+
}
|
|
238
|
+
catch (e) {
|
|
239
|
+
return fail(e);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
function resolveProjects(needs, select, workspace, argsValue) {
|
|
243
|
+
const eligible = workspace.projects.filter((p) => needs.every((t) => p.hasTrait(t)));
|
|
244
|
+
const eligibleList = eligible.map((p) => p.name).join(", ");
|
|
245
|
+
const traitList = needs.map((t) => t.name).join(", ");
|
|
246
|
+
if (select === "all")
|
|
247
|
+
return eligible;
|
|
248
|
+
const names = Array.isArray(argsValue)
|
|
249
|
+
? argsValue
|
|
250
|
+
: typeof argsValue === "string"
|
|
251
|
+
? [argsValue]
|
|
252
|
+
: [];
|
|
253
|
+
if (names.length === 0) {
|
|
254
|
+
if (select === "one") {
|
|
255
|
+
throw new Error(`Missing project argument.\n needs: [${traitList}]\n eligible: ${eligibleList}`);
|
|
256
|
+
}
|
|
257
|
+
throw new Error(`Missing project argument — select "${select}" requires 1+ project names.\n needs: [${traitList}]\n eligible: ${eligibleList}`);
|
|
53
258
|
}
|
|
54
|
-
|
|
259
|
+
const resolved = [];
|
|
260
|
+
for (const n of names) {
|
|
261
|
+
let p;
|
|
262
|
+
try {
|
|
263
|
+
p = workspace.get(n);
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
throw new Error(`Unknown project "${n}".\n available: ${workspace.projects.map((p) => p.name).join(", ")}\n eligible for this command: ${eligibleList}`);
|
|
267
|
+
}
|
|
268
|
+
const missing = needs.filter((t) => !p.hasTrait(t)).map((t) => t.name);
|
|
269
|
+
if (missing.length > 0) {
|
|
270
|
+
throw new Error(`Project "${p.name}" does not fulfill required trait(s) [${missing.join(", ")}].\n eligible projects: ${eligibleList}`);
|
|
271
|
+
}
|
|
272
|
+
resolved.push(p);
|
|
273
|
+
}
|
|
274
|
+
return resolved;
|
|
55
275
|
}
|
|
56
|
-
function
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
276
|
+
async function runMultiple(raw, args, projects, needs, workspace, run, host) {
|
|
277
|
+
const callerArgs = stripProjectArg(args);
|
|
278
|
+
const runOne = async (p) => {
|
|
279
|
+
const ctx = makeNeedsCtx(workspace, p, needs, host);
|
|
280
|
+
const res = await runHandler(raw, callerArgs, ctx);
|
|
281
|
+
const text = res.content.map((c) => (c.type === "text" ? c.text : "")).join("\n");
|
|
282
|
+
return { project: p.name, ok: !res.isError, output: text, error: res.isError ? text : undefined };
|
|
283
|
+
};
|
|
284
|
+
// serial when requested; otherwise parallel with concurrency cap
|
|
285
|
+
const serial = run?.order === "serial";
|
|
286
|
+
const concurrency = serial ? 1 : (run?.concurrency ?? 4);
|
|
287
|
+
const results = new Array(projects.length);
|
|
288
|
+
let idx = 0;
|
|
289
|
+
const workers = Array.from({ length: Math.min(concurrency, projects.length) }, async () => {
|
|
290
|
+
while (true) {
|
|
291
|
+
const i = idx++;
|
|
292
|
+
if (i >= projects.length)
|
|
293
|
+
return;
|
|
294
|
+
results[i] = await runOne(projects[i]);
|
|
295
|
+
if (run?.stopOnFailure && !results[i].ok) {
|
|
296
|
+
idx = projects.length;
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
await Promise.all(workers);
|
|
302
|
+
const realResults = results.filter(Boolean);
|
|
303
|
+
if (run?.reduce) {
|
|
304
|
+
return run.reduce(realResults);
|
|
64
305
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
handler = result.handler;
|
|
306
|
+
// default aggregation — if only one ran, return its output directly
|
|
307
|
+
if (realResults.length === 1) {
|
|
308
|
+
const r = realResults[0];
|
|
309
|
+
return r.ok ? ok(r.output ?? "") : fail(r.error ?? r.output ?? "");
|
|
70
310
|
}
|
|
71
|
-
const
|
|
72
|
-
|
|
311
|
+
const parts = [];
|
|
312
|
+
let anyError = false;
|
|
313
|
+
for (const r of realResults) {
|
|
314
|
+
parts.push(`--- ${r.project} ---\n${r.output ?? ""}`);
|
|
315
|
+
if (!r.ok)
|
|
316
|
+
anyError = true;
|
|
317
|
+
}
|
|
318
|
+
const text = parts.join("\n\n");
|
|
319
|
+
return anyError ? fail(text) : ok(text);
|
|
320
|
+
}
|
|
321
|
+
// --- Main resolve --------------------------------------------------------
|
|
322
|
+
function collectFromArgsFields(fields) {
|
|
323
|
+
const out = [];
|
|
324
|
+
if (!fields)
|
|
325
|
+
return out;
|
|
326
|
+
for (const [name, field] of Object.entries(fields)) {
|
|
327
|
+
if (field instanceof z.ZodType && hasFromArgs(field))
|
|
328
|
+
out.push({ name, schema: field });
|
|
329
|
+
}
|
|
330
|
+
return out;
|
|
331
|
+
}
|
|
332
|
+
function resolve(def, workspace, group, sourceFile, host) {
|
|
333
|
+
const meta = deriveMetadata(def, sourceFile);
|
|
334
|
+
const rawNeeds = def.needs ?? [];
|
|
335
|
+
const needs = rawNeeds.map((t) => typeof t === "string" ? workspace.traits.require(t) : t);
|
|
336
|
+
const select = def.select ?? (needs.length > 0 ? "one-or-many" : "one");
|
|
337
|
+
if (needs.length === 0 && select !== "one") {
|
|
338
|
+
throw new Error(`Command "${meta.name}": select requires needs (at ${sourceFile})`);
|
|
339
|
+
}
|
|
340
|
+
// `for` binds this command to a named project. No project arg is exposed on
|
|
341
|
+
// any surface; ctx.project is the bound project.
|
|
342
|
+
let boundProject = null;
|
|
343
|
+
if (def.for !== undefined) {
|
|
344
|
+
if (needs.length === 0) {
|
|
345
|
+
throw new Error(`Command "${meta.name}": \`for\` requires \`needs\` (a fixed project without required traits is meaningless)\n at ${sourceFile}`);
|
|
346
|
+
}
|
|
347
|
+
try {
|
|
348
|
+
boundProject = workspace.get(def.for);
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
throw new Error(`Command "${meta.name}": \`for: "${def.for}"\` — project not found\n at ${sourceFile}\n available: ${workspace.projects.map(p => p.name).join(", ")}`);
|
|
352
|
+
}
|
|
353
|
+
for (const t of needs) {
|
|
354
|
+
if (!boundProject.hasTrait(t)) {
|
|
355
|
+
throw new Error(`Command "${meta.name}": bound project "${def.for}" does not fulfill trait "${t.name}"\n at ${sourceFile}`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
const resolvedArgs = resolveFields(def.args, workspace);
|
|
360
|
+
const resolvedFlags = resolveFields(def.flags, workspace);
|
|
361
|
+
// Collect invocation-time-resolved fields for per-call validation.
|
|
362
|
+
const fromArgsFields = [
|
|
363
|
+
...collectFromArgsFields(resolvedArgs),
|
|
364
|
+
...collectFromArgsFields(resolvedFlags),
|
|
365
|
+
];
|
|
366
|
+
// `for` suppresses the user-facing project arg.
|
|
367
|
+
const projectArg = boundProject ? null : projectArgFor(needs, select, workspace);
|
|
368
|
+
const { inputSchema, positional } = buildSchema(resolvedArgs, resolvedFlags, projectArg);
|
|
369
|
+
const handler = async (args) => {
|
|
370
|
+
const validated = validateFromArgsFields(args, fromArgsFields, workspace);
|
|
371
|
+
if (needs.length === 0) {
|
|
372
|
+
const ctx = makeBaseCtx(workspace, () => workspace.root, host);
|
|
373
|
+
return runHandler(def.handler, validated, ctx);
|
|
374
|
+
}
|
|
375
|
+
if (boundProject) {
|
|
376
|
+
const ctx = makeNeedsCtx(workspace, boundProject, needs, host);
|
|
377
|
+
return runHandler(def.handler, validated, ctx);
|
|
378
|
+
}
|
|
379
|
+
const projects = resolveProjects(needs, select, workspace, validated.project);
|
|
380
|
+
if (select === "one") {
|
|
381
|
+
const ctx = makeNeedsCtx(workspace, projects[0], needs, host);
|
|
382
|
+
return runHandler(def.handler, stripProjectArg(validated), ctx);
|
|
383
|
+
}
|
|
384
|
+
return runMultiple(def.handler, validated, projects, needs, workspace, def.run, host);
|
|
385
|
+
};
|
|
386
|
+
return {
|
|
387
|
+
name: meta.name,
|
|
388
|
+
label: meta.label,
|
|
389
|
+
group,
|
|
390
|
+
description: meta.description,
|
|
391
|
+
inputSchema,
|
|
392
|
+
positional,
|
|
393
|
+
handler,
|
|
394
|
+
sourceFile,
|
|
395
|
+
select,
|
|
396
|
+
needs: needs.map((t) => t.name),
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
// --- Discovery -----------------------------------------------------------
|
|
400
|
+
async function importCommand(jiti, filePath) {
|
|
401
|
+
const mod = (await jiti.import(filePath));
|
|
402
|
+
return mod.default;
|
|
403
|
+
}
|
|
404
|
+
function isCommandFile(name) {
|
|
405
|
+
return name.endsWith(".ts") && !name.endsWith(".d.ts");
|
|
406
|
+
}
|
|
407
|
+
/** Walk the commands dir recursively. Maps paths to colon-joined command names. */
|
|
408
|
+
function walkCommands(root) {
|
|
409
|
+
const out = [];
|
|
410
|
+
function walk(dir, prefix) {
|
|
411
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
412
|
+
const full = join(dir, entry.name);
|
|
413
|
+
if (entry.isDirectory()) {
|
|
414
|
+
walk(full, [...prefix, entry.name]);
|
|
415
|
+
}
|
|
416
|
+
else if (isCommandFile(entry.name)) {
|
|
417
|
+
const base = entry.name.replace(/\.ts$/, "");
|
|
418
|
+
if (base.includes(":")) {
|
|
419
|
+
throw new Error(`Command filename contains ':' which is reserved: ${full}`);
|
|
420
|
+
}
|
|
421
|
+
const parts = base === "index" ? prefix : [...prefix, base];
|
|
422
|
+
if (parts.length === 0)
|
|
423
|
+
continue; // bare root index.ts not allowed
|
|
424
|
+
const resolvedName = parts.join(":");
|
|
425
|
+
const group = prefix.length > 0 ? titleCase(prefix[0]) : "";
|
|
426
|
+
out.push({ filePath: full, resolvedName, group });
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
walk(root, []);
|
|
431
|
+
return out;
|
|
73
432
|
}
|
|
74
433
|
export async function loadCommands(workspace) {
|
|
75
434
|
const commandsDir = join(workspace.root, ".wm", "commands");
|
|
@@ -77,29 +436,43 @@ export async function loadCommands(workspace) {
|
|
|
77
436
|
return [];
|
|
78
437
|
const jiti = createJiti(commandsDir, jitiOptions());
|
|
79
438
|
const commands = [];
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
for (const file of readdirSync(entryPath)) {
|
|
96
|
-
if (!file.endsWith(".ts") || file.endsWith(".d.ts"))
|
|
97
|
-
continue;
|
|
98
|
-
const filePath = join(entryPath, file);
|
|
99
|
-
const mod = await jiti.import(filePath);
|
|
100
|
-
commands.push(resolve(mod.default, workspace, group, filePath));
|
|
439
|
+
const byName = new Map();
|
|
440
|
+
// Invocation host: commands map is populated progressively; cycle-detected per-call.
|
|
441
|
+
const activeStack = new Set();
|
|
442
|
+
const host = {
|
|
443
|
+
invoke: async (name, args) => {
|
|
444
|
+
if (activeStack.has(name)) {
|
|
445
|
+
const trace = [...activeStack, name].join(" → ");
|
|
446
|
+
return fail(`Cyclic invocation: ${trace}`);
|
|
447
|
+
}
|
|
448
|
+
const target = byName.get(name);
|
|
449
|
+
if (!target)
|
|
450
|
+
return fail(`Unknown command: "${name}"`);
|
|
451
|
+
activeStack.add(name);
|
|
452
|
+
try {
|
|
453
|
+
return await target.handler(args);
|
|
101
454
|
}
|
|
455
|
+
finally {
|
|
456
|
+
activeStack.delete(name);
|
|
457
|
+
}
|
|
458
|
+
},
|
|
459
|
+
};
|
|
460
|
+
const discovered = walkCommands(commandsDir);
|
|
461
|
+
for (const d of discovered) {
|
|
462
|
+
const def = await importCommand(jiti, d.filePath);
|
|
463
|
+
if (!def)
|
|
464
|
+
continue;
|
|
465
|
+
const cmd = resolve(def, workspace, d.group, d.filePath, host);
|
|
466
|
+
// Override resolved name from path-derived name (filename was the fallback in meta).
|
|
467
|
+
// Meta.name wins if explicitly set; otherwise use the discovered name.
|
|
468
|
+
const userProvidedName = def.meta?.name;
|
|
469
|
+
if (!userProvidedName)
|
|
470
|
+
cmd.name = d.resolvedName;
|
|
471
|
+
if (byName.has(cmd.name)) {
|
|
472
|
+
throw new Error(`Duplicate command name "${cmd.name}": ${d.filePath} collides with ${byName.get(cmd.name).sourceFile}`);
|
|
102
473
|
}
|
|
474
|
+
byName.set(cmd.name, cmd);
|
|
475
|
+
commands.push(cmd);
|
|
103
476
|
}
|
|
104
477
|
return commands;
|
|
105
478
|
}
|
package/dist/lib/parse.d.ts
CHANGED
|
@@ -3,11 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 1. tokenize — turn argv into a structured token stream (flags vs. positionals)
|
|
5
5
|
* 2. dispatch — bind tokens to parameter names using positional order and
|
|
6
|
-
* schema-declared array
|
|
6
|
+
* schema-declared array fields (repeats/multiples accumulate)
|
|
7
7
|
* 3. coerce — convert raw strings to JSON Schema-declared types
|
|
8
8
|
*
|
|
9
|
-
* Each phase is independently testable and has one job.
|
|
10
|
-
* the command's JSON Schema rather than heuristics, so `z.string()` fields
|
|
11
|
-
* always stay strings and `z.array(...)` fields always come out as arrays.
|
|
9
|
+
* Each phase is independently testable and has one job.
|
|
12
10
|
*/
|
|
13
11
|
export declare function parseArgs(argv: string[], positional: string[], schema: Record<string, unknown>): Record<string, unknown>;
|
package/dist/lib/parse.js
CHANGED
|
@@ -3,12 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 1. tokenize — turn argv into a structured token stream (flags vs. positionals)
|
|
5
5
|
* 2. dispatch — bind tokens to parameter names using positional order and
|
|
6
|
-
* schema-declared array
|
|
6
|
+
* schema-declared array fields (repeats/multiples accumulate)
|
|
7
7
|
* 3. coerce — convert raw strings to JSON Schema-declared types
|
|
8
8
|
*
|
|
9
|
-
* Each phase is independently testable and has one job.
|
|
10
|
-
* the command's JSON Schema rather than heuristics, so `z.string()` fields
|
|
11
|
-
* always stay strings and `z.array(...)` fields always come out as arrays.
|
|
9
|
+
* Each phase is independently testable and has one job.
|
|
12
10
|
*/
|
|
13
11
|
// --- 1. tokenize ---------------------------------------------------------
|
|
14
12
|
function tokenize(argv) {
|
|
@@ -38,7 +36,19 @@ function tokenize(argv) {
|
|
|
38
36
|
}
|
|
39
37
|
// --- 2. dispatch ---------------------------------------------------------
|
|
40
38
|
function isArrayField(schema, name) {
|
|
41
|
-
|
|
39
|
+
const prop = schema.properties?.[name];
|
|
40
|
+
if (!prop)
|
|
41
|
+
return false;
|
|
42
|
+
if (prop.type === "array")
|
|
43
|
+
return true;
|
|
44
|
+
// union: accept array as one option
|
|
45
|
+
if (Array.isArray(prop.anyOf)) {
|
|
46
|
+
for (const alt of prop.anyOf) {
|
|
47
|
+
if (alt.type === "array")
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return false;
|
|
42
52
|
}
|
|
43
53
|
function appendToArray(out, key, value) {
|
|
44
54
|
const prev = out[key];
|
|
@@ -55,17 +65,28 @@ function assignFlag(out, schema, key, value) {
|
|
|
55
65
|
}
|
|
56
66
|
out[key] = value;
|
|
57
67
|
}
|
|
68
|
+
function assignPositional(out, schema, key, value) {
|
|
69
|
+
if (isArrayField(schema, key)) {
|
|
70
|
+
appendToArray(out, key, value);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
out[key] = value;
|
|
74
|
+
}
|
|
58
75
|
function dispatch(tokens, positional, schema) {
|
|
59
76
|
const out = {};
|
|
60
77
|
let posIdx = 0;
|
|
61
78
|
for (const tok of tokens) {
|
|
62
79
|
if (tok.kind === "flag") {
|
|
63
80
|
assignFlag(out, schema, tok.key, tok.value);
|
|
81
|
+
continue;
|
|
64
82
|
}
|
|
65
|
-
|
|
66
|
-
|
|
83
|
+
if (posIdx >= positional.length)
|
|
84
|
+
continue;
|
|
85
|
+
const currentKey = positional[posIdx];
|
|
86
|
+
assignPositional(out, schema, currentKey, tok.value);
|
|
87
|
+
// If the current positional is array-valued, stay on it; else advance.
|
|
88
|
+
if (!isArrayField(schema, currentKey))
|
|
67
89
|
posIdx++;
|
|
68
|
-
}
|
|
69
90
|
}
|
|
70
91
|
return out;
|
|
71
92
|
}
|
|
@@ -92,12 +113,23 @@ function coerceField(value, prop) {
|
|
|
92
113
|
return value;
|
|
93
114
|
if (typeof value === "boolean")
|
|
94
115
|
return value;
|
|
95
|
-
if (prop.type === "array") {
|
|
116
|
+
if (prop.type === "array" || (Array.isArray(prop.anyOf) && prop.anyOf.some((a) => a.type === "array"))) {
|
|
96
117
|
const items = Array.isArray(value) ? value : [value];
|
|
97
|
-
|
|
118
|
+
const innerType = prop.items?.type ?? extractArrayItemType(prop);
|
|
119
|
+
return items.map((item) => coerceScalar(String(item), innerType));
|
|
98
120
|
}
|
|
99
121
|
return coerceScalar(String(value), prop.type);
|
|
100
122
|
}
|
|
123
|
+
function extractArrayItemType(prop) {
|
|
124
|
+
if (!Array.isArray(prop.anyOf))
|
|
125
|
+
return undefined;
|
|
126
|
+
for (const alt of prop.anyOf) {
|
|
127
|
+
const a = alt;
|
|
128
|
+
if (a.type === "array")
|
|
129
|
+
return a.items?.type;
|
|
130
|
+
}
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
101
133
|
function coerce(raw, schema) {
|
|
102
134
|
const out = {};
|
|
103
135
|
const props = schema.properties ?? {};
|
package/dist/lib/project.d.ts
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
|
-
import type { IProject, ProjectDef } from "./types.js";
|
|
1
|
+
import type { IProject, ProjectDef, Trait } from "./types.js";
|
|
2
|
+
import type { TraitRegistry } from "./registry.js";
|
|
2
3
|
export declare class Project implements IProject {
|
|
3
4
|
readonly name: string;
|
|
4
5
|
readonly dir: string;
|
|
5
6
|
readonly tags: readonly string[];
|
|
6
|
-
readonly
|
|
7
|
+
readonly description?: string;
|
|
8
|
+
/** Parsed (defaulted) trait data, keyed by trait name. */
|
|
9
|
+
private readonly traitData;
|
|
7
10
|
private readonly paths;
|
|
8
|
-
constructor(def: ProjectDef, wsDir: string);
|
|
9
|
-
|
|
10
|
-
|
|
11
|
+
constructor(def: ProjectDef, wsDir: string, traitData: Record<string, unknown>);
|
|
12
|
+
hasTrait(trait: Trait | string): boolean;
|
|
13
|
+
trait<T>(trait: Trait<string, T>): T;
|
|
11
14
|
path(name: string): string;
|
|
12
15
|
}
|
|
16
|
+
/** Validate a project's `has` against the registry, returning parsed data. */
|
|
17
|
+
export declare function validateHas(def: ProjectDef, registry: TraitRegistry, sourceFile: string): Record<string, unknown>;
|