@monoes/monodesign 1.2.3 → 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 +173 -22
- package/cli/engine/engines/browser/drivers.mjs +82 -1
- package/cli/engine/rules/checks.mjs +24 -1
- package/package.json +8 -9
- package/skill/reference/hooks.md +2 -2
- package/skill/scripts/hook-admin.mjs +51 -18
- package/skill/scripts/live/sveltekit-adapter.mjs +11 -6
- package/skill/scripts/live-inject.mjs +20 -7
- package/skill/scripts/live-server.mjs +38 -2
package/README.md
CHANGED
|
@@ -8,9 +8,12 @@
|
|
|
8
8
|
[](https://github.com/monoes/monomind/blob/main/LICENSE)
|
|
9
9
|
[](https://nodejs.org)
|
|
10
10
|
|
|
11
|
-
**Frontend design intelligence for AI agents** — design tokens,
|
|
11
|
+
**Frontend design intelligence for AI agents** — OKLCH design tokens, a 51-rule AI-slop
|
|
12
|
+
antipattern detector, and the `/monodesign` agent skill for Claude Code and Gemini agy.
|
|
12
13
|
|
|
13
|
-
> Part of the [Monomind](https://github.com/monoes/monomind) ecosystem.
|
|
14
|
+
> Part of the [Monomind](https://github.com/monoes/monomind) ecosystem. v1.2.4 · Apache-2.0
|
|
15
|
+
|
|
16
|
+
---
|
|
14
17
|
|
|
15
18
|
## Install
|
|
16
19
|
|
|
@@ -18,38 +21,186 @@
|
|
|
18
21
|
npm install @monoes/monodesign
|
|
19
22
|
```
|
|
20
23
|
|
|
21
|
-
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## What's Inside
|
|
27
|
+
|
|
28
|
+
| Module | Export path | Purpose |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| Design tokens (TS) | `@monoes/monodesign` | Typed constants for font, spacing, color, motion |
|
|
31
|
+
| Design tokens (CSS) | `@monoes/monodesign/tokens` | CSS custom properties (`tokens.css`) |
|
|
32
|
+
| Detection engine | `@monoes/monodesign/engine` | Node-side 51-rule antipattern detector |
|
|
33
|
+
| Browser bundle | `@monoes/monodesign/engine/browser` | Browser-compatible detector (no Node deps) |
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Design Tokens
|
|
38
|
+
|
|
39
|
+
Source: [`src/tokens.ts`](packages/@monoes/monodesign/src/tokens.ts) + [`src/tokens.css`](packages/@monoes/monodesign/src/tokens.css)
|
|
40
|
+
|
|
41
|
+
All color tokens are in **OKLCH** — no hex colors in the token system.
|
|
22
42
|
|
|
23
|
-
|
|
43
|
+
### Token Categories
|
|
44
|
+
|
|
45
|
+
| Category | Key Values |
|
|
24
46
|
|---|---|
|
|
25
|
-
|
|
|
26
|
-
|
|
|
27
|
-
|
|
|
47
|
+
| **Typography** | Fonts: `'Cormorant Garamond'` (display), `'Instrument Sans'` (body), `'Space Grotesk'` (mono). Scale: xs (12px) → 7xl (72px). Line-heights: 1.2–2.0 |
|
|
48
|
+
| **Spacing** | 8px base grid: `--space-1` (4px) → `--space-30` (120px) |
|
|
49
|
+
| **Color** | Neutrals: ink/charcoal/ash/mist/cream/paper/white. Accent: Editorial Magenta `oklch(60% 0.25 350)` with 9-stop tonal ramp |
|
|
50
|
+
| **Motion** | Expo-out primary `cubic-bezier(0.16,1,0.3,1)`, plus quint, in-out, in. Durations: 0s → 1.2s |
|
|
51
|
+
| **Shadows** | sm/md/lg/accent. Max 0.15 alpha. |
|
|
52
|
+
| **Radius** | none(0) → sm(4px) → md(8px) → lg(12px) → xl(16px) → full(9999px) |
|
|
53
|
+
| **Z-index** | Named scale: below(−1) → base(0) → raised(1) → overlay(10) → modal(20) → toast(30) → top(40) |
|
|
54
|
+
|
|
55
|
+
### CSS Usage
|
|
56
|
+
|
|
57
|
+
```css
|
|
58
|
+
/* Import CSS custom properties */
|
|
59
|
+
@import '@monoes/monodesign/tokens';
|
|
60
|
+
|
|
61
|
+
.hero {
|
|
62
|
+
font-family: var(--font-display);
|
|
63
|
+
color: var(--color-ink);
|
|
64
|
+
padding: var(--space-8);
|
|
65
|
+
border-radius: var(--radius-lg);
|
|
66
|
+
transition: transform var(--duration-default) var(--easing-expo-out);
|
|
67
|
+
}
|
|
68
|
+
```
|
|
28
69
|
|
|
29
|
-
|
|
70
|
+
### TypeScript Usage
|
|
30
71
|
|
|
31
72
|
```typescript
|
|
32
|
-
import {
|
|
73
|
+
import { fontFamily, fontSize, spacing, easing, duration, color, accentScale, shadow, radius } from '@monoes/monodesign';
|
|
33
74
|
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
tokens.typography.body // font stacks and sizes
|
|
38
|
-
tokens.breakpoints.tablet // responsive breakpoints
|
|
75
|
+
console.log(color.accent); // oklch(60% 0.25 350)
|
|
76
|
+
console.log(spacing[8]); // 32px
|
|
77
|
+
console.log(radius.lg); // 12px
|
|
39
78
|
```
|
|
40
79
|
|
|
41
|
-
|
|
80
|
+
---
|
|
42
81
|
|
|
43
|
-
|
|
44
|
-
|
|
82
|
+
## Antipattern Detection (51 Rules)
|
|
83
|
+
|
|
84
|
+
Source: [`cli/engine/registry/antipatterns.mjs`](packages/@monoes/monodesign/cli/engine/registry/antipatterns.mjs) — 564 lines, 51 entries.
|
|
85
|
+
|
|
86
|
+
### Rule Categories
|
|
87
|
+
|
|
88
|
+
**`slop`** (21 rules) — AI-generation tells that make UIs feel generic:
|
|
89
|
+
|
|
90
|
+
`side-tab`, `border-accent-on-rounded`, `overused-font`, `single-font`, `flat-type-hierarchy`, `gradient-text`, `ai-color-palette`, `cream-palette`, `nested-cards`, `monotonous-spacing`, `bounce-easing`, `dark-glow`, `icon-tile-stack`, `italic-serif-display`, `hero-eyebrow-chip`, `repeated-section-kickers`, `numbered-section-markers`, `em-dash-overuse`, `marketing-buzzword`, `aphoristic-cadence`, `oversized-h1`, `extreme-negative-tracking`, `broken-image`
|
|
91
|
+
|
|
92
|
+
**`quality`** (24 rules) — Accessibility and design principle violations:
|
|
93
|
+
|
|
94
|
+
`gray-on-color`, `low-contrast`, `layout-transition`, `line-length`, `cramped-padding`, `body-text-viewport-edge`, `tight-leading`, `skipped-heading`, `justified-text`, `tiny-text`, `all-caps-body`, `wide-tracking`, `text-overflow`, `clipped-overflow-container`, `design-system-font`, `design-system-color`, `design-system-radius`, `design-system-font-size`, `missing-focus-visible`, `small-touch-target`, `hover-only-affordance`, `image-missing-dimensions`, `dark-scheme-contrast-blindspot`
|
|
95
|
+
|
|
96
|
+
**Provider-gated** (4 advisory rules, opt-in via `--gpt`/`--gemini`): `gpt-thin-border-wide-shadow`, `repeating-stripes-gradient`, `codex-grid-background`, `theater-slop-phrase`
|
|
97
|
+
|
|
98
|
+
### Detection Engines
|
|
99
|
+
|
|
100
|
+
| Engine | Method | Optional dep |
|
|
101
|
+
|---|---|---|
|
|
102
|
+
| `regex` | Source text / CSS-in-JS scan | none |
|
|
103
|
+
| `static-html` | HTMLparser2 static DOM parse | none |
|
|
104
|
+
| `browser` | Puppeteer live-page scan | `puppeteer` (optional) |
|
|
105
|
+
| `visual` | Visual contrast analysis | `@monoes/monobrowse` (optional) |
|
|
106
|
+
|
|
107
|
+
### Auto-Fix
|
|
108
|
+
|
|
109
|
+
`cli/engine/fix/fixers.mjs` implements safe per-rule codemods. `--dry-run` prints unified diffs. `--rule <id,...>` narrows the fix to specific rule IDs.
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## OKLCH Palette Seed Library
|
|
114
|
+
|
|
115
|
+
Source: [`packages/@monomind/cli/src/commands/design-palette.ts:29–417`](packages/@monomind/cli/src/commands/design-palette.ts#L29-L417)
|
|
116
|
+
|
|
117
|
+
- **129 hand-curated seeds** spanning the full hue wheel (0°–360°)
|
|
118
|
+
- Each seed: `{ id, oklch: [L, C, H], mood: string, strategy: string }`
|
|
119
|
+
- **Hue-zone weighting**: inverse-frequency across 30° buckets — equal probability for all hue regions regardless of seed density
|
|
120
|
+
- **Env var override**: `MONODESIGN_PALETTE_SEED`
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## The `/monodesign` Agent Skill
|
|
125
|
+
|
|
126
|
+
Source: [`skill/SKILL.src.md`](packages/@monoes/monodesign/skill/SKILL.src.md) · v2.0.0 · Apache-2.0
|
|
127
|
+
|
|
128
|
+
Triggers: `/monodesign`, `/design`, `/frontend-design`, plus 40+ natural-language triggers.
|
|
129
|
+
|
|
130
|
+
### 24 Skill Commands
|
|
131
|
+
|
|
132
|
+
| Category | Commands |
|
|
133
|
+
|---|---|
|
|
134
|
+
| Build | `craft`, `shape`, `init`, `document`, `extract`, `components`, `images`, `palette` |
|
|
135
|
+
| Evaluate | `critique`, `audit`, `research`, `detect` |
|
|
136
|
+
| Refine | `polish`, `bolder`, `quieter`, `distill`, `harden`, `onboard` |
|
|
137
|
+
| Enhance | `animate`, `colorize`, `typeset`, `layout`, `delight`, `overdrive` |
|
|
138
|
+
| Fix | `clarify`, `adapt`, `optimize` |
|
|
139
|
+
| Iterate | `live` |
|
|
140
|
+
| Management | `pin`, `unpin`, `hooks` |
|
|
141
|
+
|
|
142
|
+
### Sub-Agents
|
|
143
|
+
|
|
144
|
+
| Agent file | Role |
|
|
145
|
+
|---|---|
|
|
146
|
+
| `skill/agents/monodesign-asset-producer.md` | Image/asset generation |
|
|
147
|
+
| `skill/agents/monodesign-manual-edit-applier.md` | Accepts live manual edits during `live` sessions |
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
## Hooks Integration
|
|
152
|
+
|
|
153
|
+
When enabled (`.monodesign/config.json`), antipattern detection runs automatically after UI file edits:
|
|
154
|
+
|
|
155
|
+
```json
|
|
156
|
+
{
|
|
157
|
+
"hook": {
|
|
158
|
+
"enabled": true,
|
|
159
|
+
"limits": { "maxFindings": 5, "maxChars": 8000 }
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Manage via `monomind design ignores` or `/monodesign hooks <on|off|status|ignore-rule|ignore-file|ignore-value|reset>`.
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## CLI Subcommands (`monomind design`)
|
|
169
|
+
|
|
170
|
+
| Command | Purpose |
|
|
171
|
+
|---|---|
|
|
172
|
+
| `monomind design detect [-t <path>] [--json]` | Scan HTML/CSS for antipatterns |
|
|
173
|
+
| `monomind design fix [-t <path>] [--dry-run] [--json] [--rule <ids>]` | Auto-fix with safe codemods |
|
|
174
|
+
| `monomind design ignores <list\|add-rule\|add-file\|add-value\|remove-rule>` | Manage detector ignores |
|
|
175
|
+
| `monomind design palette [--id <id>] [--from <key>] [--random] [--json]` | Pick OKLCH brand seed |
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
## Design System Consistency
|
|
180
|
+
|
|
181
|
+
Source: [`cli/engine/design-system.mjs`](packages/@monoes/monodesign/cli/engine/design-system.mjs) — 815 lines
|
|
182
|
+
|
|
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
|
+
|
|
185
|
+
Search order: `DESIGN.md` → `.agents/context/DESIGN.md` → `docs/DESIGN.md`.
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## Package Exports
|
|
45
190
|
|
|
46
|
-
|
|
47
|
-
|
|
191
|
+
```json
|
|
192
|
+
{
|
|
193
|
+
".": "./src/index.ts",
|
|
194
|
+
"./tokens": "./src/tokens.css",
|
|
195
|
+
"./engine": "./cli/engine/detect-antipatterns.mjs",
|
|
196
|
+
"./engine/browser": "./cli/engine/detect-antipatterns-browser.js"
|
|
197
|
+
}
|
|
48
198
|
```
|
|
49
199
|
|
|
50
|
-
|
|
200
|
+
**Dependencies:** `css-select`, `css-tree`, `domutils`, `fflate`, `htmlparser2`, `marked`
|
|
201
|
+
**Optional:** `@monoes/monobrowse` (browser detection), `puppeteer` (live URL scanning)
|
|
51
202
|
|
|
52
|
-
|
|
203
|
+
---
|
|
53
204
|
|
|
54
205
|
## Links
|
|
55
206
|
|
|
@@ -59,4 +210,4 @@ In Claude Code, monodesign is available as `/monodesign` — the single design a
|
|
|
59
210
|
|
|
60
211
|
## License
|
|
61
212
|
|
|
62
|
-
|
|
213
|
+
Apache-2.0
|
|
@@ -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
|
-
|
|
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
|
|
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.
|
|
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,12 +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
|
-
"prepare": "node scripts/sync-skill.mjs"
|
|
27
|
-
},
|
|
28
22
|
"keywords": [
|
|
29
23
|
"design-system",
|
|
30
24
|
"design-tokens",
|
|
@@ -45,7 +39,12 @@
|
|
|
45
39
|
"marked": "^18.0.0"
|
|
46
40
|
},
|
|
47
41
|
"optionalDependencies": {
|
|
48
|
-
"@monoes/monobrowse": "^1.0.
|
|
42
|
+
"@monoes/monobrowse": "^1.0.7",
|
|
49
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"
|
|
50
49
|
}
|
|
51
|
-
}
|
|
50
|
+
}
|
package/skill/reference/hooks.md
CHANGED
|
@@ -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/
|
|
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/
|
|
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/
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
608
|
-
|
|
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; }
|