rulebookguard 0.3.2 → 0.5.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/README.md CHANGED
@@ -13,7 +13,7 @@ It is language-agnostic: rules are Markdown, and tests in any language link to t
13
13
 
14
14
  ## Install
15
15
 
16
- Requires Node.js 22.23.4 or later (the `site` command builds with Astro 7, which needs it).
16
+ Requires Node.js 22 or later.
17
17
 
18
18
  ```sh
19
19
  npx rulebookguard lint
@@ -260,15 +260,35 @@ An [MCP](https://modelcontextprotocol.io) server over stdio for AI agents, with
260
260
 
261
261
  The rulebook is found from `--project` (default: the current directory). A command that would fail answers a tool error carrying the same message the CLI prints, for example `Unknown rule id "R-NOPE-001". Run "rulebookguard list" to list them.`
262
262
 
263
- ### `site [--out <dir>] [--base <path>]`
263
+ ### `site:generate [--out <dir>] [--base <path>]`
264
+
265
+ The site commands build with Astro, which lives in a separate package so that installing the linter does not download it. Install it next to `rulebookguard` (it requires Node.js 22.23.4 or later):
266
+
267
+ ```sh
268
+ npm install --save-dev rulebookguard @rulebookguard/site
269
+ ```
270
+
271
+ Without it, the site commands fail, exit 1, with `The site commands need the @rulebookguard/site package. Install it next to rulebookguard: npm install --save-dev @rulebookguard/site`. One-off, without installing: `npx -p rulebookguard -p @rulebookguard/site rulebookguard site:run`.
264
272
 
265
273
  Builds a read-only static site of the rulebook with [Astro Starlight](https://starlight.astro.build) into `--out` (default `site`, relative to the current directory) and prints that path. Open it from disk or publish it as is, for example on GitHub Pages; pass `--base` when it is served from a sub-path (`--base /rules`).
266
274
 
267
275
  ```sh
268
- $ rulebookguard site --out public/rules --base /rules
276
+ $ rulebookguard site:generate --out public/rules --base /rules
269
277
  public/rules
270
278
  ```
271
279
 
280
+ `site` is the same command.
281
+
282
+ ### `site:run [--out <dir>] [--base <path>] [--port <n>]`
283
+
284
+ Builds the site like `site:generate`, then serves `--out` on `127.0.0.1` (port 4321, or `--port`) and prints the URL, until Ctrl+C. Unknown paths get the site's 404 page. A taken port fails, exit 1, before building.
285
+
286
+ ```sh
287
+ $ rulebookguard site:run
288
+ site
289
+ Serving site at http://127.0.0.1:4321/ (Ctrl+C to stop)
290
+ ```
291
+
272
292
  The site has:
273
293
 
274
294
  - **Home:** the contexts map, with how many rules of each status every context holds.
package/dist/cli.mjs CHANGED
@@ -80,7 +80,7 @@ function render(path, rules, text) {
80
80
  return parts.join("\n\n");
81
81
  }
82
82
  // package.json
83
- var version = "0.3.2";
83
+ var version = "0.5.0";
84
84
  // src/mcp/server.ts
85
85
  function createRulesServer(projectDir) {
86
86
  const server = new McpServer({
@@ -329,12 +329,20 @@ Commands:
329
329
  and prints the rules anchored to the edited file
330
330
  mcp [--project <dir>] MCP server over stdio with rules_for and rules_show;
331
331
  --project: directory to find the rulebook from (default: cwd)
332
- site [--out <dir>] [--base <path>] build the read-only static site of the rulebook;
332
+ site:generate [--out <dir>] [--base <path>] build the read-only static site of the rulebook (alias: site);
333
333
  --out: output directory (default: site), --base: URL path
334
+ site:run [--out <dir>] [--base <path>] [--port <n>]
335
+ build the site, then serve it on 127.0.0.1 until Ctrl+C;
336
+ --port: port to serve on (default: 4321)
337
+ site commands need @rulebookguard/site installed
334
338
 
335
339
  Exit codes: 0 success, 1 failure (lint: any error), 2 usage error.
336
340
  `;
337
341
  const JSON_OPTION = { json: { type: "boolean" } };
