@monoes/monodesign 1.2.4 → 1.2.6

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/tokens.js ADDED
@@ -0,0 +1,91 @@
1
+ /** Design tokens as typed JS constants — mirrors tokens.css */
2
+ export const fontFamily = {
3
+ display: "'Cormorant Garamond', Georgia, serif",
4
+ body: "'Instrument Sans', system-ui, sans-serif",
5
+ mono: "'Space Grotesk', monospace",
6
+ };
7
+ export const fontSize = {
8
+ xs: '0.75rem',
9
+ sm: '0.875rem',
10
+ base: '1rem',
11
+ lg: '1.125rem',
12
+ xl: '1.25rem',
13
+ '2xl': '1.5rem',
14
+ '3xl': '1.875rem',
15
+ '4xl': '2.25rem',
16
+ '5xl': '3rem',
17
+ '6xl': '3.75rem',
18
+ '7xl': '4.5rem',
19
+ };
20
+ export const spacing = {
21
+ 1: '4px',
22
+ 2: '8px',
23
+ 3: '12px',
24
+ 4: '16px',
25
+ 5: '20px',
26
+ 6: '24px',
27
+ 7: '28px',
28
+ 8: '32px',
29
+ 10: '40px',
30
+ 12: '48px',
31
+ 16: '64px',
32
+ 20: '80px',
33
+ 24: '96px',
34
+ 30: '120px',
35
+ };
36
+ export const easing = {
37
+ out: 'cubic-bezier(0.16, 1, 0.3, 1)',
38
+ outQuint: 'cubic-bezier(0.22, 1, 0.36, 1)',
39
+ inOut: 'cubic-bezier(0.65, 0, 0.35, 1)',
40
+ in: 'cubic-bezier(0.4, 0, 1, 1)',
41
+ };
42
+ export const duration = {
43
+ instant: '0s',
44
+ fast: '0.15s',
45
+ base: '0.3s',
46
+ slow: '0.6s',
47
+ slower: '0.8s',
48
+ slowest: '1.2s',
49
+ };
50
+ /** OKLCH color palette */
51
+ export const color = {
52
+ ink: 'oklch(10% 0 0)',
53
+ charcoal: 'oklch(25% 0 0)',
54
+ ash: 'oklch(55% 0 0)',
55
+ mist: 'oklch(92% 0 0)',
56
+ cream: 'oklch(96% 0.005 350)',
57
+ paper: 'oklch(98% 0 0)',
58
+ white: 'oklch(99.5% 0 0)',
59
+ accent: 'oklch(60% 0.25 350)',
60
+ accentHover: 'oklch(52% 0.25 350)',
61
+ accentDim: 'oklch(60% 0.25 350 / 0.15)',
62
+ accentSoft: 'oklch(60% 0.25 350 / 0.25)',
63
+ accentText: 'oklch(45% 0.22 350)',
64
+ };
65
+ export const accentScale = {
66
+ 1: 'oklch(22% 0.15 350)',
67
+ 2: 'oklch(33% 0.20 350)',
68
+ 3: 'oklch(42% 0.23 350)',
69
+ 4: 'oklch(52% 0.25 350)',
70
+ 5: 'oklch(60% 0.25 350)',
71
+ 6: 'oklch(68% 0.22 350)',
72
+ 7: 'oklch(76% 0.18 350)',
73
+ 8: 'oklch(84% 0.12 350)',
74
+ 9: 'oklch(94% 0.05 350)',
75
+ };
76
+ export const shadow = {
77
+ none: 'none',
78
+ sm: '0 1px 3px rgba(0, 0, 0, 0.06)',
79
+ md: '0 4px 24px -4px rgba(0, 0, 0, 0.12), 0 1px 3px rgba(0, 0, 0, 0.06)',
80
+ lg: '0 20px 40px rgba(0, 0, 0, 0.08)',
81
+ accent: '0 20px 60px oklch(60% 0.25 350 / 0.15)',
82
+ };
83
+ export const radius = {
84
+ none: '0',
85
+ sm: '4px',
86
+ md: '8px',
87
+ lg: '12px',
88
+ xl: '16px',
89
+ full: '9999px',
90
+ };
91
+ //# sourceMappingURL=tokens.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tokens.js","sourceRoot":"","sources":["../src/tokens.ts"],"names":[],"mappings":"AAAA,+DAA+D;AAE/D,MAAM,CAAC,MAAM,UAAU,GAAG;IACxB,OAAO,EAAE,sCAAsC;IAC/C,IAAI,EAAE,0CAA0C;IAChD,IAAI,EAAE,4BAA4B;CAC1B,CAAC;AAEX,MAAM,CAAC,MAAM,QAAQ,GAAG;IACtB,EAAE,EAAE,SAAS;IACb,EAAE,EAAE,UAAU;IACd,IAAI,EAAE,MAAM;IACZ,EAAE,EAAE,UAAU;IACd,EAAE,EAAE,SAAS;IACb,KAAK,EAAE,QAAQ;IACf,KAAK,EAAE,UAAU;IACjB,KAAK,EAAE,SAAS;IAChB,KAAK,EAAE,MAAM;IACb,KAAK,EAAE,SAAS;IAChB,KAAK,EAAE,QAAQ;CACP,CAAC;AAEX,MAAM,CAAC,MAAM,OAAO,GAAG;IACrB,CAAC,EAAE,KAAK;IACR,CAAC,EAAE,KAAK;IACR,CAAC,EAAE,MAAM;IACT,CAAC,EAAE,MAAM;IACT,CAAC,EAAE,MAAM;IACT,CAAC,EAAE,MAAM;IACT,CAAC,EAAE,MAAM;IACT,CAAC,EAAE,MAAM;IACT,EAAE,EAAE,MAAM;IACV,EAAE,EAAE,MAAM;IACV,EAAE,EAAE,MAAM;IACV,EAAE,EAAE,MAAM;IACV,EAAE,EAAE,MAAM;IACV,EAAE,EAAE,OAAO;CACH,CAAC;AAEX,MAAM,CAAC,MAAM,MAAM,GAAG;IACpB,GAAG,EAAE,+BAA+B;IACpC,QAAQ,EAAE,gCAAgC;IAC1C,KAAK,EAAE,gCAAgC;IACvC,EAAE,EAAE,4BAA4B;CACxB,CAAC;AAEX,MAAM,CAAC,MAAM,QAAQ,GAAG;IACtB,OAAO,EAAE,IAAI;IACb,IAAI,EAAE,OAAO;IACb,IAAI,EAAE,MAAM;IACZ,IAAI,EAAE,MAAM;IACZ,MAAM,EAAE,MAAM;IACd,OAAO,EAAE,MAAM;CACP,CAAC;AAEX,0BAA0B;AAC1B,MAAM,CAAC,MAAM,KAAK,GAAG;IACnB,GAAG,EAAE,gBAAgB;IACrB,QAAQ,EAAE,gBAAgB;IAC1B,GAAG,EAAE,gBAAgB;IACrB,IAAI,EAAE,gBAAgB;IACtB,KAAK,EAAE,sBAAsB;IAC7B,KAAK,EAAE,gBAAgB;IACvB,KAAK,EAAE,kBAAkB;IACzB,MAAM,EAAE,qBAAqB;IAC7B,WAAW,EAAE,qBAAqB;IAClC,SAAS,EAAE,4BAA4B;IACvC,UAAU,EAAE,4BAA4B;IACxC,UAAU,EAAE,qBAAqB;CACzB,CAAC;AAEX,MAAM,CAAC,MAAM,WAAW,GAAG;IACzB,CAAC,EAAE,qBAAqB;IACxB,CAAC,EAAE,qBAAqB;IACxB,CAAC,EAAE,qBAAqB;IACxB,CAAC,EAAE,qBAAqB;IACxB,CAAC,EAAE,qBAAqB;IACxB,CAAC,EAAE,qBAAqB;IACxB,CAAC,EAAE,qBAAqB;IACxB,CAAC,EAAE,qBAAqB;IACxB,CAAC,EAAE,qBAAqB;CAChB,CAAC;AAEX,MAAM,CAAC,MAAM,MAAM,GAAG;IACpB,IAAI,EAAE,MAAM;IACZ,EAAE,EAAE,+BAA+B;IACnC,EAAE,EAAE,oEAAoE;IACxE,EAAE,EAAE,iCAAiC;IACrC,MAAM,EAAE,wCAAwC;CACxC,CAAC;AAEX,MAAM,CAAC,MAAM,MAAM,GAAG;IACpB,IAAI,EAAE,GAAG;IACT,EAAE,EAAE,KAAK;IACT,EAAE,EAAE,KAAK;IACT,EAAE,EAAE,MAAM;IACV,EAAE,EAAE,MAAM;IACV,IAAI,EAAE,QAAQ;CACN,CAAC"}
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@monoes/monodesign",
3
- "version": "1.2.4",
3
+ "version": "1.2.6",
4
4
  "description": "Frontend design intelligence for AI agents — design tokens, antipattern detection, and the monodesign skill",
