@ldlework/workmark 1.0.0

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/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env node
2
+ import { loadCommands } from "./lib/load.js";
3
+ import { loadWorkspace } from "./lib/workspace.js";
4
+ // ---------------------------------------------------------------------------
5
+ // Arg parsing
6
+ // ---------------------------------------------------------------------------
7
+ function parseValue(raw) {
8
+ if (raw === "true")
9
+ return true;
10
+ if (raw === "false")
11
+ return false;
12
+ const n = Number(raw);
13
+ if (!Number.isNaN(n) && raw !== "")
14
+ return n;
15
+ return raw;
16
+ }
17
+ function parseArgs(argv, positionalNames) {
18
+ const args = {};
19
+ let posIdx = 0;
20
+ for (let i = 0; i < argv.length; i++) {
21
+ const token = argv[i];
22
+ if (token.startsWith("--")) {
23
+ const key = token.slice(2);
24
+ const next = argv[i + 1];
25
+ if (next === undefined || next.startsWith("--")) {
26
+ args[key] = true;
27
+ }
28
+ else {
29
+ args[key] = parseValue(next);
30
+ i++;
31
+ }
32
+ }
33
+ else if (posIdx < positionalNames.length) {
34
+ args[positionalNames[posIdx]] = parseValue(token);
35
+ posIdx++;
36
+ }
37
+ }
38
+ return args;
39
+ }
40
+ // ---------------------------------------------------------------------------
41
+ // Help
42
+ // ---------------------------------------------------------------------------
43
+ function printHelp(commands) {
44
+ console.log("Usage: ws <command> [args...]\n");
45
+ // Group by group name
46
+ const groups = new Map();
47
+ for (const cmd of commands) {
48
+ const list = groups.get(cmd.group) ?? [];
49
+ list.push(cmd);
50
+ groups.set(cmd.group, list);
51
+ }
52
+ const maxCmd = Math.max(...commands.map((c) => {
53
+ const pos = c.positional.map((p) => `<${p}>`).join(" ");
54
+ return (c.name + " " + pos).trim().length;
55
+ }));
56
+ // Print ungrouped commands first (top-level)
57
+ const ungrouped = groups.get("");
58
+ if (ungrouped) {
59
+ for (const cmd of ungrouped) {
60
+ const pos = cmd.positional.map((p) => `<${p}>`).join(" ");
61
+ const left = (cmd.name + " " + pos).trim();
62
+ console.log(` ${left.padEnd(maxCmd + 4)} ${cmd.description}`);
63
+ }
64
+ console.log();
65
+ }
66
+ // Then grouped commands
67
+ for (const [group, cmds] of groups) {
68
+ if (group === "")
69
+ continue;
70
+ console.log(` ${group}:`);
71
+ for (const cmd of cmds) {
72
+ const pos = cmd.positional.map((p) => `<${p}>`).join(" ");
73
+ const left = (cmd.name + " " + pos).trim();
74
+ console.log(` ${left.padEnd(maxCmd + 4)} ${cmd.description}`);
75
+ }
76
+ console.log();
77
+ }
78
+ }
79
+ function printCommandHelp(cmd) {
80
+ const pos = cmd.positional.map((p) => `<${p}>`).join(" ");
81
+ console.log(`Usage: ws ${cmd.name} ${pos}\n`);
82
+ console.log(cmd.description);
83
+ const schema = cmd.inputSchema;
84
+ const props = schema.properties;
85
+ if (!props || Object.keys(props).length === 0)
86
+ return;
87
+ const positionalSet = new Set(cmd.positional);
88
+ const required = new Set(schema.required ?? []);
89
+ console.log("\nArguments:");
90
+ for (const [name, prop] of Object.entries(props)) {
91
+ const isPositional = positionalSet.has(name);
92
+ const isReq = required.has(name);
93
+ const flag = isPositional ? ` ${name}` : ` --${name}`;
94
+ const parts = [];
95
+ if (prop.enum)
96
+ parts.push(`[${prop.enum.join("|")}]`);
97
+ if (prop.type)
98
+ parts.push(`(${prop.type})`);
99
+ if (prop.default !== undefined)
100
+ parts.push(`default: ${prop.default}`);
101
+ if (isReq && !isPositional)
102
+ parts.push("required");
103
+ const desc = prop.description ?? "";
104
+ console.log(`${flag.padEnd(20)} ${desc}${parts.length ? " " + parts.join(", ") : ""}`);
105
+ }
106
+ }
107
+ // ---------------------------------------------------------------------------
108
+ // Main
109
+ // ---------------------------------------------------------------------------
110
+ async function main() {
111
+ const [command, ...rest] = process.argv.slice(2);
112
+ const workspace = await loadWorkspace();
113
+ const commands = await loadCommands(workspace);
114
+ if (!command || command === "--help" || command === "-h") {
115
+ printHelp(commands);
116
+ return;
117
+ }
118
+ // Build lookup: command name → ResolvedCommand
119
+ const cmdMap = new Map();
120
+ for (const cmd of commands) {
121
+ cmdMap.set(cmd.name, cmd);
122
+ }
123
+ const cmd = cmdMap.get(command);
124
+ if (!cmd) {
125
+ console.error(`Unknown command: ${command}`);
126
+ console.error(`Run 'ws --help' for available commands.`);
127
+ process.exit(1);
128
+ }
129
+ // Per-command help
130
+ if (rest[0] === "--help" || rest[0] === "-h") {
131
+ printCommandHelp(cmd);
132
+ return;
133
+ }
134
+ const args = parseArgs(rest, cmd.positional);
135
+ const result = await cmd.handler(args);
136
+ for (const content of result.content) {
137
+ if (content.type === "text") {
138
+ const stream = result.isError ? process.stderr : process.stdout;
139
+ stream.write(content.text + "\n");
140
+ }
141
+ }
142
+ if (result.isError)
143
+ process.exit(1);
144
+ }
145
+ main().catch((err) => {
146
+ console.error("Fatal:", err);
147
+ process.exit(1);
148
+ });
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { CallToolRequestSchema, ListToolsRequestSchema, ErrorCode, McpError, } from "@modelcontextprotocol/sdk/types.js";
5
+ import { loadCommands } from "./lib/load.js";
6
+ import { loadWorkspace } from "./lib/workspace.js";
7
+ async function main() {
8
+ const server = new Server({ name: "workmark", version: "1.0.0" }, { capabilities: { tools: {} } });
9
+ const workspace = await loadWorkspace();
10
+ const commands = await loadCommands(workspace);
11
+ const handlers = new Map(commands.map((c) => [c.name, c.handler]));
12
+ console.error(`Loaded ${commands.length} commands from ${workspace.projects.length} projects`);
13
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
14
+ tools: commands.map(({ name, description, inputSchema }) => ({
15
+ name,
16
+ description,
17
+ inputSchema,
18
+ })),
19
+ }));
20
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
21
+ const handler = handlers.get(request.params.name);
22
+ if (!handler) {
23
+ throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`);
24
+ }
25
+ return handler(request.params.arguments ?? {});
26
+ });
27
+ server.onerror = (error) => console.error("[MCP Error]", error);
28
+ const transport = new StdioServerTransport();
29
+ await server.connect(transport);
30
+ console.error("workmark MCP server running on stdio");
31
+ const cleanup = async () => {
32
+ await server.close();
33
+ process.exit(0);
34
+ };
35
+ process.on("SIGINT", cleanup);
36
+ process.on("SIGTERM", cleanup);
37
+ }
38
+ main().catch((err) => {
39
+ console.error("Fatal:", err);
40
+ process.exit(1);
41
+ });
@@ -0,0 +1,3 @@
1
+ import type { ProjectDef } from "./types.js";
2
+ /** Define a project. Identity function for type safety. */
3
+ export declare function defineProject(def: ProjectDef): ProjectDef;
@@ -0,0 +1,4 @@
1
+ /** Define a project. Identity function for type safety. */
2
+ export function defineProject(def) {
3
+ return def;
4
+ }
@@ -0,0 +1,9 @@
1
+ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
2
+ export declare function ok(data: unknown): CallToolResult;
3
+ export declare function fail(error: unknown): CallToolResult;
4
+ export interface ExecOptions {
5
+ cwd: string;
6
+ timeout?: number;
7
+ }
8
+ export declare function exec(command: string, opts: ExecOptions): string;
9
+ export declare function execAsync(command: string, opts: ExecOptions): Promise<string>;
@@ -0,0 +1,48 @@
1
+ import { execSync, exec as cpExec } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ // --- Response helpers ---
4
+ export function ok(data) {
5
+ return {
6
+ content: [
7
+ {
8
+ type: "text",
9
+ text: typeof data === "string" ? data : JSON.stringify(data, null, 2),
10
+ },
11
+ ],
12
+ };
13
+ }
14
+ export function fail(error) {
15
+ let msg;
16
+ if (error instanceof Error) {
17
+ const { stderr, stdout } = error;
18
+ msg =
19
+ (typeof stderr === "string" && stderr) ||
20
+ (typeof stdout === "string" && stdout) ||
21
+ error.message;
22
+ }
23
+ else {
24
+ msg = String(error);
25
+ }
26
+ return { content: [{ type: "text", text: msg }], isError: true };
27
+ }
28
+ export function exec(command, opts) {
29
+ const { cwd, timeout = 120_000 } = opts;
30
+ return execSync(command, {
31
+ cwd,
32
+ timeout,
33
+ encoding: "utf-8",
34
+ stdio: ["ignore", "pipe", "pipe"],
35
+ maxBuffer: 1024 * 1024 * 10,
36
+ });
37
+ }
38
+ const execPromise = promisify(cpExec);
39
+ export async function execAsync(command, opts) {
40
+ const { cwd, timeout = 120_000 } = opts;
41
+ const { stdout } = await execPromise(command, {
42
+ cwd,
43
+ timeout,
44
+ encoding: "utf-8",
45
+ maxBuffer: 1024 * 1024 * 10,
46
+ });
47
+ return stdout;
48
+ }
@@ -0,0 +1,7 @@
1
+ import type { JitiOptions } from "jiti";
2
+ /**
3
+ * Build jiti options that let ws.ts / command files import from
4
+ * `@ldlework/workmark/*` and from workmark's own dependencies (zod, etc.)
5
+ * regardless of how the package is installed or linked.
6
+ */
7
+ export declare function jitiOptions(opts?: Partial<JitiOptions>): JitiOptions;
@@ -0,0 +1,26 @@
1
+ import { dirname, join } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ // Resolve the workmark package root from this file's location (src/lib/ or dist/lib/)
4
+ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
5
+ const SRC_LIB = join(PKG_ROOT, "src", "lib");
6
+ /**
7
+ * Build jiti options that let ws.ts / command files import from
8
+ * `@ldlework/workmark/*` and from workmark's own dependencies (zod, etc.)
9
+ * regardless of how the package is installed or linked.
10
+ */
11
+ export function jitiOptions(opts) {
12
+ return {
13
+ interopDefault: true,
14
+ moduleCache: false,
15
+ alias: {
16
+ "@ldlework/workmark/types": join(SRC_LIB, "types.ts"),
17
+ "@ldlework/workmark/helpers": join(SRC_LIB, "helpers.ts"),
18
+ "@ldlework/workmark/define": join(SRC_LIB, "define.ts"),
19
+ "@ldlework/workmark/workspace": join(SRC_LIB, "workspace.ts"),
20
+ "@ldlework/workmark/project": join(SRC_LIB, "project.ts"),
21
+ "@ldlework/workmark": join(SRC_LIB, "load.ts"),
22
+ },
23
+ nativeModules: ["zod", "@modelcontextprotocol/sdk"],
24
+ ...opts,
25
+ };
26
+ }
@@ -0,0 +1,2 @@
1
+ import type { IWorkspace, ResolvedCommand } from "./types.js";
2
+ export declare function loadCommands(workspace: IWorkspace): Promise<ResolvedCommand[]>;
@@ -0,0 +1,105 @@
1
+ import { existsSync, readdirSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { createJiti } from "jiti";
4
+ import { z } from "zod";
5
+ import { jitiOptions } from "./jiti-options.js";
6
+ /** Title-case a folder name: "docker" → "Docker" */
7
+ function titleCase(s) {
8
+ return s.charAt(0).toUpperCase() + s.slice(1);
9
+ }
10
+ function isStatic(def) {
11
+ return "handler" in def;
12
+ }
13
+ /** Convert a single field (Zod or raw JSON Schema property) to a JSON Schema property. */
14
+ function fieldToJsonSchema(field) {
15
+ if (field instanceof z.ZodType) {
16
+ return z.toJSONSchema(field);
17
+ }
18
+ return field;
19
+ }
20
+ /** Merge args + flags into a single JSON Schema object and return positional names. */
21
+ function mergeSchemas(args, flags) {
22
+ const properties = {};
23
+ const required = [];
24
+ const positional = [];
25
+ // Args are positional, in key order
26
+ if (args) {
27
+ for (const [name, field] of Object.entries(args)) {
28
+ const schema = fieldToJsonSchema(field);
29
+ properties[name] = schema;
30
+ positional.push(name);
31
+ // If no default and not explicitly optional, it's required
32
+ if (!("default" in schema)) {
33
+ required.push(name);
34
+ }
35
+ }
36
+ }
37
+ // Flags are named (--key value)
38
+ if (flags) {
39
+ for (const [name, field] of Object.entries(flags)) {
40
+ const schema = fieldToJsonSchema(field);
41
+ properties[name] = schema;
42
+ if (!("default" in schema)) {
43
+ required.push(name);
44
+ }
45
+ }
46
+ }
47
+ const inputSchema = {
48
+ type: "object",
49
+ properties,
50
+ };
51
+ if (required.length > 0) {
52
+ inputSchema.required = required;
53
+ }
54
+ return { inputSchema, positional };
55
+ }
56
+ function resolve(def, workspace, group, sourceFile) {
57
+ let args;
58
+ let flags;
59
+ let handler;
60
+ if (isStatic(def)) {
61
+ args = def.args;
62
+ flags = def.flags;
63
+ handler = def.handler;
64
+ }
65
+ else {
66
+ const result = def.factory(workspace);
67
+ args = result.args;
68
+ flags = result.flags;
69
+ handler = result.handler;
70
+ }
71
+ const { inputSchema, positional } = mergeSchemas(args, flags);
72
+ return { name: def.name, label: def.label, group, description: def.description, inputSchema, positional, handler, sourceFile };
73
+ }
74
+ export async function loadCommands(workspace) {
75
+ const commandsDir = join(workspace.root, ".ws", "commands");
76
+ if (!existsSync(commandsDir))
77
+ return [];
78
+ const jiti = createJiti(commandsDir, jitiOptions());
79
+ const commands = [];
80
+ // Ungrouped (root-level) commands first
81
+ for (const entry of readdirSync(commandsDir)) {
82
+ const entryPath = join(commandsDir, entry);
83
+ const stat = statSync(entryPath);
84
+ if (!stat.isDirectory() && entry.endsWith(".ts") && !entry.endsWith(".d.ts")) {
85
+ const mod = await jiti.import(entryPath);
86
+ commands.push(resolve(mod.default, workspace, "", entryPath));
87
+ }
88
+ }
89
+ // Grouped commands (subdirectories)
90
+ for (const entry of readdirSync(commandsDir)) {
91
+ const entryPath = join(commandsDir, entry);
92
+ const stat = statSync(entryPath);
93
+ if (stat.isDirectory()) {
94
+ const group = titleCase(entry);
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));
101
+ }
102
+ }
103
+ }
104
+ return commands;
105
+ }
@@ -0,0 +1,12 @@
1
+ import type { IProject, ProjectDef } from "./types.js";
2
+ export declare class Project implements IProject {
3
+ readonly name: string;
4
+ readonly dir: string;
5
+ readonly tags: readonly string[];
6
+ readonly capabilities: Readonly<Record<string, true | Record<string, unknown>>>;
7
+ private readonly paths;
8
+ constructor(def: ProjectDef, wsDir: string);
9
+ has(capability: string): boolean;
10
+ capability<T = true | Record<string, unknown>>(name: string): T;
11
+ path(name: string): string;
12
+ }
@@ -0,0 +1,32 @@
1
+ import { join } from "node:path";
2
+ export class Project {
3
+ name;
4
+ dir;
5
+ tags;
6
+ capabilities;
7
+ paths;
8
+ constructor(def, wsDir) {
9
+ this.name = def.name;
10
+ this.dir = def.dir ? join(wsDir, def.dir) : wsDir;
11
+ this.tags = Object.freeze(def.tags ?? []);
12
+ this.capabilities = Object.freeze(def.capabilities ?? {});
13
+ this.paths = Object.freeze(def.paths ?? {});
14
+ }
15
+ has(capability) {
16
+ return capability in this.capabilities;
17
+ }
18
+ capability(name) {
19
+ const cap = this.capabilities[name];
20
+ if (cap === undefined) {
21
+ throw new Error(`Project "${this.name}" does not have capability "${name}"`);
22
+ }
23
+ return cap;
24
+ }
25
+ path(name) {
26
+ const rel = this.paths[name];
27
+ if (rel === undefined) {
28
+ throw new Error(`Project "${this.name}" does not have path "${name}"`);
29
+ }
30
+ return join(this.dir, rel);
31
+ }
32
+ }
@@ -0,0 +1,69 @@
1
+ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
2
+ import type { z } from "zod";
3
+ /** An input schema: either a Zod schema (converted automatically) or raw JSON Schema. */
4
+ export type InputSchema = z.ZodType | Record<string, unknown>;
5
+ /** A record of named Zod schemas or raw JSON Schema property objects. */
6
+ export type SchemaFields = Record<string, z.ZodType | Record<string, unknown>>;
7
+ export interface ProjectDef {
8
+ /** Unique identifier. */
9
+ name: string;
10
+ /** Project directory relative to the ws.ts location. Defaults to ".". */
11
+ dir?: string;
12
+ /** Arbitrary string tags for grouping/filtering. */
13
+ tags?: string[];
14
+ /** Named directories, resolved relative to the project dir. */
15
+ paths?: Record<string, string>;
16
+ /** Capabilities this project supports. `true` = use convention, object = custom config. */
17
+ capabilities?: Record<string, true | Record<string, unknown>>;
18
+ }
19
+ export interface IProject {
20
+ readonly name: string;
21
+ readonly dir: string;
22
+ readonly tags: readonly string[];
23
+ readonly capabilities: Readonly<Record<string, true | Record<string, unknown>>>;
24
+ has(capability: string): boolean;
25
+ capability<T = true | Record<string, unknown>>(name: string): T;
26
+ path(name: string): string;
27
+ }
28
+ export interface IWorkspace {
29
+ readonly root: string;
30
+ readonly projects: readonly IProject[];
31
+ get(name: string): IProject;
32
+ withCapability(cap: string): IProject[];
33
+ withTag(tag: string): IProject[];
34
+ }
35
+ export type ToolHandler = (args: Record<string, unknown>) => Promise<CallToolResult>;
36
+ export interface StaticCommandDef {
37
+ name: string;
38
+ label: string;
39
+ description: string;
40
+ /** Positional arguments (order = key order). */
41
+ args?: SchemaFields;
42
+ /** Named flags (--key value). */
43
+ flags?: SchemaFields;
44
+ handler: ToolHandler;
45
+ }
46
+ export interface DynamicCommandDef {
47
+ name: string;
48
+ label: string;
49
+ description: string;
50
+ factory: (workspace: IWorkspace) => {
51
+ args?: SchemaFields;
52
+ flags?: SchemaFields;
53
+ handler: ToolHandler;
54
+ };
55
+ }
56
+ export type CommandDef = StaticCommandDef | DynamicCommandDef;
57
+ export interface ResolvedCommand {
58
+ name: string;
59
+ label: string;
60
+ group: string;
61
+ description: string;
62
+ /** Merged JSON Schema from args + flags. */
63
+ inputSchema: Record<string, unknown>;
64
+ /** Positional arg names in order. */
65
+ positional: string[];
66
+ handler: ToolHandler;
67
+ /** Absolute path to the command source file. */
68
+ sourceFile?: string;
69
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,11 @@
1
+ import type { IProject, IWorkspace } from "./types.js";
2
+ export declare class Workspace implements IWorkspace {
3
+ readonly root: string;
4
+ readonly projects: readonly IProject[];
5
+ private readonly byName;
6
+ constructor(root: string, projects: IProject[]);
7
+ get(name: string): IProject;
8
+ withCapability(cap: string): IProject[];
9
+ withTag(tag: string): IProject[];
10
+ }
11
+ export declare function loadWorkspace(from?: string): Promise<Workspace>;
@@ -0,0 +1,109 @@
1
+ import { dirname, join, relative } from "node:path";
2
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
3
+ import { createJiti } from "jiti";
4
+ import ignore from "ignore";
5
+ import { Project } from "./project.js";
6
+ import { jitiOptions } from "./jiti-options.js";
7
+ export class Workspace {
8
+ root;
9
+ projects;
10
+ byName;
11
+ constructor(root, projects) {
12
+ this.root = root;
13
+ this.projects = Object.freeze(projects);
14
+ this.byName = new Map(projects.map((p) => [p.name, p]));
15
+ // Validate unique names
16
+ if (this.byName.size !== projects.length) {
17
+ const seen = new Set();
18
+ for (const p of projects) {
19
+ if (seen.has(p.name))
20
+ throw new Error(`Duplicate project name: "${p.name}"`);
21
+ seen.add(p.name);
22
+ }
23
+ }
24
+ }
25
+ get(name) {
26
+ const p = this.byName.get(name);
27
+ if (!p)
28
+ throw new Error(`Unknown project: "${name}"`);
29
+ return p;
30
+ }
31
+ withCapability(cap) {
32
+ return this.projects.filter((p) => p.has(cap));
33
+ }
34
+ withTag(tag) {
35
+ return this.projects.filter((p) => p.tags.includes(tag));
36
+ }
37
+ }
38
+ /** Build an Ignore instance from .gitignore (if present) + hardcoded defaults. */
39
+ function loadIgnore(root) {
40
+ const ig = ignore().add(["node_modules", "dist"]);
41
+ const gitignorePath = join(root, ".gitignore");
42
+ if (existsSync(gitignorePath)) {
43
+ ig.add(readFileSync(gitignorePath, "utf-8"));
44
+ }
45
+ return ig;
46
+ }
47
+ /** Recursively find all ws.ts files, respecting .gitignore rules. */
48
+ function findWsFiles(root) {
49
+ const ig = loadIgnore(root);
50
+ const results = [];
51
+ function walk(dir) {
52
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
53
+ const rel = relative(root, join(dir, entry.name));
54
+ if (entry.isDirectory()) {
55
+ if (!ig.ignores(rel + "/"))
56
+ walk(join(dir, entry.name));
57
+ }
58
+ else if (entry.name === "ws.ts") {
59
+ results.push(join(dir, entry.name));
60
+ }
61
+ }
62
+ }
63
+ walk(root);
64
+ return results;
65
+ }
66
+ /** Find the workspace root by walking up from cwd looking for .ws/. */
67
+ function findRoot(from) {
68
+ let dir = from;
69
+ while (true) {
70
+ if (existsSync(join(dir, ".ws"))) {
71
+ return dir;
72
+ }
73
+ const parent = dirname(dir);
74
+ if (parent === dir)
75
+ throw new Error("Could not find workspace root (.ws/ directory)");
76
+ dir = parent;
77
+ }
78
+ }
79
+ export async function loadWorkspace(from) {
80
+ const root = from ?? process.env.WORKSPACE_ROOT ?? findRoot(process.cwd());
81
+ const jiti = createJiti(root, jitiOptions());
82
+ // Recursively find all ws.ts files
83
+ const wsFiles = findWsFiles(root);
84
+ // Import each and build Project instances
85
+ const projects = [];
86
+ for (const wsFile of wsFiles) {
87
+ const dir = dirname(wsFile);
88
+ let exported;
89
+ try {
90
+ exported = await jiti.import(wsFile);
91
+ }
92
+ catch (err) {
93
+ // Log import failures — helps debug bad ws.ts files
94
+ console.error(`[workspace] Skipping ${wsFile}: ${err.message}`);
95
+ continue;
96
+ }
97
+ // jiti may return { default: ... } when interopDefault doesn't fully unwrap
98
+ if (exported && typeof exported === "object" && "default" in exported) {
99
+ exported = exported.default;
100
+ }
101
+ const items = Array.isArray(exported) ? exported : [exported];
102
+ for (const item of items) {
103
+ if (item && typeof item === "object" && typeof item.name === "string") {
104
+ projects.push(new Project(item, dir));
105
+ }
106
+ }
107
+ }
108
+ return new Workspace(root, projects);
109
+ }