342
+ const SITE_OPTIONS = {
343
+ out: { type: "string" },
344
+ base: { type: "string" }
345
+ };
338
346
  const COMMANDS = {
339
347
  contexts: {
340
348
  usage: "rulebookguard contexts [--json]",
@@ -378,9 +386,17 @@ const COMMANDS = {
378
386
  },
379
387
  site: {
380
388
  usage: "rulebookguard site [--out <dir>] [--base <path>]",
389
+ options: SITE_OPTIONS
390
+ },
391
+ "site:generate": {
392
+ usage: "rulebookguard site:generate [--out <dir>] [--base <path>]",
393
+ options: SITE_OPTIONS
394
+ },
395
+ "site:run": {
396
+ usage: "rulebookguard site:run [--out <dir>] [--base <path>] [--port <n>]",
381
397
  options: {
382
- out: { type: "string" },
383
- base: { type: "string" }
398
+ ...SITE_OPTIONS,
399
+ port: { type: "string" }
384
400
  }
385
401
  },
386
402
  lint: {
@@ -424,11 +440,13 @@ async function dispatch(argv, io) {
424
440
  await mcpCommand(io, values.project);
425
441
  return 0;
426
442
  }
427
- if (name === "site") {
428
- const { siteCommand } = await import("./site-C2qLRXNK.mjs");
443
+ if (name === "site" || name === "site:generate" || name === "site:run") {
444
+ const { siteCommand } = await import("./site-BiiOPVEe.mjs");
429
445
  await siteCommand(io, {
430
446
  out: values.out,
431
- base: values.base
447
+ base: values.base,
448
+ serve: name === "site:run",
449
+ port: sitePort(values.port, spec.usage)
432
450
  });
433
451
  return 0;
434
452
  }
@@ -461,6 +479,12 @@ async function dispatch(argv, io) {
461
479
  }
462
480
  return 0;
463
481
  }
482
+ function sitePort(value, usage) {
483
+ if (value === void 0) return;
484
+ const port = Number(value);
485
+ if (!/^\d+$/.test(value) || port > 65535) throw new CliError(`--port must be a number from 0 to 65535.\nUsage: ${usage}`, 2);
486
+ return port;
487
+ }
464
488
  function parse(spec, args) {
465
489
  let parsed;
466
490
  try {
@@ -1,11 +1,9 @@
1
1
  import { a as CliError, c as openCatalog, d as scanTestTags, g as expandGlobs, n as lintCatalog, s as gitFallbackWarning, u as writeLines, y as runGit } from "./lint-C1yUeLOq.mjs";
2
- import { cp, mkdir, readdir, rm, writeFile } from "node:fs/promises";
3
- import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
4
- import { execFile } from "node:child_process";
5
- import { promisify } from "node:util";
6
- import { randomUUID } from "node:crypto";
7
- import { existsSync, readFileSync } from "node:fs";
8
- import { fileURLToPath } from "node:url";
2
+ import { createRequire } from "node:module";
3
+ import { readFile, readdir, stat, writeFile } from "node:fs/promises";
4
+ import { dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
+ import { createServer } from "node:http";
6
+ import { fileURLToPath, pathToFileURL } from "node:url";
9
7
  // src/site/data.ts
10
8
  const FIELD = "";
11
9
  async function siteData(catalog, onGitFallback) {
@@ -395,17 +393,53 @@ function demote(markdown) {
395
393
  }).join("\n");
396
394
  }
397
395
  // src/commands/site.ts
398
- const execFileAsync = promisify(execFile);
396
+ const SITE_PACKAGE = "@rulebookguard/site";
399
397
  /** Marks an output directory as written by `site`, so a rebuild may replace it. */
400
398
  const MARKER = ".rulebookguard-site";
401
- async function siteCommand(io, options) {
399
+ /**
400
+ * Builds the site into `--out`; with `serve`, also serves it on 127.0.0.1 until `until` settles (Ctrl+C by default).
401
+ */
402
+ async function siteCommand(io, options, until = untilInterrupted()) {
402
403
  const catalog = await openCatalog(io);
403
404
  const out = resolve(io.cwd, options.out ?? "site");
404
405
  const fromCwd = relative(io.cwd, out);
405
406
  const shown = fromCwd === "" ? "." : fromCwd.startsWith("..") || isAbsolute(fromCwd) ? out : fromCwd;
406
407
  await assertReplaceable(out, shown);
408
+ const site = await importSitePackage([catalog.root, dirname(fileURLToPath(import.meta.url))]);
407
409
  const language = catalog.config.language ?? "en";
408
410
  const base = normalizeBase(options.base ?? "/");
411
+ const server = options.serve ? await listen(out, base, options.port ?? 4321) : void 0;
412
+ try {
413
+ await buildSite(site, catalog, out, language, base, io);
414
+ } catch (error) {
415
+ server?.close();
416
+ throw error;
417
+ }
418
+ writeLines(io, [shown]);
419
+ if (server === void 0) return;
420
+ const { port } = server.address();
421
+ writeLines(io, [`Serving ${shown} at http://127.0.0.1:${port}${base} (Ctrl+C to stop)`]);
422
+ await until;
423
+ await new Promise((closed) => {
424
+ server.close(() => closed());
425
+ server.closeAllConnections();
426
+ });
427
+ }
428
+ /** Imports the site builder from the first of `from` that resolves it. */
429
+ async function importSitePackage(from) {
430
+ for (const directory of from) {
431
+ let entry;
432
+ try {
433
+ entry = createRequire(join(directory, "package.json")).resolve(SITE_PACKAGE);
434
+ } catch (error) {
435
+ if (error.code === "MODULE_NOT_FOUND") continue;
436
+ throw error;
437
+ }
438
+ return await import(pathToFileURL(entry).href);
439
+ }
440
+ throw new CliError(`The site commands need the ${SITE_PACKAGE} package. Install it next to rulebookguard: npm install --save-dev ${SITE_PACKAGE}`);
441
+ }
442
+ async function buildSite(site, catalog, out, language, base, io) {
409
443
  const files = sitePages(await siteData(catalog, gitFallbackWarning(io)), {
410
444
  language,
411
445
  base
@@ -426,45 +460,85 @@ async function siteCommand(io, options) {
426
460
  }))
427
461
  ];
428
462
  files.set("astro.config.mjs", astroConfig(text.title, language, base, sidebar));
429
- const work = join(siteWorkRoot(), randomUUID());
430
463
  try {
431
- for (const [path, content] of files) {
432
- await mkdir(dirname(join(work, path)), { recursive: true });
433
- await writeFile(join(work, path), content);
434
- }
435
- await buildWithAstro(work);
436
- await rm(out, {
437
- recursive: true,
438
- force: true
464
+ await site.buildSite(files, out);
465
+ } catch (error) {
466
+ throw error instanceof site.SiteBuildError ? new CliError(error.message) : error;
467
+ }
468
+ await writeFile(join(out, MARKER), "");
469
+ }
470
+ function untilInterrupted() {
471
+ return new Promise((stop) => {
472
+ process.once("SIGINT", () => stop());
473
+ process.once("SIGTERM", () => stop());
474
+ });
475
+ }
476
+ const CONTENT_TYPES = {
477
+ ".html": "text/html; charset=utf-8",
478
+ ".css": "text/css; charset=utf-8",
479
+ ".js": "text/javascript; charset=utf-8",
480
+ ".mjs": "text/javascript; charset=utf-8",
481
+ ".json": "application/json; charset=utf-8",
482
+ ".txt": "text/plain; charset=utf-8",
483
+ ".xml": "application/xml; charset=utf-8",
484
+ ".svg": "image/svg+xml",
485
+ ".png": "image/png",
486
+ ".jpg": "image/jpeg",
487
+ ".ico": "image/x-icon",
488
+ ".webp": "image/webp",
489
+ ".woff2": "font/woff2",
490
+ ".wasm": "application/wasm"
491
+ };
492
+ function listen(out, base, port) {
493
+ const server = createServer((request, response) => {
494
+ serveFile(out, base, request, response).catch(() => {
495
+ response.writeHead(500).end();
439
496
  });
440
- await cp(join(work, "dist"), out, { recursive: true });
441
- await writeFile(join(out, MARKER), "");
442
- } finally {
443
- await rm(work, {
444
- recursive: true,
445
- force: true
497
+ });
498
+ return new Promise((ready, fail) => {
499
+ server.once("error", (error) => {
500
+ fail(error.code === "EADDRINUSE" ? new CliError(`Port ${port} is already in use; pass another --port.`) : error);
446
501
  });
447
- }
448
- writeLines(io, [shown]);
502
+ server.listen(port, "127.0.0.1", () => ready(server));
503
+ });
449
504
  }
450
- /**
451
- * Starlight injects its routes as package specifiers that Astro resolves from the project root, so the generated
452
- * project must live inside the node_modules directory that holds rulebookguard's own dependencies.
453
- */
454
- function siteWorkRoot() {
455
- let directory = dirname(fileURLToPath(import.meta.url));
456
- while (!isOwnPackageRoot(directory)) {
457
- const parent = dirname(directory);
458
- if (parent === directory) throw new Error("rulebookguard package root not found");
459
- directory = parent;
505
+ async function serveFile(out, base, request, response) {
506
+ const path = decodeURIComponent(new URL(request.url ?? "/", "http://localhost").pathname);
507
+ if (`${path}/` === base) {
508
+ response.writeHead(301, { location: base }).end();
509
+ return;
510
+ }
511
+ if (!path.startsWith(base)) {
512
+ if (path === "/") {
513
+ response.writeHead(301, { location: base }).end();
514
+ return;
515
+ }
516
+ await sendNotFound(out, response);
517
+ return;
518
+ }
519
+ let file = resolve(out, `.${sep}${path.slice(base.length)}`);
520
+ if (file !== out && !file.startsWith(`${out}${sep}`)) {
521
+ await sendNotFound(out, response);
522
+ return;
460
523
  }
461
- const parent = dirname(directory);
462
- const modules = basename(parent) === "node_modules" ? parent : join(directory, "node_modules");
463
- return join(modules, ".rulebookguard-site");
524
+ if ((await stat(file).catch(() => void 0))?.isDirectory()) {
525
+ if (!path.endsWith("/")) {
526
+ response.writeHead(301, { location: `${path}/` }).end();
527
+ return;
528
+ }
529
+ file = join(file, "index.html");
530
+ }
531
+ const body = await readFile(file).catch(() => void 0);
532
+ if (body === void 0) {
533
+ await sendNotFound(out, response);
534
+ return;
535
+ }
536
+ response.writeHead(200, { "content-type": CONTENT_TYPES[extname(file)] ?? "application/octet-stream" });
537
+ response.end(body);
464
538
  }
465
- function isOwnPackageRoot(directory) {
466
- const file = join(directory, "package.json");
467
- return existsSync(file) && JSON.parse(readFileSync(file, "utf8")).name === "rulebookguard";
539
+ async function sendNotFound(out, response) {
540
+ const page = await readFile(join(out, "404.html")).catch(() => "Not found");
541
+ response.writeHead(404, { "content-type": CONTENT_TYPES[".html"] }).end(page);
468
542
  }
469
543
  async function assertReplaceable(out, shown) {
470
544
  let entries;
@@ -476,31 +550,6 @@ async function assertReplaceable(out, shown) {
476
550
  }
477
551
  if (entries.length > 0 && !entries.includes(MARKER)) throw new CliError(`${shown} is not empty and was not written by "rulebookguard site"; choose another --out or empty it first.`);
478
552
  }
479
- async function buildWithAstro(work) {
480
- const astro = join(dirname(siteWorkRoot()), "astro");
481
- const { bin } = JSON.parse(readFileSync(join(astro, "package.json"), "utf8"));
482
- try {
483
- await execFileAsync(process.execPath, [
484
- join(astro, bin.astro),
485
- "build",
486
- "--silent"
487
- ], {
488
- cwd: work,
489
- env: {
490
- ...process.env,
491
- ASTRO_TELEMETRY_DISABLED: "1"
492
- },
493
- maxBuffer: 67108864
494
- });
495
- } catch (error) {
496
- const { stderr, stdout } = error;
497
- throw new CliError(`The site build failed:\n${astroFailureDetail(`${stderr ?? ""}${stdout ?? ""}`)}`);
498
- }
499
- }
500
- /** The first lines of Astro's error output, without its stack trace. */
501
- function astroFailureDetail(output) {
502
- return output.split("\n").filter((line) => !/^\s*at /.test(line) && !/^\s*Stack trace:/.test(line)).filter((line) => line.trim() !== "").slice(0, 12).join("\n");
503
- }
504
553
  function astroConfig(title, language, base, sidebar) {
505
554
  return `import starlight from '@astrojs/starlight';
506
555
  import { defineConfig } from 'astro/config';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rulebookguard",
3
- "version": "0.3.2",
3
+ "version": "0.5.0",
4
4
  "description": "Keep business rules as the single source of truth, bound to code and tests.",
5
5
  "homepage": "https://github.com/JuanCJR/rulebookguard#readme",
6
6
  "bugs": {
@@ -28,23 +28,29 @@
28
28
  "./package.json": "./package.json"
29
29
  },
30
30
  "dependencies": {
31
- "@astrojs/markdown-remark": "^7.3.2",
32
- "@astrojs/starlight": "^0.42.6",
33
31
  "@modelcontextprotocol/server": "^2.3.1",
34
32
  "ajv": "^8.20.0",
35
- "astro": "^7.3.8",
36
33
  "picomatch": "^4.0.7",
37
34
  "yaml": "^2.9.1",
38
35
  "zod": "^4.6.7"
39
36
  },
40
37
  "devDependencies": {
41
38
  "@modelcontextprotocol/client": "2.3.1",
39
+ "@rulebookguard/site": "0.5.0",
42
40
  "@types/node": "^20.19.43",
43
41
  "@types/picomatch": "^4.0.3",
44
42
  "typescript": "^7.0.2",
45
43
  "vite-plus": "^1.4.0"
46
44
  },
45
+ "peerDependencies": {
46
+ "@rulebookguard/site": "^0.5.0"
47
+ },
48
+ "peerDependenciesMeta": {
49
+ "@rulebookguard/site": {
50
+ "optional": true
51
+ }
52
+ },
47
53
  "engines": {
48
- "node": ">=22.23.4"
54
+ "node": ">=22"
49
55
  }
50
56
  }