5
5
  "type": "module",
6
- "main": "./src/index.ts",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
7
8
  "exports": {
8
- ".": "./src/index.ts",
9
+ ".": "./dist/index.js",
9
10
  "./tokens": "./src/tokens.css",
10
11
  "./engine": "./cli/engine/detect-antipatterns.mjs",
11
12
  "./engine/browser": "./cli/engine/detect-antipatterns-browser.js"
@@ -15,12 +16,15 @@
15
16
  },
16
17
  "files": [
17
18
  "cli/",
19
+ "dist/",
18
20
  "skill/",
19
- "src/",
21
+ "src/tokens.css",
20
22
  "NOTICE.md"
21
23
  ],
22
24
  "scripts": {
25
+ "prebuild": "node ../../../scripts/build-fs.mjs clean dist tsconfig.tsbuildinfo",
23
26
  "build": "tsc",
27
+ "prepublishOnly": "npm run build",
24
28
  "test": "node scripts/run-tests.mjs",
25
29
  "sync": "node scripts/sync-skill.mjs"
26
30
  },
@@ -44,7 +48,7 @@
44
48
  "marked": "^18.0.0"
45
49
  },
46
50
  "optionalDependencies": {
47
- "@monoes/monobrowse": "^1.0.6",
51
+ "@monoes/monobrowse": "^1.0.7",
48
52
  "puppeteer": "^25.1.0"
49
53
  }
