@webjsdev/cli 0.10.11 → 0.10.12

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.
@@ -0,0 +1,400 @@
1
+ /**
2
+ * The knowledge + authoring layer for `webjs mcp` (#376).
3
+ *
4
+ * `webjs mcp` (lib/mcp.js) started as four READ-ONLY introspection tools. This
5
+ * module adds the second layer the Next.js MCP (`next-devtools-mcp`) showed is
6
+ * the high-leverage one: the framework knowledge an agent needs to author
7
+ * idiomatic webjs code, surfaced as MCP RESOURCES (the `agent-docs/*.md` corpus
8
+ * plus the `AGENTS.md` contract), an `init` "read first" primer that fights the
9
+ * React/RSC mental model, a `docs` retrieval tool, and guided-workflow PROMPTS
10
+ * built from the recipes.
11
+ *
12
+ * It stays hand-rolled and ZERO-dependency (no `@modelcontextprotocol/sdk`),
13
+ * consistent with webjs being buildless + minimal-deps. Everything here is PURE
14
+ * given its injected `{ docsDir, agentsPath, readFile }` deps, so it is testable
15
+ * in-process without booting a server or touching the real filesystem.
16
+ *
17
+ * Docs resolution (so `npx @webjsdev/cli mcp` is self-contained): a published
18
+ * install reads the corpus bundled under `<cli>/resources/agent-docs` (copied
19
+ * at `prepack`, see `scripts/copy-mcp-resources.js`); a monorepo dev run falls
20
+ * back to the repo-root `agent-docs/`. {@link resolveDocsLocation} encodes that
21
+ * two-path lookup so source stays single (no committed duplicate docs).
22
+ *
23
+ * @module mcp-docs
24
+ */
25
+
26
+ import { existsSync } from 'node:fs';
27
+ import { dirname, join, resolve } from 'node:path';
28
+ import { fileURLToPath } from 'node:url';
29
+
30
+ /** The URI scheme for a framework-docs resource: `webjs-docs://<name>`. */
31
+ const DOCS_SCHEME = 'webjs-docs://';
32
+
33
+ /**
34
+ * Resolve where the framework-docs corpus lives, plus the `AGENTS.md` contract
35
+ * path. Tries the BUNDLED location first (a published `@webjsdev/cli` ships
36
+ * `resources/agent-docs/` + `resources/AGENTS.md` via `prepack`), then falls
37
+ * back to the monorepo-root layout (`agent-docs/` + `AGENTS.md`) used in dev and
38
+ * tests. Returns `{ docsDir, agentsPath }`; either path may not exist, callers
39
+ * fail soft (an empty corpus is valid, never a crash).
40
+ *
41
+ * @param {string} [moduleUrl] `import.meta.url` of the caller (defaults to this module)
42
+ * @returns {{ docsDir: string, agentsPath: string }}
43
+ */
44
+ export function resolveDocsLocation(moduleUrl) {
45
+ const here = dirname(fileURLToPath(moduleUrl || import.meta.url));
46
+ const cliRoot = resolve(here, '..'); // packages/cli/lib -> packages/cli
47
+ const repoRoot = resolve(here, '..', '..', '..'); // -> monorepo root
48
+
49
+ const bundledDocs = join(cliRoot, 'resources', 'agent-docs');
50
+ if (existsSync(bundledDocs)) {
51
+ return { docsDir: bundledDocs, agentsPath: join(cliRoot, 'resources', 'AGENTS.md') };
52
+ }
53
+ return { docsDir: join(repoRoot, 'agent-docs'), agentsPath: join(repoRoot, 'AGENTS.md') };
54
+ }
55
+
56
+ /**
57
+ * A doc's logical name from its filename: `lit-muscle-memory-gotchas.md` ->
58
+ * `lit-muscle-memory-gotchas`. The `AGENTS.md` contract keeps its own name.
59
+ *
60
+ * @param {string} file
61
+ * @returns {string}
62
+ */
63
+ function docName(file) {
64
+ return file.replace(/\.md$/i, '');
65
+ }
66
+
67
+ /**
68
+ * A human title for a doc name: `lit-muscle-memory-gotchas` ->
69
+ * `Lit Muscle Memory Gotchas`. Used in the resource listing so a model picks
70
+ * the right doc without reading it.
71
+ *
72
+ * @param {string} name
73
+ * @returns {string}
74
+ */
75
+ function titleFor(name) {
76
+ if (name === 'AGENTS') return 'AGENTS.md (the framework contract + invariants)';
77
+ return name
78
+ .split('-')
79
+ .map((w) => (w ? w[0].toUpperCase() + w.slice(1) : w))
80
+ .join(' ');
81
+ }
82
+
83
+ /**
84
+ * The corpus catalogue: every servable doc as `{ name, file, uri, title }`.
85
+ * Reads the docs dir listing (so it tracks the shipped set) plus the `AGENTS.md`
86
+ * contract. PURE given `deps`.
87
+ *
88
+ * @param {{ docsDir: string, agentsPath: string, listDir: (d: string) => string[], exists: (p: string) => boolean }} deps
89
+ * @returns {Array<{ name: string, file: string, uri: string, title: string }>}
90
+ */
91
+ export function catalogue(deps) {
92
+ const { docsDir, agentsPath, listDir, exists } = deps;
93
+ /** @type {Array<{ name: string, file: string, uri: string, title: string }>} */
94
+ const out = [];
95
+ if (exists(agentsPath)) {
96
+ out.push({ name: 'AGENTS', file: agentsPath, uri: `${DOCS_SCHEME}AGENTS`, title: titleFor('AGENTS') });
97
+ }
98
+ let files = [];
99
+ try { files = listDir(docsDir).filter((f) => /\.md$/i.test(f)).sort(); } catch { files = []; }
100
+ for (const f of files) {
101
+ const name = docName(f);
102
+ out.push({ name, file: join(docsDir, f), uri: `${DOCS_SCHEME}${name}`, title: titleFor(name) });
103
+ }
104
+ return out;
105
+ }
106
+
107
+ /**
108
+ * MCP `resources/list`: the corpus as resource descriptors.
109
+ *
110
+ * @param {object} deps
111
+ * @returns {Array<{ uri: string, name: string, title: string, mimeType: string }>}
112
+ */
113
+ export function listResources(deps) {
114
+ return catalogue(deps).map((d) => ({
115
+ uri: d.uri,
116
+ name: d.name,
117
+ title: d.title,
118
+ mimeType: 'text/markdown',
119
+ }));
120
+ }
121
+
122
+ /**
123
+ * MCP `resources/read`: the markdown text for a `webjs-docs://<name>` URI.
124
+ * Throws a clear Error for an unknown URI (the dispatcher maps it to a JSON-RPC
125
+ * error, never a crash).
126
+ *
127
+ * @param {object} deps
128
+ * @param {string} uri
129
+ * @returns {Promise<{ uri: string, mimeType: string, text: string }>}
130
+ */
131
+ export async function readResource(deps, uri) {
132
+ const entry = catalogue(deps).find((d) => d.uri === uri);
133
+ if (!entry) throw new Error(`Unknown resource: ${uri}`);
134
+ const text = await deps.readFile(entry.file, 'utf8');
135
+ return { uri, mimeType: 'text/markdown', text };
136
+ }
137
+
138
+ /**
139
+ * Extract a `## <heading>` section (heading line through the line before the
140
+ * next same-or-higher-level heading) from a markdown doc. Used to source the
141
+ * `init` primer from `AGENTS.md` rather than hand-duplicating it (so it cannot
142
+ * drift). Returns '' when the heading is absent.
143
+ *
144
+ * @param {string} md
145
+ * @param {RegExp} headingRe matches the section's heading LINE (e.g. /^##\s+Execution model/m)
146
+ * @returns {string}
147
+ */
148
+ export function sectionByHeading(md, headingRe) {
149
+ const lines = md.split('\n');
150
+ let start = -1;
151
+ let level = 0;
152
+ for (let i = 0; i < lines.length; i++) {
153
+ if (headingRe.test(lines[i])) {
154
+ start = i;
155
+ level = (lines[i].match(/^#+/) || ['##'])[0].length;
156
+ break;
157
+ }
158
+ }
159
+ if (start === -1) return '';
160
+ let end = lines.length;
161
+ for (let i = start + 1; i < lines.length; i++) {
162
+ const m = lines[i].match(/^(#+)\s/);
163
+ if (m && m[1].length <= level) { end = i; break; }
164
+ }
165
+ return lines.slice(start, end).join('\n').trim();
166
+ }
167
+
168
+ /**
169
+ * The `init` tool output: the "read first" orientation that fights the
170
+ * React/RSC mental model. Sources the EXECUTION MODEL + INVARIANTS sections
171
+ * from the shipped `AGENTS.md` (no hand-duplication), prepends a short router
172
+ * to the highest-value resources, and lists the corpus so the agent knows what
173
+ * else it can pull. PURE given `deps`.
174
+ *
175
+ * @param {object} deps
176
+ * @returns {Promise<string>}
177
+ */
178
+ export async function initText(deps) {
179
+ let agents = '';
180
+ try { agents = await deps.readFile(deps.agentsPath, 'utf8'); } catch { agents = ''; }
181
+ const execModel = sectionByHeading(agents, /^##\s+Execution model/im);
182
+ const invariants = sectionByHeading(agents, /^##\s+Invariants/im);
183
+
184
+ const cat = catalogue(deps);
185
+ const resourceList = cat.map((d) => `- \`${d.uri}\` (${d.title})`).join('\n');
186
+
187
+ const router = [
188
+ 'You are about to write or edit a webjs app. Read this orientation FIRST, then',
189
+ 'pull the specific docs you need via the `docs` tool or the `webjs-docs://*`',
190
+ 'resources. webjs is web-components-first and looks like Lit + Rails, NOT React/Next:',
191
+ 'there is NO RSC, no server/client component split, no `use client`. Components',
192
+ 'hydrate (islands); pages and layouts do NOT hydrate. Signals are the default',
193
+ 'state primitive. Server-only code lives behind the `.server.{js,ts}` boundary.',
194
+ 'When writing a component, read `webjs-docs://lit-muscle-memory-gotchas` first:',
195
+ 'the Lit habits that break webjs SSR/reactivity each have a webjs-shaped fix there.',
196
+ '',
197
+ 'webjs is buildless: the authored framework source is readable JSDoc in',
198
+ '`node_modules/@webjsdev/<pkg>/src`, and server-side that source runs directly.',
199
+ '(The one built artifact is the `@webjsdev/core` BROWSER bundle in `dist/`;',
200
+ 'its authored source is still in `src/`.) When the docs do not answer something,',
201
+ 'use the `source` tool to grep or read that real `src/` source (it skips `dist/`).',
202
+ ].join('\n');
203
+
204
+ const parts = [
205
+ '# webjs: read first',
206
+ '',
207
+ router,
208
+ '',
209
+ execModel || '(execution-model section unavailable)',
210
+ '',
211
+ invariants || '(invariants section unavailable)',
212
+ '',
213
+ '## Available docs (read via the `docs` tool or these resources)',
214
+ '',
215
+ resourceList || '(no docs bundled)',
216
+ ];
217
+ return parts.join('\n');
218
+ }
219
+
220
+ /**
221
+ * The `docs` tool. With `topic` matching a corpus name, returns that doc's full
222
+ * text. With `query`, keyword-searches every doc and returns the matching lines
223
+ * (each tagged with its source URI and nearest heading). With neither, returns
224
+ * the topic index (the catalogue). PURE given `deps`.
225
+ *
226
+ * @param {object} deps
227
+ * @param {{ topic?: string, query?: string }} [args]
228
+ * @returns {Promise<string>}
229
+ */
230
+ export async function searchDocs(deps, args) {
231
+ const { topic, query } = args || {};
232
+ const cat = catalogue(deps);
233
+
234
+ if (topic) {
235
+ const entry = cat.find((d) => d.name.toLowerCase() === String(topic).toLowerCase());
236
+ if (!entry) {
237
+ const names = cat.map((d) => d.name).join(', ');
238
+ return `Unknown topic "${topic}". Available topics: ${names}`;
239
+ }
240
+ return await deps.readFile(entry.file, 'utf8');
241
+ }
242
+
243
+ if (query) {
244
+ const q = String(query).toLowerCase();
245
+ const MAX_HITS = 40;
246
+ /** @type {string[]} */
247
+ const hits = [];
248
+ let capped = false;
249
+ outer: for (const entry of cat) {
250
+ let text = '';
251
+ try { text = await deps.readFile(entry.file, 'utf8'); } catch { continue; }
252
+ const lines = text.split('\n');
253
+ for (let i = 0; i < lines.length; i++) {
254
+ if (!lines[i].toLowerCase().includes(q)) continue;
255
+ // Check the cap BEFORE pushing, so `capped` means a match BEYOND the cap
256
+ // exists (exactly MAX_HITS matches is NOT a truncation, nothing dropped).
257
+ if (hits.length >= MAX_HITS) { capped = true; break outer; }
258
+ // Nearest preceding heading for context.
259
+ let heading = '';
260
+ for (let j = i; j >= 0; j--) {
261
+ if (/^#+\s/.test(lines[j])) { heading = lines[j].replace(/^#+\s/, ''); break; }
262
+ }
263
+ hits.push(`[${entry.uri}] ${heading ? heading + ': ' : ''}${lines[i].trim()}`);
264
+ }
265
+ }
266
+ if (!hits.length) return `No matches for "${query}" in the webjs docs. Topics: ${cat.map((d) => d.name).join(', ')}`;
267
+ // Disclose truncation rather than silently capping (no silent caps).
268
+ if (capped) hits.push(`... (truncated at ${MAX_HITS} matches; refine the query or open a doc with \`topic\`)`);
269
+ return hits.join('\n');
270
+ }
271
+
272
+ // No args: the topic index.
273
+ return ['webjs docs topics (pass one as `topic`, or `query` to search):', '', ...cat.map((d) => `- ${d.name}: ${d.title}`)].join('\n');
274
+ }
275
+
276
+ /**
277
+ * The guided-workflow PROMPTS, built from the recipes. Each is a single-message
278
+ * prompt that hands the agent the canonical webjs recipe plus the invariants it
279
+ * must not break, then tells it to pull `webjs-docs://recipes` for the full set.
280
+ * Static metadata; {@link getPrompt} fills the message text.
281
+ */
282
+ export const PROMPTS = [
283
+ { name: 'add_page', description: 'Scaffold a webjs page (app/<segment>/page.ts), the idiomatic way.', arguments: [{ name: 'route', description: 'URL path, e.g. /about', required: false }] },
284
+ { name: 'add_dynamic_route', description: 'Scaffold a dynamic page reading params, e.g. app/users/[id]/page.ts.', arguments: [{ name: 'route', description: 'URL path with a [param], e.g. /users/[id]', required: false }] },
285
+ { name: 'add_server_action', description: 'Scaffold a server action (.server.ts + use server) called from a component.', arguments: [{ name: 'feature', description: 'Feature/module name', required: false }] },
286
+ { name: 'add_component', description: 'Scaffold an interactive WebComponent (signals, light DOM, register).', arguments: [{ name: 'tag', description: 'Custom-element tag, e.g. my-thing', required: false }] },
287
+ { name: 'add_module', description: 'Scaffold a modules/<feature>/ slice (actions/queries/components/utils).', arguments: [{ name: 'feature', description: 'Feature name', required: false }] },
288
+ ];
289
+
290
+ /**
291
+ * The canonical recipe snippet + invariant reminders for each prompt. Kept
292
+ * compact on purpose: the prompt orients + shows the shape, then points at the
293
+ * full `webjs-docs://recipes` resource. The shapes mirror `agent-docs/recipes.md`.
294
+ *
295
+ * @type {Record<string, string>}
296
+ */
297
+ const PROMPT_BODIES = {
298
+ add_page: [
299
+ 'Add a webjs page. A page is `app/<segment>/page.ts` whose DEFAULT export is a',
300
+ '(possibly async) function returning a `TemplateResult`; it runs ONLY on the server.',
301
+ '',
302
+ '```ts',
303
+ "import { html } from '@webjsdev/core';",
304
+ 'export default function About() {',
305
+ ' return html`<h1>About</h1>`;',
306
+ '}',
307
+ '```',
308
+ '',
309
+ 'Rules: the default export is a FUNCTION (invariant 6), it does NOT call render().',
310
+ 'For interactivity, render a component tag inside it (pages do not hydrate).',
311
+ 'Name metadata via a `metadata` / `generateMetadata` named export.',
312
+ ].join('\n'),
313
+ add_dynamic_route: [
314
+ 'Add a dynamic page. `app/users/[id]/page.ts` receives `{ params }`; fetch data',
315
+ 'through a server action or query, never by importing the DB directly.',
316
+ '',
317
+ '```ts',
318
+ 'export default async function User({ params }: { params: { id: string } }) {',
319
+ ' const user = await getUser(params.id); // a `use server` action / query',
320
+ ' return html`<h1>${user.name}</h1>`;',
321
+ '}',
322
+ '```',
323
+ '',
324
+ 'Catch-all is `[...rest]`, optional catch-all `[[...rest]]`. Server-only data',
325
+ 'access goes through `.server.{js,ts}`, never a direct import into the page.',
326
+ ].join('\n'),
327
+ add_server_action: [
328
+ "Add a server action. A `*.server.ts` file with `'use server'` exports async",
329
+ 'functions that round-trip serializer-safe values; a client import is rewritten',
330
+ 'to a typed RPC stub (never hand-write fetch).',
331
+ '',
332
+ '```ts',
333
+ '// modules/<feature>/actions/<name>.server.ts',
334
+ "'use server';",
335
+ "import { prisma } from '../../../lib/prisma.server.ts';",
336
+ 'export async function doThing(input: { name: string }) {',
337
+ " const name = String(input?.name || '').trim();",
338
+ " if (!name) return { success: false, error: 'name required', status: 400 };",
339
+ ' return { success: true, data: await prisma.thing.create({ data: { name } }) };',
340
+ '}',
341
+ '```',
342
+ '',
343
+ 'Return the `ActionResult<T>` envelope. Server-only code MUST stay in `.server.*`',
344
+ '(invariant 1). Call it from a component via a normal import.',
345
+ ].join('\n'),
346
+ add_component: [
347
+ 'Add an interactive WebComponent. One custom element per file; register at module',
348
+ 'top level. Signals are the default state; read with `.get()` inside render().',
349
+ '',
350
+ '```ts',
351
+ "import { WebComponent, html, signal } from '@webjsdev/core';",
352
+ 'export class MyThing extends WebComponent {',
353
+ ' count = signal(0);',
354
+ ' render() {',
355
+ ' return html`<button @click=${() => this.count.set(this.count.get() + 1)}>',
356
+ ' ${this.count.get()}</button>`;',
357
+ ' }',
358
+ '}',
359
+ "MyThing.register('my-thing');",
360
+ '```',
361
+ '',
362
+ 'Tag MUST contain a hyphen (invariant 3). Event/property/boolean holes are',
363
+ 'unquoted (invariant 4). Read `webjs-docs://lit-muscle-memory-gotchas` first:',
364
+ 'a class-field initializer that overwrites a reactive accessor breaks reactivity',
365
+ '(use `declare` + `static properties`).',
366
+ ].join('\n'),
367
+ add_module: [
368
+ 'Add a feature module. `modules/<feature>/` holds `actions/*.server.ts` (mutations),',
369
+ '`queries/*.server.ts` (reads), `components/*.ts` (feature UI), `utils/*.ts` (pure),',
370
+ '`types.ts`. One function per action/query file. Routes stay thin: extract anything',
371
+ 'over ~20 lines into a module action. Shared presentational primitives go in the',
372
+ 'top-level `components/`, cross-cutting infra in `lib/*.server.ts`.',
373
+ ].join('\n'),
374
+ };
375
+
376
+ /**
377
+ * MCP `prompts/get`: the messages for a guided-workflow prompt. Throws for an
378
+ * unknown name (mapped to a JSON-RPC error).
379
+ *
380
+ * @param {string} name
381
+ * @param {Record<string, string>} [args]
382
+ * @returns {{ description: string, messages: Array<{ role: string, content: { type: string, text: string } }> }}
383
+ */
384
+ export function getPrompt(name, args) {
385
+ const meta = PROMPTS.find((p) => p.name === name);
386
+ const body = PROMPT_BODIES[name];
387
+ if (!meta || !body) throw new Error(`Unknown prompt: ${name}`);
388
+
389
+ // Fold any provided argument values in as a one-line hint at the top.
390
+ const provided = Object.entries(args || {}).filter(([, v]) => v != null && v !== '');
391
+ const argLine = provided.length
392
+ ? `Context: ${provided.map(([k, v]) => `${k}=${v}`).join(', ')}.\n\n`
393
+ : '';
394
+
395
+ const text = `${argLine}${body}\n\nSee \`webjs-docs://recipes\` for the full recipe set and \`webjs-docs://AGENTS\` for the invariants.`;
396
+ return {
397
+ description: meta.description,
398
+ messages: [{ role: 'user', content: { type: 'text', text } }],
399
+ };
400
+ }
@@ -0,0 +1,244 @@
1
+ /**
2
+ * The `source` tool for `webjs mcp` (#378): read-only access to the FRAMEWORK
3
+ * source itself.
4
+ *
5
+ * webjs is buildless, so every app's `node_modules/@webjsdev/<pkg>/src` holds the
6
+ * authored JSDoc `.js`, and server-side that source runs directly. (The one built
7
+ * artifact is the `@webjsdev/core` browser bundle in `dist/`, which this tool
8
+ * deliberately skips: it surfaces only the authored `src/`.) That is a real
9
+ * advantage: when the docs do not answer a question, an agent can read the real
10
+ * authored source. This tool makes that first-class and
11
+ * discoverable (and reachable for an MCP-only client with no filesystem tools):
12
+ * - no args (or `package`): list the resolved `@webjsdev/*` packages + their
13
+ * `src/` entry-point files.
14
+ * - `query`: grep the framework `src/` trees, returning bounded `file:line`
15
+ * hits (with a disclosed cap, no silent truncation).
16
+ * - `path`: read one source file (e.g. `server/src/ssr.js`), traversal-guarded
17
+ * to stay inside a resolved framework package root.
18
+ *
19
+ * READ-ONLY and side-effect-free: it only reads files, loads no module, and
20
+ * cannot read outside the resolved `@webjsdev/*` package roots. Zero-dependency,
21
+ * consistent with the rest of the server. PURE given injected `deps`
22
+ * (`{ roots, readFile, readdir }`), so it is testable against a fake tree.
23
+ *
24
+ * @module mcp-source
25
+ */
26
+
27
+ import { createRequire } from 'node:module';
28
+ import { join, resolve, sep, relative } from 'node:path';
29
+
30
+ /** The published framework packages whose source an agent may want to read. */
31
+ export const FRAMEWORK_PACKAGES = ['core', 'server', 'cli', 'ts-plugin', 'ui'];
32
+
33
+ /** Source file extensions worth grepping / reading (text, not assets). */
34
+ const TEXT_EXT = /\.(?:js|ts|mjs|mts|cjs|cts|json|md)$/i;
35
+
36
+ /** Bound the grep output (disclosed when hit) and the walk (defensive). */
37
+ const MAX_HITS = 60;
38
+ const MAX_FILES = 4000;
39
+
40
+ /**
41
+ * Resolve each `@webjsdev/*` package's root + source dir from `cwd`. Locates the
42
+ * root by checking each `require.resolve.paths` node_modules dir on disk for
43
+ * `@webjsdev/<pkg>/package.json`, so it works for a real `node_modules` install
44
+ * AND the monorepo workspace (where the dir is a symlink to `packages/<pkg>`),
45
+ * and honours hoisting. This fs check is deliberate: `<pkg>/package.json` is
46
+ * blocked by `exports` for server/cli/ui, and the bin-only cli has no main
47
+ * entry, so neither `resolve('<pkg>/package.json')` nor `resolve('<pkg>')` is
48
+ * reliable. The source dir is `src/`, or `lib/` for the cli. A package that is
49
+ * not installed is skipped (not every app depends on every `@webjsdev/*`).
50
+ *
51
+ * @param {string} cwd
52
+ * @param {{ exists: (p: string) => boolean }} fsDeps
53
+ * @returns {Array<{ pkg: string, root: string, src: string }>}
54
+ */
55
+ export function resolveFrameworkRoots(cwd, fsDeps) {
56
+ const req = createRequire(join(cwd, '__webjs_mcp_source__.js'));
57
+ /** @type {Array<{ pkg: string, root: string, src: string }>} */
58
+ const out = [];
59
+ for (const pkg of FRAMEWORK_PACKAGES) {
60
+ // Find the package ROOT by checking each node_modules search path on disk,
61
+ // NOT via `require.resolve('<pkg>')` or `<pkg>/package.json`: a package whose
62
+ // `exports` omits `./package.json` (server/cli/ui) or that has no main entry
63
+ // (cli is a bin-only package) would otherwise be unreachable. The fs check
64
+ // bypasses both and still honours hoisting (the search paths include every
65
+ // parent `node_modules`).
66
+ const bases = req.resolve.paths(`@webjsdev/${pkg}`) || [];
67
+ let root = '';
68
+ for (const base of bases) {
69
+ const cand = join(base, '@webjsdev', pkg);
70
+ if (fsDeps.exists(join(cand, 'package.json'))) { root = cand; break; }
71
+ }
72
+ if (!root) continue;
73
+ // Most packages keep source in `src/`; the cli keeps it in `lib/`. Use
74
+ // whichever exists so every framework package's source is reachable.
75
+ const src = fsDeps.exists(join(root, 'src'))
76
+ ? join(root, 'src')
77
+ : fsDeps.exists(join(root, 'lib'))
78
+ ? join(root, 'lib')
79
+ : '';
80
+ if (src) out.push({ pkg, root, src });
81
+ }
82
+ return out;
83
+ }
84
+
85
+ /**
86
+ * Recursively list text-source files under `dir` (absolute paths), skipping
87
+ * `node_modules` / `dist` and bounded by {@link MAX_FILES}.
88
+ *
89
+ * @param {string} dir
90
+ * @param {{ readdir: (d: string) => Array<{ name: string, isDir: boolean }> }} deps
91
+ * @returns {string[]}
92
+ */
93
+ export function walkSource(dir, deps) {
94
+ /** @type {string[]} */
95
+ const files = [];
96
+ /** @type {string[]} */
97
+ const stack = [dir];
98
+ while (stack.length && files.length < MAX_FILES) {
99
+ const d = stack.pop();
100
+ let entries = [];
101
+ try { entries = deps.readdir(d); } catch { continue; }
102
+ for (const e of entries) {
103
+ if (e.isDir) {
104
+ if (e.name === 'node_modules' || e.name === 'dist' || e.name === '.git') continue;
105
+ stack.push(join(d, e.name));
106
+ } else if (TEXT_EXT.test(e.name)) {
107
+ files.push(join(d, e.name));
108
+ if (files.length >= MAX_FILES) break;
109
+ }
110
+ }
111
+ }
112
+ return files.sort();
113
+ }
114
+
115
+ /**
116
+ * No-args / `package` mode: list the resolved packages and their `src/`
117
+ * top-level files (the entry points), so the agent has a map to grep or read.
118
+ *
119
+ * @param {{ roots: Array<{ pkg: string, root: string, src: string }>, readdir: Function }} deps
120
+ * @param {string} [pkgFilter]
121
+ * @returns {string}
122
+ */
123
+ export function listSources(deps, pkgFilter) {
124
+ const roots = pkgFilter ? deps.roots.filter((r) => r.pkg === pkgFilter) : deps.roots;
125
+ if (!roots.length) {
126
+ return pkgFilter
127
+ ? `@webjsdev/${pkgFilter} is not installed/resolvable here. Resolvable: ${deps.roots.map((r) => r.pkg).join(', ') || '(none)'}`
128
+ : 'No @webjsdev/* packages resolvable from here (run inside a webjs app or the monorepo).';
129
+ }
130
+ const lines = ['webjs framework authored source (buildless; server-side this runs directly, and core ships a built browser dist/ that is excluded here). Read with `source({ path })` or search with `source({ query })`.', ''];
131
+ for (const r of roots) {
132
+ const dirName = r.src.split(sep).pop(); // 'src' for most, 'lib' for cli
133
+ let entries = [];
134
+ try { entries = deps.readdir(r.src); } catch { entries = []; }
135
+ const top = entries.filter((e) => !e.isDir && TEXT_EXT.test(e.name)).map((e) => e.name).sort();
136
+ const dirs = entries.filter((e) => e.isDir).map((e) => e.name).sort();
137
+ lines.push(`@webjsdev/${r.pkg}/${dirName}:`);
138
+ if (top.length) lines.push(` files: ${top.map((f) => `${r.pkg}/${dirName}/${f}`).join(', ')}`);
139
+ if (dirs.length) lines.push(` subdirs: ${dirs.join(', ')}`);
140
+ }
141
+ return lines.join('\n');
142
+ }
143
+
144
+ /**
145
+ * `query` mode: grep every resolved `src/` tree for the (case-insensitive)
146
+ * substring, returning bounded `[<pkg>/src/<rel>:<line>] <text>` hits. Discloses
147
+ * truncation rather than silently capping.
148
+ *
149
+ * @param {{ roots: Array<{ pkg: string, root: string, src: string }>, readFile: Function, readdir: Function }} deps
150
+ * @param {string} query
151
+ * @returns {Promise<string>}
152
+ */
153
+ export async function grepSources(deps, query) {
154
+ const q = String(query).toLowerCase();
155
+ if (!q) return 'Provide a non-empty `query`.';
156
+ /** @type {string[]} */
157
+ const hits = [];
158
+ let capped = false;
159
+ outer: for (const r of deps.roots) {
160
+ for (const file of walkSource(r.src, deps)) {
161
+ let text = '';
162
+ try { text = await deps.readFile(file, 'utf8'); } catch { continue; }
163
+ if (!text.toLowerCase().includes(q)) continue; // fast skip whole file
164
+ const lines = text.split('\n');
165
+ const rel = relative(r.root, file).split(sep).join('/');
166
+ for (let i = 0; i < lines.length; i++) {
167
+ if (!lines[i].toLowerCase().includes(q)) continue;
168
+ if (hits.length >= MAX_HITS) { capped = true; break outer; }
169
+ hits.push(`[@webjsdev/${r.pkg}/${rel}:${i + 1}] ${lines[i].trim()}`);
170
+ }
171
+ }
172
+ }
173
+ if (!hits.length) return `No matches for "${query}" in the @webjsdev/* source.`;
174
+ if (capped) hits.push(`... (truncated at ${MAX_HITS} matches; narrow the query or read a file with \`path\`)`);
175
+ return hits.join('\n');
176
+ }
177
+
178
+ /** True when `p` is `base` itself or a descendant of it. */
179
+ function within(base, p) {
180
+ return p === base || p.startsWith(base + sep);
181
+ }
182
+
183
+ /**
184
+ * `path` mode: read one AUTHORED-source file. Accepts `<pkg>/...` or
185
+ * `@webjsdev/<pkg>/...`. Scoped to the package's SOURCE dir (`src/`, or `lib/`
186
+ * for cli), so it serves only the authored source and NOT the built `dist/`
187
+ * browser bundle, `node_modules`, etc. Refuses any path that escapes the source
188
+ * dir lexically (`..`/absolute), and (when `deps.realpath` is provided)
189
+ * re-checks the symlink-resolved path so a symlink inside `src/` cannot reach
190
+ * outside. Read-only.
191
+ *
192
+ * @param {{ roots: Array<{ pkg: string, root: string, src: string }>, readFile: Function, realpath?: Function }} deps
193
+ * @param {string} path
194
+ * @returns {Promise<string>}
195
+ */
196
+ export async function readSource(deps, path) {
197
+ const cleaned = String(path).replace(/^@webjsdev\//, '');
198
+ const segs = cleaned.split('/').filter(Boolean);
199
+ const pkg = segs[0];
200
+ const entry = deps.roots.find((r) => r.pkg === pkg);
201
+ if (!entry) {
202
+ return `Unknown or unresolvable package "${pkg || path}". Resolvable: ${deps.roots.map((r) => r.pkg).join(', ') || '(none)'}. Pass a path like server/src/ssr.js.`;
203
+ }
204
+ const abs = resolve(entry.root, segs.slice(1).join('/'));
205
+ const srcLabel = entry.src.split(sep).pop();
206
+ // Scope to the authored source dir, so dist/ (the built core browser bundle),
207
+ // package.json, node_modules, etc. are not readable; only `src/` (or cli `lib/`).
208
+ if (!within(entry.src, abs)) {
209
+ return `Refusing to read outside the @webjsdev/${pkg} authored source (only ${srcLabel}/ is exposed; the built dist/ is not).`;
210
+ }
211
+ // Defense in depth: a symlink inside the source dir must not resolve outside it.
212
+ if (deps.realpath) {
213
+ try {
214
+ if (!within(deps.realpath(entry.src), deps.realpath(abs))) {
215
+ return `Refusing to read outside the @webjsdev/${pkg} authored source (a symlink escapes ${srcLabel}/).`;
216
+ }
217
+ } catch { /* abs does not exist; the readFile below returns the not-a-file message */ }
218
+ }
219
+ // Legacy guard kept as a belt-and-suspenders against a root escape too.
220
+ if (abs !== entry.root && !abs.startsWith(entry.root + sep)) {
221
+ return `Refusing to read outside @webjsdev/${pkg} (path escapes the package root).`;
222
+ }
223
+ try {
224
+ return await deps.readFile(abs, 'utf8');
225
+ } catch {
226
+ return `Could not read ${path} (not a file under @webjsdev/${pkg}).`;
227
+ }
228
+ }
229
+
230
+ /**
231
+ * The `source` tool entry point. Dispatches on the args: `path` reads a file,
232
+ * `query` greps, otherwise (or with `package`) lists the packages. PURE given
233
+ * `deps` (`{ roots, readFile, readdir }`).
234
+ *
235
+ * @param {object} deps
236
+ * @param {{ query?: string, path?: string, package?: string }} [args]
237
+ * @returns {Promise<string>}
238
+ */
239
+ export async function runSourceTool(deps, args) {
240
+ const a = args || {};
241
+ if (a.path) return readSource(deps, a.path);
242
+ if (a.query) return grepSources(deps, a.query);
243
+ return listSources(deps, a.package);
244
+ }