@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/parse.ts
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
|
|
|
14
12
|
type Token =
|
|
@@ -19,6 +17,7 @@ type SchemaProp = {
|
|
|
19
17
|
type?: string;
|
|
20
18
|
items?: { type?: string; enum?: unknown[] };
|
|
21
19
|
enum?: unknown[];
|
|
20
|
+
anyOf?: unknown[];
|
|
22
21
|
};
|
|
23
22
|
|
|
24
23
|
type SchemaObject = {
|
|
@@ -57,7 +56,16 @@ function tokenize(argv: string[]): Token[] {
|
|
|
57
56
|
// --- 2. dispatch ---------------------------------------------------------
|
|
58
57
|
|
|
59
58
|
function isArrayField(schema: SchemaObject, name: string): boolean {
|
|
60
|
-
|
|
59
|
+
const prop = schema.properties?.[name];
|
|
60
|
+
if (!prop) return false;
|
|
61
|
+
if (prop.type === "array") return true;
|
|
62
|
+
// union: accept array as one option
|
|
63
|
+
if (Array.isArray(prop.anyOf)) {
|
|
64
|
+
for (const alt of prop.anyOf) {
|
|
65
|
+
if ((alt as SchemaProp).type === "array") return true;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return false;
|
|
61
69
|
}
|
|
62
70
|
|
|
63
71
|
function appendToArray(out: Record<string, unknown>, key: string, value: string): void {
|
|
@@ -82,6 +90,19 @@ function assignFlag(
|
|
|
82
90
|
out[key] = value;
|
|
83
91
|
}
|
|
84
92
|
|
|
93
|
+
function assignPositional(
|
|
94
|
+
out: Record<string, unknown>,
|
|
95
|
+
schema: SchemaObject,
|
|
96
|
+
key: string,
|
|
97
|
+
value: string,
|
|
98
|
+
): void {
|
|
99
|
+
if (isArrayField(schema, key)) {
|
|
100
|
+
appendToArray(out, key, value);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
out[key] = value;
|
|
104
|
+
}
|
|
105
|
+
|
|
85
106
|
function dispatch(
|
|
86
107
|
tokens: Token[],
|
|
87
108
|
positional: string[],
|
|
@@ -93,10 +114,16 @@ function dispatch(
|
|
|
93
114
|
for (const tok of tokens) {
|
|
94
115
|
if (tok.kind === "flag") {
|
|
95
116
|
assignFlag(out, schema, tok.key, tok.value);
|
|
96
|
-
|
|
97
|
-
out[positional[posIdx]] = tok.value;
|
|
98
|
-
posIdx++;
|
|
117
|
+
continue;
|
|
99
118
|
}
|
|
119
|
+
|
|
120
|
+
if (posIdx >= positional.length) continue;
|
|
121
|
+
|
|
122
|
+
const currentKey = positional[posIdx];
|
|
123
|
+
assignPositional(out, schema, currentKey, tok.value);
|
|
124
|
+
|
|
125
|
+
// If the current positional is array-valued, stay on it; else advance.
|
|
126
|
+
if (!isArrayField(schema, currentKey)) posIdx++;
|
|
100
127
|
}
|
|
101
128
|
return out;
|
|
102
129
|
}
|
|
@@ -123,14 +150,24 @@ function coerceField(value: unknown, prop: SchemaProp | undefined): unknown {
|
|
|
123
150
|
if (prop === undefined) return value;
|
|
124
151
|
if (typeof value === "boolean") return value;
|
|
125
152
|
|
|
126
|
-
if (prop.type === "array") {
|
|
153
|
+
if (prop.type === "array" || (Array.isArray(prop.anyOf) && prop.anyOf.some((a) => (a as SchemaProp).type === "array"))) {
|
|
127
154
|
const items = Array.isArray(value) ? value : [value];
|
|
128
|
-
|
|
155
|
+
const innerType = prop.items?.type ?? extractArrayItemType(prop);
|
|
156
|
+
return items.map((item) => coerceScalar(String(item), innerType));
|
|
129
157
|
}
|
|
130
158
|
|
|
131
159
|
return coerceScalar(String(value), prop.type);
|
|
132
160
|
}
|
|
133
161
|
|
|
162
|
+
function extractArrayItemType(prop: SchemaProp): string | undefined {
|
|
163
|
+
if (!Array.isArray(prop.anyOf)) return undefined;
|
|
164
|
+
for (const alt of prop.anyOf) {
|
|
165
|
+
const a = alt as SchemaProp;
|
|
166
|
+
if (a.type === "array") return a.items?.type;
|
|
167
|
+
}
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
|
|
134
171
|
function coerce(
|
|
135
172
|
raw: Record<string, unknown>,
|
|
136
173
|
schema: SchemaObject,
|
package/src/lib/project.ts
CHANGED
|
@@ -1,32 +1,39 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
|
-
import type { IProject, ProjectDef } from "./types.js";
|
|
2
|
+
import type { IProject, ProjectDef, Trait } from "./types.js";
|
|
3
|
+
import type { TraitRegistry } from "./registry.js";
|
|
3
4
|
|
|
4
5
|
export class Project implements IProject {
|
|
5
6
|
readonly name: string;
|
|
6
7
|
readonly dir: string;
|
|
7
8
|
readonly tags: readonly string[];
|
|
8
|
-
readonly
|
|
9
|
+
readonly description?: string;
|
|
9
10
|
|
|
11
|
+
/** Parsed (defaulted) trait data, keyed by trait name. */
|
|
12
|
+
private readonly traitData: Readonly<Record<string, unknown>>;
|
|
10
13
|
private readonly paths: Readonly<Record<string, string>>;
|
|
11
14
|
|
|
12
|
-
constructor(def: ProjectDef, wsDir: string) {
|
|
15
|
+
constructor(def: ProjectDef, wsDir: string, traitData: Record<string, unknown>) {
|
|
13
16
|
this.name = def.name;
|
|
14
17
|
this.dir = def.dir ? join(wsDir, def.dir) : wsDir;
|
|
15
|
-
this.tags = Object.freeze(def.tags ?? []);
|
|
16
|
-
this.
|
|
17
|
-
this.
|
|
18
|
+
this.tags = Object.freeze([...(def.tags ?? [])]);
|
|
19
|
+
this.description = def.description;
|
|
20
|
+
this.traitData = Object.freeze(traitData);
|
|
21
|
+
this.paths = Object.freeze({ ...(def.paths ?? {}) });
|
|
18
22
|
}
|
|
19
23
|
|
|
20
|
-
|
|
21
|
-
|
|
24
|
+
hasTrait(trait: Trait | string): boolean {
|
|
25
|
+
const name = typeof trait === "string" ? trait : trait.name;
|
|
26
|
+
return name in this.traitData;
|
|
22
27
|
}
|
|
23
28
|
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
if (
|
|
27
|
-
throw new Error(
|
|
29
|
+
trait<T>(trait: Trait<string, T>): T {
|
|
30
|
+
const data = this.traitData[trait.name];
|
|
31
|
+
if (data === undefined) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
`Project "${this.name}" does not fulfill trait "${trait.name}"`,
|
|
34
|
+
);
|
|
28
35
|
}
|
|
29
|
-
return
|
|
36
|
+
return data as T;
|
|
30
37
|
}
|
|
31
38
|
|
|
32
39
|
path(name: string): string {
|
|
@@ -37,3 +44,41 @@ export class Project implements IProject {
|
|
|
37
44
|
return join(this.dir, rel);
|
|
38
45
|
}
|
|
39
46
|
}
|
|
47
|
+
|
|
48
|
+
/** Validate a project's `has` against the registry, returning parsed data. */
|
|
49
|
+
export function validateHas(
|
|
50
|
+
def: ProjectDef,
|
|
51
|
+
registry: TraitRegistry,
|
|
52
|
+
sourceFile: string,
|
|
53
|
+
): Record<string, unknown> {
|
|
54
|
+
const out: Record<string, unknown> = {};
|
|
55
|
+
const has = def.has ?? {};
|
|
56
|
+
for (const [traitName, raw] of Object.entries(has)) {
|
|
57
|
+
const trait = registry.get(traitName);
|
|
58
|
+
if (!trait) {
|
|
59
|
+
const known = registry.all().map((t) => t.name).join(", ") || "(none)";
|
|
60
|
+
throw new Error(
|
|
61
|
+
`Project "${def.name}" declares has.${traitName}, but no such trait is defined\n at ${sourceFile}\n known traits: ${known}`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
// Marker-trait sugar: `has: { x: true }` parses the trait with `{}`, which
|
|
65
|
+
// triggers per-field defaults. Zod 4's outer `.default({})` returns `{}`
|
|
66
|
+
// for undefined input — useful but NOT deeply defaulted — so we pass `{}`
|
|
67
|
+
// explicitly to get nested defaults.
|
|
68
|
+
const input = raw === true ? {} : raw;
|
|
69
|
+
const result = trait.schema.safeParse(input);
|
|
70
|
+
if (!result.success) {
|
|
71
|
+
const issues = result.error.issues
|
|
72
|
+
.map((i) => ` ${i.path.join(".") || "(root)"}: ${i.message}`)
|
|
73
|
+
.join("\n");
|
|
74
|
+
const hint = raw === true
|
|
75
|
+
? `\n hint: "true" is sugar for defaults; trait "${traitName}" has no top-level .default(). Provide an object literal.`
|
|
76
|
+
: "";
|
|
77
|
+
throw new Error(
|
|
78
|
+
`Project "${def.name}" has.${traitName} failed validation:\n${issues}\n at ${sourceFile}${hint}`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
out[traitName] = result.data;
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { Trait } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/** Per-load trait registry. A fresh instance is created for each `loadWorkspace`
|
|
4
|
+
* call so reloads don't trip duplicate-name checks against prior state. */
|
|
5
|
+
export class TraitRegistry {
|
|
6
|
+
private readonly traits = new Map<string, Trait>();
|
|
7
|
+
private readonly sources = new Map<string, string>();
|
|
8
|
+
|
|
9
|
+
register(trait: Trait, sourceFile?: string): void {
|
|
10
|
+
const existing = this.traits.get(trait.name);
|
|
11
|
+
const prevSource = this.sources.get(trait.name);
|
|
12
|
+
if (existing) {
|
|
13
|
+
// Same source file re-executed (jiti moduleCache:false) is idempotent.
|
|
14
|
+
// Different source files with the same name is an error.
|
|
15
|
+
if (sourceFile && prevSource && sourceFile !== prevSource) {
|
|
16
|
+
throw new Error(
|
|
17
|
+
`Trait "${trait.name}" defined twice:\n ${prevSource}\n ${sourceFile}`,
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
// Keep the first-registered trait object as canonical.
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
this.traits.set(trait.name, trait);
|
|
24
|
+
if (sourceFile) this.sources.set(trait.name, sourceFile);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
get(name: string): Trait | undefined {
|
|
28
|
+
return this.traits.get(name);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
require(name: string): Trait {
|
|
32
|
+
const t = this.traits.get(name);
|
|
33
|
+
if (!t) {
|
|
34
|
+
const known = [...this.traits.keys()].join(", ") || "(none)";
|
|
35
|
+
throw new Error(`Unknown trait "${name}". Known: ${known}`);
|
|
36
|
+
}
|
|
37
|
+
return t;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
has(name: string): boolean {
|
|
41
|
+
return this.traits.has(name);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
all(): readonly Trait[] {
|
|
45
|
+
return [...this.traits.values()];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Ambient registry used by `defineTrait` at module-import time.
|
|
50
|
+
*
|
|
51
|
+
* Stored on `globalThis` so it survives the pnpm+jiti module-duplication problem:
|
|
52
|
+
* the CLI's workmark module and the jiti-loaded trait file's workmark module are
|
|
53
|
+
* distinct instances, but both see the same globalThis slot.
|
|
54
|
+
*
|
|
55
|
+
* The loader installs a fresh registry, imports trait files (which call
|
|
56
|
+
* defineTrait → registerAmbient → reads this slot), then uninstalls. */
|
|
57
|
+
const GLOBAL_KEY = Symbol.for("workmark.ambientRegistry");
|
|
58
|
+
type GlobalSlot = { [K in typeof GLOBAL_KEY]?: TraitRegistry };
|
|
59
|
+
|
|
60
|
+
function slot(): GlobalSlot {
|
|
61
|
+
return globalThis as unknown as GlobalSlot;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function installRegistry(reg: TraitRegistry): void {
|
|
65
|
+
slot()[GLOBAL_KEY] = reg;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function uninstallRegistry(): void {
|
|
69
|
+
delete slot()[GLOBAL_KEY];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function registerAmbient(trait: Trait, sourceFile?: string): void {
|
|
73
|
+
const reg = slot()[GLOBAL_KEY];
|
|
74
|
+
if (!reg) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`defineTrait called outside of a workspace load. Traits must live in .wm/traits/*.ts files.`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
reg.register(trait, sourceFile);
|
|
80
|
+
}
|
package/src/lib/types.ts
CHANGED
|
@@ -1,37 +1,58 @@
|
|
|
1
1
|
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
2
2
|
import type { z } from "zod";
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
export type InputSchema = z.ZodType | Record<string, unknown>;
|
|
4
|
+
// ---- Traits ------------------------------------------------------------
|
|
6
5
|
|
|
7
|
-
/** A
|
|
8
|
-
|
|
6
|
+
/** A named, schema-backed contract that projects fulfill and commands require.
|
|
7
|
+
*
|
|
8
|
+
* Identity is `name`. Created via `defineTrait`; the returned object carries a
|
|
9
|
+
* phantom type so `cmd({ needs: [docker] })` can infer trait-data types into
|
|
10
|
+
* the handler's `ctx.traits`. */
|
|
11
|
+
export interface Trait<N extends string = string, T = unknown> {
|
|
12
|
+
readonly name: N;
|
|
13
|
+
readonly schema: z.ZodType;
|
|
14
|
+
readonly description?: string;
|
|
15
|
+
/** Phantom for inference. Never accessed at runtime. */
|
|
16
|
+
readonly __brand?: readonly ["trait", N, T];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ---- Schema fields ------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
/** A field schema: a zod type or raw JSON Schema object. */
|
|
22
|
+
export type SchemaField = z.ZodType | Record<string, unknown>;
|
|
23
|
+
|
|
24
|
+
/** A record of named field schemas. */
|
|
25
|
+
export type SchemaFields = Record<string, SchemaField>;
|
|
9
26
|
|
|
10
|
-
// ----
|
|
27
|
+
// ---- Projects -----------------------------------------------------------
|
|
11
28
|
|
|
12
29
|
export interface ProjectDef {
|
|
13
30
|
/** Unique identifier. */
|
|
14
31
|
name: string;
|
|
15
32
|
/** Project directory relative to the wm.ts location. Defaults to ".". */
|
|
16
33
|
dir?: string;
|
|
17
|
-
/**
|
|
34
|
+
/** Free-form labels. Filter only — no handler injection. */
|
|
18
35
|
tags?: string[];
|
|
19
36
|
/** Named directories, resolved relative to the project dir. */
|
|
20
37
|
paths?: Record<string, string>;
|
|
21
|
-
/**
|
|
22
|
-
|
|
38
|
+
/** Trait fulfillments. Keys are trait names; values are validated against
|
|
39
|
+
* the trait schema at load time. */
|
|
40
|
+
has?: Record<string, unknown>;
|
|
41
|
+
/** Optional human-readable description. */
|
|
42
|
+
description?: string;
|
|
23
43
|
}
|
|
24
44
|
|
|
25
|
-
// ---- Runtime project/workspace interfaces ----
|
|
26
|
-
|
|
27
45
|
export interface IProject {
|
|
28
46
|
readonly name: string;
|
|
29
47
|
readonly dir: string;
|
|
30
48
|
readonly tags: readonly string[];
|
|
31
|
-
readonly
|
|
49
|
+
readonly description?: string;
|
|
32
50
|
|
|
33
|
-
|
|
34
|
-
|
|
51
|
+
/** Check whether this project fulfills a trait (by object or name). */
|
|
52
|
+
hasTrait(trait: Trait | string): boolean;
|
|
53
|
+
/** Get the parsed trait data. Throws if the project does not fulfill it. */
|
|
54
|
+
trait<T>(trait: Trait<string, T>): T;
|
|
55
|
+
/** Resolve a named path relative to this project's directory. */
|
|
35
56
|
path(name: string): string;
|
|
36
57
|
}
|
|
37
58
|
|
|
@@ -40,50 +61,87 @@ export interface IWorkspace {
|
|
|
40
61
|
readonly projects: readonly IProject[];
|
|
41
62
|
|
|
42
63
|
get(name: string): IProject;
|
|
43
|
-
|
|
64
|
+
withTrait(trait: Trait | string): IProject[];
|
|
44
65
|
withTag(tag: string): IProject[];
|
|
45
66
|
}
|
|
46
67
|
|
|
47
|
-
// ----
|
|
68
|
+
// ---- Commands -----------------------------------------------------------
|
|
48
69
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
) => Promise<CallToolResult>;
|
|
70
|
+
/** How many projects a command runs against. */
|
|
71
|
+
export type SelectMode = "one" | "one-or-many" | "all";
|
|
52
72
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
|
|
61
|
-
|
|
73
|
+
/** Framework-standard context passed to every handler. */
|
|
74
|
+
export interface BaseCtx {
|
|
75
|
+
workspace: IWorkspace;
|
|
76
|
+
/** Run a shell command (or sequence) in the resolved cwd. An array is executed
|
|
77
|
+
* sequentially; fails fast; output is concatenated. */
|
|
78
|
+
sh: (cmd: string | readonly string[], opts?: { timeout?: number; env?: Record<string, string> }) => Promise<CallToolResult>;
|
|
79
|
+
/** Run a shell command with explicit options (cwd, timeout, env). */
|
|
80
|
+
exec: (cmd: string, opts: { cwd: string; timeout?: number; env?: Record<string, string> }) => Promise<CallToolResult>;
|
|
81
|
+
ok: (data: unknown) => CallToolResult;
|
|
82
|
+
fail: (error: unknown) => CallToolResult;
|
|
83
|
+
/** Invoke another command by name with wire-shape args. */
|
|
84
|
+
invoke: (name: string, args: Record<string, unknown>) => Promise<CallToolResult>;
|
|
62
85
|
}
|
|
63
86
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
factory: (workspace: IWorkspace) => {
|
|
69
|
-
args?: SchemaFields;
|
|
70
|
-
flags?: SchemaFields;
|
|
71
|
-
handler: ToolHandler;
|
|
72
|
-
};
|
|
87
|
+
/** Context for commands with `needs`. Adds `project` and `traits`. */
|
|
88
|
+
export interface NeedsCtx<Traits extends Record<string, unknown>> extends BaseCtx {
|
|
89
|
+
project: IProject;
|
|
90
|
+
traits: Traits;
|
|
73
91
|
}
|
|
74
92
|
|
|
75
|
-
export type
|
|
93
|
+
export type HandlerReturn = CallToolResult;
|
|
76
94
|
|
|
95
|
+
/** A resolved, runnable command — what the framework holds after load. */
|
|
77
96
|
export interface ResolvedCommand {
|
|
78
97
|
name: string;
|
|
79
98
|
label: string;
|
|
80
99
|
group: string;
|
|
81
100
|
description: string;
|
|
82
|
-
/** Merged JSON Schema from args + flags. */
|
|
83
101
|
inputSchema: Record<string, unknown>;
|
|
84
|
-
/** Positional arg names in order. */
|
|
85
102
|
positional: string[];
|
|
86
|
-
handler:
|
|
87
|
-
/** Absolute path to the command source file. */
|
|
103
|
+
handler: ResolvedHandler;
|
|
88
104
|
sourceFile?: string;
|
|
105
|
+
select: SelectMode;
|
|
106
|
+
/** Names of traits this command requires. Empty array = no needs. */
|
|
107
|
+
needs: string[];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export type ResolvedHandler = (
|
|
111
|
+
args: Record<string, unknown>,
|
|
112
|
+
) => Promise<CallToolResult>;
|
|
113
|
+
|
|
114
|
+
// ---- Result aggregation -------------------------------------------------
|
|
115
|
+
|
|
116
|
+
/** Per-project result shape for multi-target commands. */
|
|
117
|
+
export interface ProjectResult {
|
|
118
|
+
project: string;
|
|
119
|
+
ok: boolean;
|
|
120
|
+
output?: string;
|
|
121
|
+
error?: string;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Optional reducer for custom multi-target aggregation. */
|
|
125
|
+
export type ReduceFn = (results: ProjectResult[]) => HandlerReturn | Promise<HandlerReturn>;
|
|
126
|
+
|
|
127
|
+
export interface RunOptions {
|
|
128
|
+
order?: "parallel" | "serial";
|
|
129
|
+
concurrency?: number;
|
|
130
|
+
stopOnFailure?: boolean;
|
|
131
|
+
reduce?: ReduceFn;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---- fromWorkspace marker ----------------------------------------------
|
|
135
|
+
|
|
136
|
+
/** Internal marker stamped on schemas produced by `fromWorkspace`. */
|
|
137
|
+
export const FROM_WORKSPACE = Symbol.for("workmark.fromWorkspace");
|
|
138
|
+
/** Marker for invocation-time (args-dependent) schemas — resolved per-call. */
|
|
139
|
+
export const FROM_ARGS = Symbol.for("workmark.fromArgs");
|
|
140
|
+
|
|
141
|
+
export interface FromWorkspaceResolver {
|
|
142
|
+
(ws: IWorkspace): z.ZodType;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface FromArgsResolver {
|
|
146
|
+
(ws: IWorkspace, args: Record<string, unknown>): z.ZodType;
|
|
89
147
|
}
|
package/src/lib/workspace.ts
CHANGED
|
@@ -2,22 +2,25 @@ import { dirname, join, relative } from "node:path";
|
|
|
2
2
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
3
3
|
import { createJiti } from "jiti";
|
|
4
4
|
import ignore, { type Ignore } from "ignore";
|
|
5
|
-
import { Project } from "./project.js";
|
|
6
|
-
import type { IProject, IWorkspace, ProjectDef } from "./types.js";
|
|
5
|
+
import { Project, validateHas } from "./project.js";
|
|
6
|
+
import type { IProject, IWorkspace, ProjectDef, Trait } from "./types.js";
|
|
7
|
+
import { TraitRegistry, installRegistry, uninstallRegistry } from "./registry.js";
|
|
7
8
|
import { jitiOptions } from "./jiti-options.js";
|
|
8
9
|
|
|
9
10
|
export class Workspace implements IWorkspace {
|
|
10
11
|
readonly root: string;
|
|
11
12
|
readonly projects: readonly IProject[];
|
|
13
|
+
/** Trait registry populated during load. Exposed for command-load phase. */
|
|
14
|
+
readonly traits: TraitRegistry;
|
|
12
15
|
|
|
13
16
|
private readonly byName: Map<string, IProject>;
|
|
14
17
|
|
|
15
|
-
constructor(root: string, projects: IProject[]) {
|
|
18
|
+
constructor(root: string, projects: IProject[], traits: TraitRegistry) {
|
|
16
19
|
this.root = root;
|
|
17
|
-
this.projects = Object.freeze(projects);
|
|
20
|
+
this.projects = Object.freeze([...projects]);
|
|
21
|
+
this.traits = traits;
|
|
18
22
|
this.byName = new Map(projects.map((p) => [p.name, p]));
|
|
19
23
|
|
|
20
|
-
// Validate unique names
|
|
21
24
|
if (this.byName.size !== projects.length) {
|
|
22
25
|
const seen = new Set<string>();
|
|
23
26
|
for (const p of projects) {
|
|
@@ -33,8 +36,9 @@ export class Workspace implements IWorkspace {
|
|
|
33
36
|
return p;
|
|
34
37
|
}
|
|
35
38
|
|
|
36
|
-
|
|
37
|
-
|
|
39
|
+
withTrait(trait: Trait | string): IProject[] {
|
|
40
|
+
const name = typeof trait === "string" ? trait : trait.name;
|
|
41
|
+
return this.projects.filter((p) => p.hasTrait(name));
|
|
38
42
|
}
|
|
39
43
|
|
|
40
44
|
withTag(tag: string): IProject[] {
|
|
@@ -42,7 +46,8 @@ export class Workspace implements IWorkspace {
|
|
|
42
46
|
}
|
|
43
47
|
}
|
|
44
48
|
|
|
45
|
-
|
|
49
|
+
// ---- Discovery ---------------------------------------------------------
|
|
50
|
+
|
|
46
51
|
function loadIgnore(root: string): Ignore {
|
|
47
52
|
const ig = ignore().add(["node_modules", "dist"]);
|
|
48
53
|
const gitignorePath = join(root, ".gitignore");
|
|
@@ -52,7 +57,6 @@ function loadIgnore(root: string): Ignore {
|
|
|
52
57
|
return ig;
|
|
53
58
|
}
|
|
54
59
|
|
|
55
|
-
/** Recursively find all wm.ts files, respecting .gitignore rules. */
|
|
56
60
|
function findWmFiles(root: string): string[] {
|
|
57
61
|
const ig = loadIgnore(root);
|
|
58
62
|
const results: string[] = [];
|
|
@@ -72,53 +76,86 @@ function findWmFiles(root: string): string[] {
|
|
|
72
76
|
return results;
|
|
73
77
|
}
|
|
74
78
|
|
|
75
|
-
|
|
79
|
+
function findTraitFiles(root: string): string[] {
|
|
80
|
+
const traitsDir = join(root, ".wm", "traits");
|
|
81
|
+
if (!existsSync(traitsDir)) return [];
|
|
82
|
+
const out: string[] = [];
|
|
83
|
+
function walk(dir: string): void {
|
|
84
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
85
|
+
const full = join(dir, entry.name);
|
|
86
|
+
if (entry.isDirectory()) walk(full);
|
|
87
|
+
else if (entry.name.endsWith(".ts") && !entry.name.endsWith(".d.ts")) {
|
|
88
|
+
out.push(full);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
walk(traitsDir);
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
76
96
|
function findRoot(from: string): string {
|
|
77
97
|
let dir = from;
|
|
78
98
|
while (true) {
|
|
79
|
-
if (existsSync(join(dir, ".wm")))
|
|
80
|
-
return dir;
|
|
81
|
-
}
|
|
99
|
+
if (existsSync(join(dir, ".wm"))) return dir;
|
|
82
100
|
const parent = dirname(dir);
|
|
83
101
|
if (parent === dir) throw new Error("Could not find workspace root (.wm/ directory)");
|
|
84
102
|
dir = parent;
|
|
85
103
|
}
|
|
86
104
|
}
|
|
87
105
|
|
|
88
|
-
|
|
89
|
-
const root = from ?? process.env.WORKSPACE_ROOT ?? findRoot(process.cwd());
|
|
90
|
-
const jiti = createJiti(root, jitiOptions());
|
|
106
|
+
// ---- Load --------------------------------------------------------------
|
|
91
107
|
|
|
92
|
-
|
|
93
|
-
const
|
|
108
|
+
async function loadTraits(root: string, jiti: ReturnType<typeof createJiti>): Promise<TraitRegistry> {
|
|
109
|
+
const registry = new TraitRegistry();
|
|
110
|
+
installRegistry(registry);
|
|
111
|
+
for (const file of findTraitFiles(root)) {
|
|
112
|
+
try {
|
|
113
|
+
await jiti.import(file);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
uninstallRegistry();
|
|
116
|
+
throw new Error(`Failed to import trait file ${file}: ${(err as Error).message}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// Registry stays installed — command loading re-executes trait files
|
|
120
|
+
// under jiti moduleCache:false, and those defineTrait calls need it.
|
|
121
|
+
return registry;
|
|
122
|
+
}
|
|
94
123
|
|
|
95
|
-
|
|
124
|
+
async function loadProjects(
|
|
125
|
+
root: string,
|
|
126
|
+
jiti: ReturnType<typeof createJiti>,
|
|
127
|
+
registry: TraitRegistry,
|
|
128
|
+
): Promise<Project[]> {
|
|
96
129
|
const projects: Project[] = [];
|
|
97
|
-
|
|
98
|
-
for (const wmFile of wmFiles) {
|
|
130
|
+
for (const wmFile of findWmFiles(root)) {
|
|
99
131
|
const dir = dirname(wmFile);
|
|
100
132
|
let exported: unknown;
|
|
101
133
|
try {
|
|
102
134
|
exported = await jiti.import(wmFile);
|
|
103
135
|
} catch (err) {
|
|
104
|
-
|
|
105
|
-
console.error(`[workspace] Skipping ${wmFile}: ${(err as Error).message}`);
|
|
106
|
-
continue;
|
|
136
|
+
throw new Error(`Failed to import ${wmFile}: ${(err as Error).message}`);
|
|
107
137
|
}
|
|
108
|
-
|
|
109
|
-
// jiti may return { default: ... } when interopDefault doesn't fully unwrap
|
|
110
138
|
if (exported && typeof exported === "object" && "default" in (exported as Record<string, unknown>)) {
|
|
111
139
|
exported = (exported as Record<string, unknown>).default;
|
|
112
140
|
}
|
|
113
|
-
|
|
114
141
|
const items: unknown[] = Array.isArray(exported) ? exported : [exported];
|
|
115
|
-
|
|
116
142
|
for (const item of items) {
|
|
117
143
|
if (item && typeof item === "object" && typeof (item as Record<string, unknown>).name === "string") {
|
|
118
|
-
|
|
144
|
+
const def = item as ProjectDef;
|
|
145
|
+
const traitData = validateHas(def, registry, wmFile);
|
|
146
|
+
projects.push(new Project(def, dir, traitData));
|
|
119
147
|
}
|
|
120
148
|
}
|
|
121
149
|
}
|
|
150
|
+
return projects;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function loadWorkspace(from?: string): Promise<Workspace> {
|
|
154
|
+
const root = from ?? process.env.WORKSPACE_ROOT ?? findRoot(process.cwd());
|
|
155
|
+
const jiti = createJiti(root, jitiOptions());
|
|
156
|
+
|
|
157
|
+
const registry = await loadTraits(root, jiti);
|
|
158
|
+
const projects = await loadProjects(root, jiti, registry);
|
|
122
159
|
|
|
123
|
-
return new Workspace(root, projects);
|
|
160
|
+
return new Workspace(root, projects, registry);
|
|
124
161
|
}
|