50
54
  }
@@ -10,7 +10,7 @@ Declare server-side template extensions under **`detector.extensions`** when the
10
10
 
11
11
  Manual `monomind design detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `monomind design detect --no-config ...` for a raw detector run that ignores project config/context. Use `monomind design ignores ...` for direct CLI CRUD on the same detector ignores.
12
12
 
13
- Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), Cursor (`.cursor/hooks.json` in the project), and GitHub Copilot (`.github/hooks/monodesign.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks/monodesign.json` is committed to the repository's default branch.
13
+ Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/config.toml` in the project), Cursor (`.cursor/hooks.json` in the project), and GitHub Copilot (`.github/hooks/monodesign.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks/monodesign.json` is committed to the repository's default branch.
14
14
 
15
15
  On **Cursor**, `preToolUse` checks proposed Write/Edit/Shell write content and denies only when the real detector finds an issue. The denial message is visible to the agent as the tool error, so the agent can reconsider before the bad write lands.
16
16
 
@@ -84,7 +84,7 @@ node {{scripts_path}}/hook-admin.mjs ignore-file "src/legacy/Card.tsx"
84
84
  - Never modify `.monodesign/config.json` or `.monodesign/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent. One exception: `detector.extensions` has no admin action, so when the user asks to cover a template stack, edit that one field in `.monodesign/config.json` directly and leave the rest of the file untouched.
85
85
  - Do not edit the hook scripts themselves (`hook.mjs`, `hook-lib.mjs`, `hook-before-edit.mjs`) from this flow. Those are skill plumbing.
86
86
  - Cursor can block a proposed write when the detector finds a real issue. Claude Code, Codex, and GitHub Copilot do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders.
87
- - The hook is bundled with the Monodesign skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, `.cursor/hooks.json`, and `.github/hooks/monodesign.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks. On GitHub Copilot, the CLI loads `.github/hooks/monodesign.json` once it is committed to the repository's default branch, and the cloud agent reads it from the repo directly.
87
+ - The hook is bundled with the Monodesign skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/config.toml`, `.cursor/hooks.json`, and `.github/hooks/monodesign.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks. On GitHub Copilot, the CLI loads `.github/hooks/monodesign.json` once it is committed to the repository's default branch, and the cloud agent reads it from the repo directly.
88
88
 
89
89
  ## Failure modes
90
90
 
@@ -74,24 +74,20 @@ const HOOK_MANIFEST_TARGETS = [
74
74
  {
75
75
  provider: '.agents',
76
76
  skillRel: '.agents/skills/monodesign',
77
- destRel: '.codex/hooks.json',
78
- manifest: () => ({
79
- hooks: {
80
- PostToolUse: [
81
- {
82
- matcher: 'Edit|Write|apply_patch',
83
- hooks: [
84
- {
85
- type: 'command',
86
- command: 'node ".agents/skills/monodesign/scripts/hook.mjs"',
87
- timeout: TIMEOUT_SECONDS,
88
- statusMessage: STATUS_MESSAGE,
89
- },
90
- ],
91
- },
92
- ],
93
- },
94
- }),
77
+ destRel: '.codex/config.toml',
78
+ format: 'toml',
79
+ manifest: () => [
80
+ '# monodesign:start native-hook',
81
+ '[[hooks.PostToolUse]]',
82
+ 'matcher = "Edit|Write|apply_patch"',
83
+ '[[hooks.PostToolUse.hooks]]',
84
+ 'type = "command"',
85
+ 'command = "node .agents/skills/monodesign/scripts/hook.mjs"',
86
+ `timeout = ${TIMEOUT_SECONDS}`,
87
+ `statusMessage = "${STATUS_MESSAGE}"`,
88
+ '# monodesign:end native-hook',
89
+ '',
90
+ ].join('\n'),
95
91
  },
96
92
  {
97
93
  provider: '.cursor',
@@ -335,6 +331,19 @@ function repairHookManifests(cwd) {
335
331
  const dest = path.join(cwd, target.destRel);
336
332
  const sharedDest = target.sharedDestRel ? path.join(cwd, target.sharedDestRel) : null;
337
333
 
334
+ if (target.format === 'toml') {
335
+ const current = fs.existsSync(dest) ? safeReadText(dest) : '';
336
+ const next = mergeCodexHookConfig(current || '', target.manifest());
337
+ if (current === next) {
338
+ result.already.push(target.provider);
339
+ continue;
340
+ }
341
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
342
+ fs.writeFileSync(dest, next);
343
+ result.written.push(target.provider);
344
+ continue;
345
+ }
346
+
338
347
  if (sharedDest && fileHasMonodesignHookMarker(sharedDest)) {
339
348
  pruneMonodesignHookFromManifest(dest);
340
349
  result.already.push(target.provider);
@@ -366,6 +375,30 @@ function repairHookManifests(cwd) {
366
375
  return result;
367
376
  }
368
377
 
378
+ function mergeCodexHookConfig(existing, hookBlock) {
379
+ const completeBlock = /# monodesign:start native-hook[\s\S]*?# monodesign:end native-hook\n?/m;
380
+ const incompleteBlock = /# monodesign:start native-hook[\s\S]*$/m;
381
+ let merged = existing;
382
+ if (completeBlock.test(merged)) merged = merged.replace(completeBlock, hookBlock);
383
+ else if (incompleteBlock.test(merged)) merged = merged.replace(incompleteBlock, hookBlock);
384
+ else merged = `${merged.trimEnd()}${merged.trim() ? '\n\n' : ''}${hookBlock}`;
385
+ return enableCodexHooks(merged);
386
+ }
387
+
388
+ function enableCodexHooks(config) {
389
+ if (!/^\[features\]\s*$/m.test(config)) {
390
+ return `[features]\nhooks = true\n\n${config}`;
391
+ }
392
+ const lines = config.split(/\r?\n/);
393
+ const start = lines.findIndex((line) => /^\[features\]\s*$/.test(line));
394
+ let end = start + 1;
395
+ while (end < lines.length && !/^\[\[?[^\]]+\]\]?\s*$/.test(lines[end])) end++;
396
+ const hookIndex = lines.slice(start + 1, end).findIndex((line) => /^hooks\s*=/.test(line));
397
+ if (hookIndex === -1) lines.splice(end, 0, 'hooks = true');
398
+ else lines[start + 1 + hookIndex] = 'hooks = true';
399
+ return lines.join('\n');
400
+ }
401
+
369
402
  function safeReadText(filePath) {
370
403
  try {
371
404
  return fs.readFileSync(filePath, 'utf-8');
@@ -36,14 +36,17 @@ export function detectSvelteKitProject(cwd = process.cwd(), config = null) {
36
36
  };
37
37
  }
38
38
 
39
- export function applySvelteKitLiveAdapter({ cwd = process.cwd(), port, config = null } = {}) {
39
+ export function applySvelteKitLiveAdapter({ cwd = process.cwd(), port, authCred, config = null } = {}) {
40
40
  if (!Number.isFinite(Number(port))) {
41
41
  throw new Error('SvelteKit live adapter requires a numeric port');
42
42
  }
43
+ if (!authCred) {
44
+ throw new Error('SvelteKit live adapter requires an auth credential');
45
+ }
43
46
  const detected = detectSvelteKitProject(cwd, config);
44
47
  if (!detected) return null;
45
48
 
46
- ensureSvelteLiveRootComponent(cwd, Number(port));
49
+ ensureSvelteLiveRootComponent(cwd, Number(port), authCred);
47
50
 
48
51
  const layoutRel = detected.layoutFile;
49
52
  const layoutAbs = path.join(cwd, layoutRel);
@@ -136,18 +139,20 @@ export function unpatchSvelteLayout(content) {
136
139
  return out.replace(/\n{3,}/g, '\n\n');
137
140
  }
138
141
 
139
- export function ensureSvelteLiveRootComponent(cwd, port) {
142
+ export function ensureSvelteLiveRootComponent(cwd, port, authCred) {
140
143
  const file = path.join(cwd, SVELTE_LIVE_ROOT_COMPONENT);
141
144
  fs.mkdirSync(path.dirname(file), { recursive: true });
142
- fs.writeFileSync(file, buildSvelteLiveRootComponent(port), 'utf-8');
145
+ fs.writeFileSync(file, buildSvelteLiveRootComponent(port, authCred), 'utf-8');
143
146
  return file;
144
147
  }
145
148
 
146
- export function buildSvelteLiveRootComponent(port) {
149
+ export function buildSvelteLiveRootComponent(port, authCred) {
150
+ const authQuery = new URLSearchParams();
151
+ authQuery.set('token', String(authCred ?? ''));
147
152
  return `<script>
148
153
  import { onMount } from 'svelte';
149
154
 
150
- const LIVE_URL = 'http://localhost:${Number(port)}/live.js';
155
+ const LIVE_URL = 'http://localhost:${Number(port)}/live.js?${authQuery.toString()}';
151
156
  const HOST_ID = 'monodesign-live-root';
152
157
 
153
158
  onMount(() => {
@@ -16,7 +16,7 @@
16
16
  import fs from 'node:fs';
17
17
  import path from 'node:path';
18
18
  import { fileURLToPath } from 'node:url';
19
- import { resolveLiveConfigPath } from './lib/monodesign-paths.mjs';
19
+ import { resolveLiveConfigPath, readLiveServerInfo } from './lib/monodesign-paths.mjs';
20
20
  import {
21
21
  applySvelteKitLiveAdapter,
22
22
  detectSvelteKitProject,
@@ -27,6 +27,8 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
27
27
  const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
28
28
  const MARKER_OPEN_TEXT = 'monodesign-live-start';
29
29
  const MARKER_CLOSE_TEXT = 'monodesign-live-end';
30
+ // Query param name live-server.mjs checks on every protected route.
31
+ const AUTH_QUERY_KEY = 'token';
30
32
  const IGNORE_MARKER_OPEN = '# monodesign-live-ignore-start';
31
33
  const IGNORE_MARKER_CLOSE = '# monodesign-live-ignore-end';
32
34
 
@@ -145,10 +147,19 @@ Output (JSON):
145
147
  console.error(JSON.stringify({ ok: false, error: 'missing_port' }));
146
148
  process.exit(1);
147
149
  }
150
+ // The injected tag must authenticate like every other server route does —
151
+ // read the per-process credential live-server.mjs persisted at startup.
152
+ const liveInfo = readLiveServerInfo(process.cwd());
153
+ const credentialFields = liveInfo?.info ?? {};
154
+ const authCred = credentialFields['token'];
155
+ if (!authCred) {
156
+ console.error(JSON.stringify({ ok: false, error: 'missing_auth_credential', detail: 'no running live server info found for this project — start it before injecting' }));
157
+ process.exit(1);
158
+ }
148
159
  const gitIgnore = ensureLiveGitIgnores(process.cwd());
149
160
 
150
161
  if (svelteKit) {
151
- const adapterResult = applySvelteKitLiveAdapter({ cwd: process.cwd(), port, config });
162
+ const adapterResult = applySvelteKitLiveAdapter({ cwd: process.cwd(), port, authCred, config });
152
163
  console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] }));
153
164
  return;
154
165
  }
@@ -158,7 +169,7 @@ Output (JSON):
158
169
  if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
159
170
  const content = fs.readFileSync(absFile, 'utf-8');
160
171
  const withoutOld = revertCspMeta(removeTag(content, config.commentSyntax));
161
- const withTag = insertTag(withoutOld, config, port, relFile);
172
+ const withTag = insertTag(withoutOld, config, port, authCred, relFile);
162
173
  if (withTag === withoutOld) {
163
174
  return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter };
164
175
  }
@@ -356,16 +367,18 @@ function validateConfig(cfg) {
356
367
  function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : '<!--'; }
357
368
  function commentClose(syntax) { return syntax === 'jsx' ? '*/}' : '-->'; }
358
369
 
359
- function buildTagBlock(syntax, port, filePath) {
370
+ function buildTagBlock(syntax, port, authCred, filePath) {
360
371
  const open = commentOpen(syntax);
361
372
  const close = commentClose(syntax);
362
373
  // Astro processes <script> tags by default and rewrites src to its own
363
374
  // bundled URL. is:inline opts out so the literal external src survives.
364
375
  const isAstro = typeof filePath === 'string' && filePath.endsWith('.astro');
365
376
  const scriptAttrs = isAstro ? 'is:inline ' : '';
377
+ const authQuery = new URLSearchParams();
378
+ authQuery.set(AUTH_QUERY_KEY, authCred);
366
379
  return (
367
380
  open + ' ' + MARKER_OPEN_TEXT + ' ' + close + '\n' +
368
- '<script ' + scriptAttrs + 'src="http://localhost:' + port + '/live.js"></script>\n' +
381
+ '<script ' + scriptAttrs + 'src="http://localhost:' + port + '/live.js?' + authQuery.toString() + '"></script>\n' +
369
382
  open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n'
370
383
  );
371
384
  }
@@ -387,9 +400,9 @@ function readLineEndingAt(content, index) {
387
400
  return '';
388
401
  }
389
402
 
390
- function insertTag(content, config, port, filePath) {
403
+ function insertTag(content, config, port, authCred, filePath) {
391
404
  const lineEnding = detectLineEnding(content);
392
- const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath), lineEnding);
405
+ const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, authCred, filePath), lineEnding);
393
406
  // insertBefore: match the LAST occurrence. Anchors like `</body>` naturally
394
407
  // belong at the end, and the same literal can appear earlier in code blocks
395
408
  // within rendered documentation pages.
@@ -63,6 +63,8 @@ const DESIGN_MD_PATH = PROJECT_CONTEXT.designPath
63
63
  : null;
64
64
  const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway
65
65
  const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s
66
+ // Query param name every protected route checks against the per-process credential.
67
+ const AUTH_QUERY_KEY = 'token';
66
68
 
67
69
  // ---------------------------------------------------------------------------
68
70
  // Port detection
@@ -383,6 +385,20 @@ function statOrNull(filePath) {
383
385
  try { return fs.statSync(filePath); } catch { return null; }
384
386
  }
385
387
 
388
+ // Shared gate for every protected route, including /live.js (see its call
389
+ // site for why that one needs it too). Writes the 401 itself and returns
390
+ // false so callers can `if (!requireAuthQueryCred(url, res)) return;`.
391
+ function requireAuthQueryCred(url, res) {
392
+ const provided = url.searchParams.get(AUTH_QUERY_KEY);
393
+ const expected = state[AUTH_QUERY_KEY];
394
+ if (provided !== expected) {
395
+ res.writeHead(401);
396
+ res.end('Unauthorized');
397
+ return false;
398
+ }
399
+ return true;
400
+ }
401
+
386
402
  // HTTP request handler
387
403
  // ---------------------------------------------------------------------------
388
404
 
@@ -398,6 +414,15 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
398
414
 
399
415
  // --- Scripts ---
400
416
  if (p === '/live.js') {
417
+ // This response embeds the shared bearer credential (below) — unlike
418
+ // /detect.js and the vendored scripts, it must be gated the same way
419
+ // every OTHER protected route already is. Without this, requesting this
420
+ // one route defeats every other route's check: whoever fetches it walks
421
+ // away with the value that's supposed to distinguish the legitimate
422
+ // preview tab from any other page. See requireAuthQueryCred() below —
423
+ // callers must supply the credential the running server printed at
424
+ // startup (also mirrored into the live-server info file for tooling).
425
+ if (!requireAuthQueryCred(url, res)) return;
401
426
  // Re-read from disk each request so edits to live-browser.js land on
402
427
  // the next tab reload. No-store headers prevent browser caching across
403
428
  // sessions — during iteration, a cached old script silently breaks
@@ -604,8 +629,19 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
604
629
  if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
605
630
  const filePath = url.searchParams.get('path');
606
631
  if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; }
607
- const absPath = path.resolve(process.cwd(), filePath);
608
- if (!absPath.startsWith(process.cwd())) { res.writeHead(403); res.end('Forbidden'); return; }
632
+ // Resolve symlinks on BOTH sides before comparing: a lexical prefix check
633
+ // (path.resolve + startsWith) can't tell a symlink that lexically resolves
634
+ // inside cwd from one that physically points outside it, and startsWith on
635
+ // its own also has no path-boundary — "/proj-evil" lexically "starts with"
636
+ // "/proj". realpath + path.relative closes both gaps (the same pattern
637
+ // already used correctly elsewhere in this package).
638
+ let root, absPath;
639
+ try {
640
+ root = fs.realpathSync(process.cwd());
641
+ absPath = fs.realpathSync(path.resolve(process.cwd(), filePath));
642
+ } catch { res.writeHead(404); res.end('File not found'); return; }
643
+ const rel = path.relative(root, absPath);
644
+ if (rel.startsWith('..') || path.isAbsolute(rel)) { res.writeHead(403); res.end('Forbidden'); return; }
609
645
  let content;
610
646
  try { content = fs.readFileSync(absPath, 'utf-8'); }
611
647
  catch { res.writeHead(404); res.end('File not found'); return; }
@@ -1,164 +0,0 @@
1
- import { existsSync, readFileSync, statSync } from 'node:fs';
2
- import { extname } from 'node:path';
3
-
4
- export type AntipatternCategory = 'slop' | 'quality' | 'performance' | 'accessibility';
5
-
6
- export interface AntipatternFinding {
7
- id: string;
8
- category: AntipatternCategory;
9
- name: string;
10
- severity: 'error' | 'warning' | 'info';
11
- message: string;
12
- file?: string;
13
- line?: number;
14
- }
15
-
16
- export interface DetectionResult {
17
- findings: AntipatternFinding[];
18
- fileCount: number;
19
- durationMs: number;
20
- }
21
-
22
- // Shape of the bundled engine (cli/engine/detect-antipatterns.mjs).
23
- interface DetectorEngine {
24
- detectText: (content: string, filePath: string, options?: Record<string, unknown>) => RawFinding[];
25
- detectHtml: (filePath: string, options?: Record<string, unknown>) => Promise<RawFinding[]>;
26
- detectUrl: (url: string, options?: Record<string, unknown>) => Promise<RawFinding[]>;
27
- walkDir: (dir: string) => string[];
28
- ANTIPATTERNS: Array<{ id: string; category?: string; severity?: string }>;
29
- }
30
-
31
- interface RawFinding {
32
- antipattern?: string;
33
- id?: string;
34
- name?: string;
35
- description?: string;
36
- severity?: string;
37
- file?: string;
38
- line?: number;
39
- snippet?: string;
40
- }
41
-
42
- // Typed as plain string so tsc emits a true dynamic import instead of trying
43
- // to type-resolve the untyped .mjs engine module.
44
- const ENGINE_SPECIFIER: string = '../cli/engine/detect-antipatterns.mjs';
45
-
46
- let enginePromise: Promise<DetectorEngine | null> | undefined;
47
-
48
- async function loadEngine(): Promise<DetectorEngine | null> {
49
- enginePromise ??= import(ENGINE_SPECIFIER).then(
50
- (mod) => mod as unknown as DetectorEngine,
51
- () => null,
52
- );
53
- return enginePromise;
54
- }
55
-
56
- /** True when the bundled detection engine can be loaded. */
57
- export async function detectorAvailable(): Promise<boolean> {
58
- return (await loadEngine()) !== null;
59
- }
60
-
61
- const HTML_EXTENSIONS = new Set(['.html', '.htm']);
62
-
63
- function mapSeverity(severity: string | undefined): AntipatternFinding['severity'] {
64
- if (severity === 'error') return 'error';
65
- if (severity === 'advisory' || severity === 'info') return 'info';
66
- return 'warning';
67
- }
68
-
69
- function toFinding(raw: RawFinding, categoryById: Map<string, string>): AntipatternFinding {
70
- const id = String(raw.antipattern ?? raw.id ?? '');
71
- return {
72
- id,
73
- category: (categoryById.get(id) ?? 'quality') as AntipatternCategory,
74
- name: String(raw.name ?? id),
75
- severity: mapSeverity(raw.severity),
76
- message: String(raw.description ?? raw.snippet ?? ''),
77
- file: raw.file ? String(raw.file) : undefined,
78
- line: raw.line ? Number(raw.line) : undefined,
79
- };
80
- }
81
-
82
- async function detectFile(engine: DetectorEngine, filePath: string): Promise<RawFinding[]> {
83
- if (HTML_EXTENSIONS.has(extname(filePath).toLowerCase())) {
84
- return engine.detectHtml(filePath);
85
- }
86
- return engine.detectText(readFileSync(filePath, 'utf-8'), filePath);
87
- }
88
-
89
- /**
90
- * Run antipattern detection on a path (file, directory, or URL) using the
91
- * bundled detection engine (cli/engine/detect-antipatterns.mjs). URL targets
92
- * require the optional puppeteer dependency.
93
- */
94
- export async function detectAntipatterns(target: string): Promise<DetectionResult> {
95
- const engine = await loadEngine();
96
- if (!engine) {
97
- throw new Error(
98
- 'The monodesign detection engine could not be loaded. ' +
99
- 'Reinstall @monoes/monodesign (its cli/engine/ directory is missing or its dependencies are not installed).',
100
- );
101
- }
102
-
103
- const start = Date.now();
104
- const categoryById = new Map(
105
- engine.ANTIPATTERNS.map((ap) => [ap.id, ap.category ?? 'quality']),
106
- );
107
-
108
- let raw: RawFinding[] = [];
109
- let fileCount = 0;
110
-
111
- if (/^https?:\/\//i.test(target)) {
112
- raw = await engine.detectUrl(target);
113
- fileCount = 1;
114
- } else if (existsSync(target) && statSync(target).isDirectory()) {
115
- const files = engine.walkDir(target);
116
- fileCount = files.length;
117
- for (const file of files) {
118
- raw.push(...(await detectFile(engine, file)));
119
- }
120
- } else if (existsSync(target)) {
121
- raw = await detectFile(engine, target);
122
- fileCount = 1;
123
- } else {
124
- throw new Error(`Target not found: ${target}`);
125
- }
126
-
127
- return {
128
- findings: raw.map((f) => toFinding(f, categoryById)),
129
- fileCount,
130
- durationMs: Date.now() - start,
131
- };
132
- }
133
-
134
- /**
135
- * Format findings as a human-readable string for display in agent output.
136
- */
137
- export function formatFindings(result: DetectionResult): string {
138
- if (result.findings.length === 0) {
139
- return `No antipatterns detected in ${result.fileCount} file(s). (${result.durationMs}ms)`;
140
- }
141
-
142
- const byCategory = result.findings.reduce<Record<string, AntipatternFinding[]>>((acc, f) => {
143
- (acc[f.category] ??= []).push(f);
144
- return acc;
145
- }, {});
146
-
147
- const lines: string[] = [
148
- `${result.findings.length} finding(s) in ${result.fileCount} file(s) — ${result.durationMs}ms`,
149
- '',
150
- ];
151
-
152
- for (const [cat, items] of Object.entries(byCategory)) {
153
- lines.push(`[${cat.toUpperCase()}]`);
154
- for (const f of items) {
155
- const loc = f.file ? ` ${f.file}${f.line ? `:${f.line}` : ''}` : '';
156
- lines.push(` ${f.severity === 'error' ? '✗' : '⚠'} ${f.name}`);
157
- lines.push(` ${f.message}`);
158
- if (loc) lines.push(loc);
159
- }
160
- lines.push('');
161
- }
162
-
163
- return lines.join('\n');
164
- }