@monoes/monodesign 1.2.4 → 1.2.5

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
@@ -11,7 +11,7 @@
11
11
  **Frontend design intelligence for AI agents** — OKLCH design tokens, a 51-rule AI-slop
12
12
  antipattern detector, and the `/monodesign` agent skill for Claude Code and Gemini agy.
13
13
 
14
- > Part of the [Monomind](https://github.com/monoes/monomind) ecosystem. v1.2.3 · Apache-2.0
14
+ > Part of the [Monomind](https://github.com/monoes/monomind) ecosystem. v1.2.4 · Apache-2.0
15
15
 
16
16
  ---
17
17
 
@@ -36,7 +36,7 @@ npm install @monoes/monodesign
36
36
 
37
37
  ## Design Tokens
38
38
 
39
- Source: [`src/tokens.ts`](file:///Users/morteza/Desktop/tools/monomind/packages/@monoes/monodesign/src/tokens.ts) + [`src/tokens.css`](file:///Users/morteza/Desktop/tools/monomind/packages/@monoes/monodesign/src/tokens.css)
39
+ Source: [`src/tokens.ts`](packages/@monoes/monodesign/src/tokens.ts) + [`src/tokens.css`](packages/@monoes/monodesign/src/tokens.css)
40
40
 
41
41
  All color tokens are in **OKLCH** — no hex colors in the token system.
42
42
 
@@ -81,7 +81,7 @@ console.log(radius.lg); // 12px
81
81
 
82
82
  ## Antipattern Detection (51 Rules)
83
83
 
84
- Source: [`cli/engine/registry/antipatterns.mjs`](file:///Users/morteza/Desktop/tools/monomind/packages/@monoes/monodesign/cli/engine/registry/antipatterns.mjs) — 564 lines, 51 entries.
84
+ Source: [`cli/engine/registry/antipatterns.mjs`](packages/@monoes/monodesign/cli/engine/registry/antipatterns.mjs) — 564 lines, 51 entries.
85
85
 
86
86
  ### Rule Categories
87
87
 
@@ -112,7 +112,7 @@ Source: [`cli/engine/registry/antipatterns.mjs`](file:///Users/morteza/Desktop/t
112
112
 
113
113
  ## OKLCH Palette Seed Library
114
114
 
115
- Source: [`packages/@monomind/cli/src/commands/design-palette.ts:29–417`](file:///Users/morteza/Desktop/tools/monomind/packages/@monomind/cli/src/commands/design-palette.ts#L29-L417)
115
+ Source: [`packages/@monomind/cli/src/commands/design-palette.ts:29–417`](packages/@monomind/cli/src/commands/design-palette.ts#L29-L417)
116
116
 
117
117
  - **129 hand-curated seeds** spanning the full hue wheel (0°–360°)
118
118
  - Each seed: `{ id, oklch: [L, C, H], mood: string, strategy: string }`
@@ -123,7 +123,7 @@ Source: [`packages/@monomind/cli/src/commands/design-palette.ts:29–417`](file:
123
123
 
124
124
  ## The `/monodesign` Agent Skill
125
125
 
126
- Source: [`skill/SKILL.src.md`](file:///Users/morteza/Desktop/tools/monomind/packages/@monoes/monodesign/skill/SKILL.src.md) · v2.0.0 · Apache-2.0
126
+ Source: [`skill/SKILL.src.md`](packages/@monoes/monodesign/skill/SKILL.src.md) · v2.0.0 · Apache-2.0
127
127
 
128
128
  Triggers: `/monodesign`, `/design`, `/frontend-design`, plus 40+ natural-language triggers.
129
129
 
@@ -178,7 +178,7 @@ Manage via `monomind design ignores` or `/monodesign hooks <on|off|status|ignore
178
178
 
179
179
  ## Design System Consistency
180
180
 
181
- Source: [`cli/engine/design-system.mjs`](file:///Users/morteza/Desktop/tools/monomind/packages/@monoes/monodesign/cli/engine/design-system.mjs) — 815 lines
181
+ Source: [`cli/engine/design-system.mjs`](packages/@monoes/monodesign/cli/engine/design-system.mjs) — 815 lines
182
182
 
183
183
  Parses `DESIGN.md` / `.monodesign/design.json` to enforce consistency rules (`design-system-font`, `design-system-color`, `design-system-radius`, `design-system-font-size`). The skill writes `DESIGN.md`; the detector reads it for drift detection.
184
184
 
@@ -28,7 +28,20 @@
28
28
  // Selection: MONODESIGN_BROWSER_DRIVER=monobrowse|puppeteer forces one;
29
29
  // otherwise monobrowse is tried first and puppeteer is the fallback.
30
30
 
31
+ import { createServer } from 'node:net';
32
+ import { mkdtempSync, rmSync } from 'node:fs';
33
+ import { tmpdir } from 'node:os';
34
+ import { join } from 'node:path';
35
+
31
36
  const DRIVER_ENV = 'MONODESIGN_BROWSER_DRIVER';
37
+ // Windows CI teardown is the slow case: Chrome acknowledges Browser.close long
38
+ // before the process exits and hands the socket back, and closeBrowser()'s
39
+ // force-kill safety net is itself a deferred 1s timer. 5s is comfortable
40
+ // locally but cuts into a loaded runner's teardown, so give CI more runway —
41
+ // same reasoning as launchTimeoutMs below.
42
+ const CDP_PORT_RELEASE_TIMEOUT_MS = process.env.CI ? 15_000 : 5_000;
43
+ const CDP_PORT_RELEASE_POLL_MS = 25;
44
+ const PROFILE_CLEANUP_RETRY_MS = 2_000;
32
45
 
33
46
  // Ports we launch headless Chrome on for detection runs. Deliberately away
34
47
  // from 9222 (the default `monomind browse` port): we must never attach to a
@@ -56,6 +69,41 @@ function withTimeout(promise, ms, label) {
56
69
  return Promise.race([promise, timeout]).finally(() => clearTimeout(handle));
57
70
  }
58
71
 
72
+ // "Released" has to mean BINDABLE, not merely unreachable. A connect probe
73
+ // stops succeeding as soon as Chrome's listener socket closes, which happens
74
+ // early in its shutdown — while the process is still alive holding both the
75
+ // port and the --user-data-dir singleton lock for this port. On Windows that
76
+ // gap is wide and the socket stays unbindable across it (node does not set
77
+ // SO_REUSEADDR there), so close() reported success and the next launch on the
78
+ // same forced port sat until its timeout: "Chrome failed to start on port N
79
+ // within 30000ms". Binding is the question the next launch actually asks, so
80
+ // ask it directly. A listener that never accepted a connection leaves no
81
+ // TIME_WAIT behind, so probing this way does not itself hold the port.
82
+ function isPortBindable(port) {
83
+ return new Promise((resolve) => {
84
+ const server = createServer();
85
+ server.once('error', () => resolve(false));
86
+ server.listen(port, '127.0.0.1', () => server.close(() => resolve(true)));
87
+ });
88
+ }
89
+
90
+ function removeProfileDir(dir) {
91
+ try {
92
+ rmSync(dir, { recursive: true, force: true });
93
+ } catch {
94
+ /* reclaimed by the OS's temp cleanup instead */
95
+ }
96
+ }
97
+
98
+ async function waitForCdpPortRelease(port) {
99
+ const deadline = Date.now() + CDP_PORT_RELEASE_TIMEOUT_MS;
100
+ while (Date.now() < deadline) {
101
+ if (await isPortBindable(port)) return;
102
+ await new Promise(resolve => setTimeout(resolve, CDP_PORT_RELEASE_POLL_MS));
103
+ }
104
+ throw new Error(`Chrome did not release CDP port ${port} within ${CDP_PORT_RELEASE_TIMEOUT_MS}ms`);
105
+ }
106
+
59
107
  // ---------------------------------------------------------------------------
60
108
  // monobrowse driver
61
109
  // ---------------------------------------------------------------------------
@@ -143,7 +191,27 @@ async function launchMonobrowseBrowser(options = {}) {
143
191
  if (port === null) throw new Error('Could not find a free CDP port for monobrowse');
144
192
  }
145
193
 
146
- const launchedPort = await mb.launchBrowser({ port, headless, args: launchArgs });
194
+ // CI runners (observed on the Windows GH Actions runner) are slower to get
195
+ // Chrome's CDP port open than the 10s default — a batch of sequential
196
+ // headless launches in the same job intermittently misses that window and
197
+ // fails with "Chrome failed to start... within 10000ms" even though the
198
+ // browser starts fine, just later. Give CI more runway; local dev keeps
199
+ // the tighter default so a genuinely broken launch still fails fast.
200
+ const launchTimeoutMs = options.launchTimeoutMs ?? (process.env.CI ? 30000 : undefined);
201
+ // Throwaway profile per launch. monobrowse defaults --user-data-dir to
202
+ // tmpdir()/monomind-browser-<port>, which is right for `monomind browse`
203
+ // (a later process reattaches to the same session by port) but wrong here:
204
+ // detection launches are stateless, and keying the profile on the port
205
+ // means two launches on the SAME port share one profile. Chrome enforces
206
+ // single-instance-per-profile, so the second launch rendezvouses with the
207
+ // first instead of starting its own — it hands over its command line and
208
+ // exits without ever opening a debugging port, which surfaces as "Chrome
209
+ // failed to start on port N" after the full launch timeout. Windows is
210
+ // where this bites: its singleton lock survives the previous Chrome being
211
+ // force-killed, and outlives the port being released. A unique dir removes
212
+ // the sharing entirely rather than racing the lock's cleanup.
213
+ const userDataDir = mkdtempSync(join(tmpdir(), 'monodesign-cdp-'));
214
+ const launchedPort = await mb.launchBrowser({ port, headless, args: launchArgs, launchTimeoutMs, userDataDir });
147
215
  // Control connection (to the initial about:blank tab) — used for lifecycle
148
216
  // commands; detection pages get their own dedicated connections.
149
217
  const control = await mb.connectToTarget(launchedPort);
@@ -175,8 +243,21 @@ async function launchMonobrowseBrowser(options = {}) {
175
243
  // Browser.close command directly (best effort, short timeout).
176
244
  await withTimeout(control.client.send('Browser.close', {}), 3000, 'Browser.close').catch(() => {});
177
245
  }
246
+ // Browser.close is acknowledged before Chrome has actually exited. A
247
+ // following detection run on a forced port can otherwise attach to the
248
+ // shutting-down process and fail creating its first target. Do not
249
+ // expose close() as complete until that CDP listener is gone.
250
+ await waitForCdpPortRelease(launchedPort);
178
251
  } finally {
179
252
  control.client.close();
253
+ // Best effort, twice: Chrome is still flushing profile data when the
254
+ // port comes back, so an immediate delete gets a few files recreated
255
+ // under it. The unref'd retry lands after that (same pattern as
256
+ // monobrowse's own post-close force-kill net) and never holds the
257
+ // process open. Whatever still survives is a few KB under tmpdir that
258
+ // nothing reuses, so neither attempt may fail close().
259
+ removeProfileDir(userDataDir);
260
+ setTimeout(() => removeProfileDir(userDataDir), PROFILE_CLEANUP_RETRY_MS).unref?.();
180
261
  }
181
262
  },
182
263
  };
@@ -2635,6 +2635,29 @@ function checkElementTextOverflowDOM(el) {
2635
2635
  // conditions can't be read off a resting element. Shared by the regex, static
2636
2636
  // and browser engines (each hands over its own CSS text).
2637
2637
 
2638
+ // Split a selector list on top-level commas only — i.e. commas inside a
2639
+ // functional pseudo-class's parens (`:is(a, button)`, `:where(a, b)`,
2640
+ // `:not(a, b)`, `:has(x, y)`) don't split. A naive `str.split(',')` breaks
2641
+ // `:is(a, button):focus { outline: none }` into `:is(a` and `button):focus`,
2642
+ // which both selectorTargetsInteractive() and the dark/light selector maps
2643
+ // below then treat as two unrelated (and malformed) selectors — producing
2644
+ // false-positive missing-focus-visible findings and double-counted rules.
2645
+ function splitTopLevelCommas(str) {
2646
+ const parts = [];
2647
+ let depth = 0, start = 0;
2648
+ for (let k = 0; k < str.length; k++) {
2649
+ const ch = str[k];
2650
+ if (ch === '(') depth++;
2651
+ else if (ch === ')') depth = Math.max(0, depth - 1);
2652
+ else if (ch === ',' && depth === 0) {
2653
+ parts.push(str.slice(start, k));
2654
+ start = k + 1;
2655
+ }
2656
+ }
2657
+ parts.push(str.slice(start));
2658
+ return parts;
2659
+ }
2660
+
2638
2661
  // Parse flat CSS into { selector, body, dark } tuples, tracking one level of
2639
2662
  // dark-scheme context (a `@media (prefers-color-scheme: dark)` ancestor).
2640
2663
  // @supports / @layer / @container / @scope are transparently descended.
@@ -2664,7 +2687,7 @@ function collectCssRules(css, dark = false, depth = 0) {
2664
2687
  }
2665
2688
  continue;
2666
2689
  }
2667
- for (const sel of prelude.split(',').map(s => s.trim()).filter(Boolean)) {
2690
+ for (const sel of splitTopLevelCommas(prelude).map(s => s.trim()).filter(Boolean)) {
2668
2691
  out.push({ selector: sel, body, dark });
2669
2692
  }
2670
2693
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monoes/monodesign",
3
- "version": "1.2.4",
3
+ "version": "1.2.5",
4
4
  "description": "Frontend design intelligence for AI agents — design tokens, antipattern detection, and the monodesign skill",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -19,11 +19,6 @@
19
19
  "src/",
20
20
  "NOTICE.md"
21
21
  ],
22
- "scripts": {
23
- "build": "tsc",
24
- "test": "node scripts/run-tests.mjs",
25
- "sync": "node scripts/sync-skill.mjs"
26
- },
27
22
  "keywords": [
28
23
  "design-system",
29
24
  "design-tokens",
@@ -44,7 +39,12 @@
44
39
  "marked": "^18.0.0"
45
40
  },
46
41
  "optionalDependencies": {
47
- "@monoes/monobrowse": "^1.0.6",
42
+ "@monoes/monobrowse": "^1.0.7",
48
43
  "puppeteer": "^25.1.0"
44
+ },
45
+ "scripts": {
46
+ "build": "tsc",
47
+ "test": "node scripts/run-tests.mjs",
48
+ "sync": "node scripts/sync-skill.mjs"
49
49
  }
50
- }
50
+ }
@@ -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; }