spectoflow 0.21.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,6 +16,10 @@
16
16
  <a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license"></a>
17
17
  </p>
18
18
 
19
+ <p align="center">
20
+ <img src="https://raw.githubusercontent.com/georgesmomo/spectoflow/main/docs/screenshot-board.png" alt="spectoflow dashboard — Board overview" width="880">
21
+ </p>
22
+
19
23
  An **agent-agnostic** spec-driven development framework with a **real-time local control plane**.
20
24
  You speak in plain language; the framework classifies your intent and runs the right workflow. No
21
25
  ceremonial command to start.
@@ -30,6 +34,8 @@ silently activating something that isn't there.
30
34
  > (Claude Code, Copilot CLI, …). spectoflow's own **team personas** (developer, qa-engineer, …) are a
31
35
  > different, unrelated use of "agent" — see [Agents vs skills](#agents-vs-skills) further down.
32
36
 
37
+ ## Supported coding agents
38
+
33
39
  | Coding agent | Headless run | Docs |
34
40
  |---|---|---|
35
41
  | Claude Code | ✓ | [code.claude.com/docs/en/cli-reference](https://code.claude.com/docs/en/cli-reference) |
@@ -101,8 +107,8 @@ spectoflow --help (-h) show help (append -h to any com
101
107
  `.claude/commands/spectoflow.md`.
102
108
  - `.spectoflow/runtime.json` is gitignored (volatile execution state).
103
109
 
104
- `init` **auto-detects your installed agent(s)** (probes PATH for `claude`, `codex`, `cursor-agent`,
105
- `gemini`, and existing `.claude`/`.codex`/… dirs): it writes shims for each, sets the active agent in
110
+ `init` **auto-detects your installed agent(s)** (probes PATH for each of the 13 supported CLIs above,
111
+ and existing `.claude`/`.codex`/… dirs): it writes shims for each, sets the active agent in
106
112
  `config.json`, and seeds the runner commands. Override with `--agent=claude,codex`; if nothing is
107
113
  detected it falls back to claude + codex.
108
114
 
@@ -166,56 +172,72 @@ spectoflow dashboard # → http://localhost:4319 (or --port=
166
172
 
167
173
  `spectoflow dashboard` is the simple way — it prints the URL and won't double-start (it detects a
168
174
  dashboard already running on the port). `spectoflow status` tells you whether one is up. Running the
169
- `server.js` directly still works for a manual/embedded setup. Zero dependencies, updates live via SSE +
170
- file watching. Ten tabs behind a dense icon-tab header (brand logo · subtitle · **active agent** ·
171
- autonomy mode · language · a slim global-progress meter · a sync dot · a **Run** quick-action): **Board**
172
- (the control-room Overview compact KPI cards, a status donut, a **scope-vs-delivered area curve**, a
173
- workflow-at-a-glance strip, per-phase progress bars, filter chips + search plus the phase board),
174
- **Chat** (a full-height group-chat panel with **Summarize**/**Clear**), **Requests** (tasks awaiting
175
- you`to_validate`/`to_analyze`), **Attention** (points the agent raised via a `::spectoflow attention
176
- msg=…` sentinel or that you noted edit/resolve/delete, or **validate task**), **Backlog** (a flat
177
- sortable/filterable, paginated table of every task, defaulting to open work), **Workflow** (the
178
- pipeline as step cards click one to enable/disable it, which edits `workflow.md`), **Agents &
179
- Skills** (enriched cards that open a full-body markdown drawer), **Info** (a project-at-a-glance
180
- summary), **Documentation** (the supported-agents table above, live with your own install status and
181
- links, plus the CLI command reference), and **Personalize** (autonomy mode, language, design, the
182
- active agent, and **Extend spectoflow** — described under *Customize* below). URLs are real routes
183
- (`/board`, `/backlog/T-012`, …). Charts are zero-dep, hand-rolled SVG in `dashboard/public/charts.js`
184
- (donut/area/bars/ring, animated, `prefers-reduced-motion`-aware), and every aggregate is computed
185
- client-side.
186
-
187
- **Designs & theme.** The dashboard ships **switchable designs** (Settings → *Dashboard design*):
188
- **Control Room** (violet), **Obsidian Ops** (near-black lime/cyan, mono), **Neon Command**
189
- (glassmorphism aurora), and **Mission Control** (indigo control panel). Each works in light and dark
190
- (the moon toggle). A design is a `data-design`
191
- skin a scoped CSS token block plus a one-line entry in `dashboard/public/designs.js`, so adding one
192
- is trivial. Fonts are **self-hosted** (`dashboard/public/fonts/*.woff2`), keeping the dashboard fully
193
- offline and dependency-free. Your choice persists per viewer (localStorage) and as the project
194
- default (`config.design`).
195
-
196
- A floating **💬 chat widget** (bottom-right, redesigned) and the **Chat** tab render the same
197
- `runtime.messages` log via a shared `renderChatLog()`, so they never drift. A running agent identifies
198
- itself by printing `::spectoflow role=… kind=… msg=…` sentinels, which become labelled messages
199
- (analyst / developer / qa …); other output streams raw. The board refreshes live as it edits plans.
200
- Either surface can also **Orchestrate** the enabled workflow: each step runs its agent, gated by mode
201
- + policy. The Chat tab can also **Summarize** the recent log into one digest (via the active agent)
202
- or **Clear** it outright, when it's grown long. The Agents & Skills drawer is served by the one
203
- read-only endpoint, `GET /api/agentfile?path=` (scoped to `.spectoflow/agents/**` +
204
- `.spectoflow/skills/**`, path-traversal-safe) the framework's only other server surface is
205
- unchanged.
206
-
207
- **Customize.** Personalize → **Extend spectoflow** lets you extend the project's own spectoflow
208
- install: add a
175
+ `server.js` directly still works for a manual/embedded setup. Zero dependencies, updates live via SSE
176
+ + file watching.
177
+
178
+ The header bar always shows the brand, the **active agent**, autonomy mode, language, a global-progress
179
+ meter, a sync dot, and a **Run** quick-action. Ten tabs:
180
+
181
+ - **Board** the control-room Overview (compact KPI cards, a status donut, a **scope-vs-delivered
182
+ area curve**, a workflow-at-a-glance strip, per-phase progress bars, filter chips + search) plus the
183
+ phase board.
184
+ - **Chat**a full-height group-chat panel with **Summarize** / **Clear**.
185
+ - **Requests** tasks awaiting you (`to_validate` / `to_analyze`).
186
+ - **Attention** — points the agent raised (a `::spectoflow attention msg=…` sentinel) or that you
187
+ noted yourself edit / resolve / delete, or **validate task**.
188
+ - **Backlog** — a flat sortable/filterable, paginated table of every task, defaulting to open work.
189
+ - **Workflow** the pipeline as step cards; click one to enable/disable it, which edits `workflow.md`.
190
+ - **Agents & Skills** enriched cards that open a full-body markdown drawer.
191
+ - **Info** — a project-at-a-glance summary.
192
+ - **Documentation** — the live supported-agents table (your own install status + links) plus the CLI
193
+ command reference.
194
+ - **Personalize** autonomy mode, language, design, the active agent, and **Extend spectoflow** (see
195
+ *Customize* below).
196
+
197
+ URLs are real routes (`/board`, `/backlog/T-012`, …). Charts are zero-dep, hand-rolled SVG in
198
+ `dashboard/public/charts.js` (donut/area/bars/ring, animated, `prefers-reduced-motion`-aware), and
199
+ every aggregate is computed client-side.
200
+
201
+ ### Designs & theme
202
+
203
+ The dashboard ships **switchable designs** (Personalize *Dashboard design*): **Spectral Console**
204
+ (dark-first, ⌘K palette the default), **Orbit** (light, circular radial menu), **Control Room**
205
+ (violet), **Obsidian Ops** (near-black lime/cyan, mono), **Neon Command** (glassmorphism aurora), and
206
+ **Mission Control** (indigo control panel). Each works in light and dark (the moon toggle). A design
207
+ is a `data-design` skin a scoped CSS token block plus a one-line entry in
208
+ `dashboard/public/designs.js`, so adding one is trivial. Fonts are **self-hosted**
209
+ (`dashboard/public/fonts/*.woff2`), keeping the dashboard fully offline and dependency-free. Your
210
+ choice persists per viewer (localStorage) and as the project default (`config.design`).
211
+
212
+ ### Chat
213
+
214
+ A floating **💬 chat widget** and the **Chat** tab render the same `runtime.messages` log via a shared
215
+ `renderChatLog()`, so they never drift. A running agent identifies itself by printing `::spectoflow
216
+ role=… kind=… msg=…` sentinels, which become labelled messages (analyst / developer / qa …); other
217
+ output streams raw. The board refreshes live as it edits plans. Either surface can also
218
+ **Orchestrate** the enabled workflow (each step runs its agent, gated by mode + policy), **Summarize**
219
+ the recent log into one digest via the active agent, or **Clear** it outright. The Agents & Skills
220
+ drawer is served by the one read-only endpoint, `GET /api/agentfile?path=` (scoped to
221
+ `.spectoflow/agents/**` + `.spectoflow/skills/**`, path-traversal-safe) — the framework's only other
222
+ server surface is unchanged.
223
+
224
+ ### Customize
225
+
226
+ Personalize → **Extend spectoflow** lets you extend the project's own spectoflow install: add a
209
227
  dashboard, a skill, or an agent by describing what you want (or hit **Auto** to have the agent survey
210
- the project and propose candidates), and it clarifies first if the ask is ambiguous. Dashboards are
211
- never raw HTML — they're a small **declarative block spec** (`markdown`, `kpi-row`, `chart-bars`,
212
- `chart-donut`, `table`, `list`, `stat-tile-row`) rendered by the same components the built-in Board
213
- uses, so a generated dashboard automatically matches whatever design is active, in both themes, and
214
- keeps matching if you switch designs later. Blocks can bind live to project stats (`bind:
215
- "phases.0.pct"`) or hold a static value. Generated skills and agents follow the same gold-standard
216
- shape as the shipped ones, cite real domain standards (OWASP, WCAG, C4/ADR, …) instead of generic
217
- advice, and are marked `origin: user-generated` so they're easy to tell apart in the UI. The same
218
- generators are available from the terminal:
228
+ the project and propose candidates) it clarifies first if the ask is ambiguous.
229
+
230
+ - Dashboards are never raw HTML they're a small **declarative block spec** (`markdown`, `kpi-row`,
231
+ `chart-bars`, `chart-donut`, `table`, `list`, `stat-tile-row`) rendered by the same components the
232
+ built-in Board uses, so a generated dashboard automatically matches whatever design is active, in
233
+ both themes, and keeps matching if you switch designs later. Blocks can bind live to project stats
234
+ (`bind: "phases.0.pct"`) or hold a static value.
235
+ - Generated skills and agents follow the same gold-standard shape as the shipped ones, cite real
236
+ domain standards (OWASP, WCAG, C4/ADR, …) instead of generic advice, and are marked `origin:
237
+ user-generated` so they're easy to tell apart in the UI.
238
+
239
+ The same generators are available from the terminal — each streams the agent's run live and exits
240
+ with its status, the same pipeline the dashboard's Generate/Auto buttons use:
219
241
 
220
242
  ```bash
221
243
  spectoflow skill create "reviews PRs for accessibility" # or: --auto to propose candidates
@@ -223,9 +245,6 @@ spectoflow agent create "owns accessibility review" # or: --auto
223
245
  spectoflow dashboard create "a KPI overview for support" # or: --auto
224
246
  ```
225
247
 
226
- Each streams the agent's run live and exits with its status — the same pipeline the dashboard's
227
- Generate/Auto buttons use, just from a shell.
228
-
229
248
  ## Agents vs skills
230
249
 
231
250
  Agents (`.spectoflow/agents/`) are **stable team personas** (Product Manager, Developer, QA Engineer…).
@@ -235,25 +254,38 @@ runs a skill. Improve a skill without touching the agent.
235
254
  Agents and skills follow real domain standards, cited in-file — TDD, OWASP ASVS/Top 10, C4/ADR,
236
255
  INVEST, Playwright E2E, Conventional Commits, and more — not generic one-liners.
237
256
 
238
- **Clarify before acting.** spectoflow is an **expert analyst, not an order-taker**. When a request is
239
- vague ("login displays badly, users can't sign in"), an always-on **Clarify reflex** — in the agent's
240
- memory (`AGENTS.md`) and backed by the `clarify` skill reflects it back and asks **one targeted
241
- question at a time**, each with a recommendation anchored in the project's goals and best practices,
242
- until the need is crisp; then it runs the normal workflow. It's additive: it feeds the router, never
243
- replaces it, and it's mode-aware.
244
-
245
- **End-to-end tests run headed, in the real browser, by default.** `write-e2e-tests` defaults to
246
- **Playwright lib, `--headed`** for its own local runs — the browser window is visible so a flow that
247
- "passes" for the wrong reason gets caught, not just a bare pass/fail line. `--ui` mode is used for
248
- authoring a flow or chasing a failure interactively. It only steps down — to headless, then
249
- **Playwright MCP**, then the client's native browser tooling (e.g. Claude Code's Chrome extension), then
250
- writing the spec and raising a `need` when you asked for something else or headed genuinely can't
251
- launch, and it **always says why** via the `::spectoflow` sentinel, never a silent switch. CI keeps
252
- running the committed suite headless that's the pipeline's job, not a fallback. `init` idempotently
253
- wires a `playwright` entry into the target project's `.mcp.json` (and `.cursor/mcp.json` for Cursor) so
254
- the MCP rung works out of the box — `npx` fetches the server on first use, so spectoflow stays zero-dep
255
- (the config lives in *your* project). The durable artifact is always the committed `*.spec.ts`; the
256
- Workflow tab's End-to-end step shows this policy in its dashboard popover.
257
+ ### Clarify before acting
258
+
259
+ spectoflow is an **expert analyst, not an order-taker**. When a request is vague ("login displays
260
+ badly, users can't sign in"), an always-on **Clarify reflex** in the agent's memory (`AGENTS.md`)
261
+ and backed by the `clarify` skill reflects it back and asks **one targeted question at a time**,
262
+ each with a recommendation anchored in the project's goals and best practices, until the need is
263
+ crisp; then it runs the normal workflow. It's additive: it feeds the router, never replaces it, and
264
+ it's mode-aware.
265
+
266
+ ### End-to-end tests run headed, in the real browser, by default
267
+
268
+ `write-e2e-tests` defaults to **Playwright lib, `--headed`** for its own local runs the browser
269
+ window is visible so a flow that "passes" for the wrong reason gets caught, not just a bare pass/fail
270
+ line. `--ui` mode is used for authoring a flow or chasing a failure interactively. It only steps down
271
+ when you asked for something else or headed genuinely can't launch and it **always says why** via
272
+ the `::spectoflow` sentinel, never a silent switch:
273
+
274
+ 1. Playwright lib, headed (default)
275
+ 2. `--ui` (interactive authoring/debugging)
276
+ 3. Playwright lib, headless
277
+ 4. **Playwright MCP**
278
+ 5. the client's native browser tooling (e.g. Claude Code's Chrome extension)
279
+ 6. write the spec and raise a `need`
280
+
281
+ CI keeps running the committed suite headless — that's the pipeline's job, not a fallback. `init`
282
+ idempotently wires a `playwright` entry into the target project's `.mcp.json` (and
283
+ `.cursor/mcp.json` for Cursor) so the MCP rung works out of the box — `npx` fetches the server on
284
+ first use, so spectoflow stays zero-dep (the config lives in *your* project). The durable artifact is
285
+ always the committed `*.spec.ts`; the Workflow tab's End-to-end step shows this policy in its
286
+ dashboard popover.
287
+
288
+ ### Keeping spec and code honest
257
289
 
258
290
  A `governance` capability adds a **Spec Source Guardian** (skill `audit-source`): it keeps the spec
259
291
  (intent) and the code/tests (reality) coherent — flagging drift in both directions, never auto-fixing,
@@ -263,12 +295,30 @@ drift helper (`lib/spec-drift.js`) and an opt-in Claude Code `Stop` hook (`hooks
263
295
  ## Language
264
296
 
265
297
  `.spectoflow/config.json` → `language` (default `en`, incl. code comments). Switchable from the CLI
266
- (`config.json`) or the dashboard's **settings** gear (mode + language).
298
+ (`config.json`), the dashboard's **Personalize** tab, or the topbar's language select directly.
267
299
 
268
300
  ## Studied, not copied
269
301
 
270
- Structure informed by spec-kit, OpenSpec (markdown + per-agent adapters), and BMAD (agent-personas).
271
- spectoflow keeps the good ideas, removes the ceremony, and adds a real-time control plane.
302
+ spectoflow's design was informed by three open-source projects worth knowing about in their own
303
+ right credit where it's due:
304
+
305
+ - **[GitHub spec-kit](https://github.com/github/spec-kit)** — the spec-driven workflow itself
306
+ (spec → plan → tasks) and its own multi-agent integration list, which shaped how spectoflow thinks
307
+ about agent-agnosticism.
308
+ - **[OpenSpec](https://github.com/Fission-AI/OpenSpec)** by Fission AI — markdown as the source of
309
+ truth and the per-agent adapter pattern (a thin native entry file per coding agent, pointing back
310
+ to one canonical brain) that `lib/adapters.js` is built on.
311
+ - **[BMAD-METHOD](https://github.com/bmad-code-org/BMAD-METHOD)** — stable, named agent personas
312
+ (PM, Architect, Developer, QA, …) as the right way to give an AI coding agent a consistent role,
313
+ rather than one undifferentiated prompt.
314
+
315
+ spectoflow keeps what worked, drops the ceremony each of those needs from you, and adds what none of
316
+ them had: a real-time local dashboard (board, chat, live agent-run tracking) and a genuinely
317
+ exhaustive, individually-verified agent-CLI compatibility list — see [Supported coding
318
+ agents](#supported-coding-agents) above.
319
+
320
+ If you use spec-kit, OpenSpec, or BMAD-METHOD today, spectoflow is worth a look; if you're evaluating
321
+ spectoflow, those three are worth a look too.
272
322
 
273
323
  ## License & author
274
324
 
package/bin/spectoflow.js CHANGED
@@ -220,7 +220,7 @@ function init() {
220
220
  console.log('');
221
221
  }
222
222
 
223
- function update() {
223
+ async function update() {
224
224
  const root = process.cwd();
225
225
  if (!fs.existsSync(path.join(root, '.spectoflow'))) {
226
226
  return console.log('No spectoflow project here. Run: spectoflow init');
@@ -252,6 +252,24 @@ function update() {
252
252
  else console.log(` ${changed ? c.g('✓ Done') : c.dim('Already up to date')}${changed ? c.dim(` · ${changed} file(s) changed`) : ''}`);
253
253
  if (r.newSidecar.length && !dryRun) console.log(` ${c.y('→')} ${c.dim(`${r.newSidecar.length} *.new file(s) to review and merge — or re-run: spectoflow update --force`)}`);
254
254
  console.log('');
255
+
256
+ // A running dashboard has the OLD framework code loaded into memory (Node caches `require()`d
257
+ // modules at process start) — new bytes on disk change nothing until it restarts. Do that
258
+ // automatically so an update always actually takes effect, instead of leaving a confusing
259
+ // half-updated dashboard (new static files, stale server logic) until someone thinks to restart.
260
+ if (!dryRun && changed) {
261
+ const lock = path.join(root, '.spectoflow', '.dashboard.lock');
262
+ let info = null;
263
+ try { info = JSON.parse(fs.readFileSync(lock, 'utf8')); } catch {}
264
+ if (info && info.port && (await probeDashboard(info.port, 2000))) {
265
+ console.log(` ${c.dim('Dashboard is running — restarting it on port ' + info.port + ' to apply the update…')}`);
266
+ // Restart on the SAME port it was already on, not resolvePort(argv)'s default — `update`
267
+ // itself was never given a --port, so a naive restartDashboard() would silently move a
268
+ // non-default-port dashboard back to 4319.
269
+ argv.push(`--port=${info.port}`);
270
+ await restartDashboard();
271
+ }
272
+ }
255
273
  }
256
274
 
257
275
  // THE launch command — routes the subcommands, then starts. Starting spawns the server DETACHED and
@@ -331,7 +349,12 @@ async function startDashboard() {
331
349
  const env = Object.assign({}, process.env, { SPECTOFLOW_PORT: String(port) });
332
350
  const child = spawn('node', [fs.existsSync(local) ? local : bundled], { detached: true, stdio: 'ignore', env });
333
351
  child.unref(); // let this CLI exit while the server keeps running
334
- console.log(`${c.g('✓')} dashboard started ${c.bold(url)} ${c.dim('(pid ' + child.pid + ')')}`);
352
+ // Confirm it actually came up (a still-releasing port from a just-stopped instance, or any other
353
+ // startup error, would otherwise print a false "started" while the detached process silently died).
354
+ let up = false;
355
+ for (let i = 0; i < 20 && !up; i++) { await new Promise((r) => setTimeout(r, 250)); up = await probeDashboard(port, 300); }
356
+ if (up) console.log(`${c.g('✓')} dashboard started → ${c.bold(url)} ${c.dim('(pid ' + child.pid + ')')}`);
357
+ else console.log(`${c.y('!')} spawned (pid ${child.pid}) but it isn't responding on ${url} yet — check ${c.g('spectoflow dashboard status')} in a moment, or its own output if something's wrong.`);
335
358
  printDashboardCommands();
336
359
  }
337
360
 
@@ -354,7 +377,11 @@ async function dashboardStatus() {
354
377
 
355
378
  async function restartDashboard() {
356
379
  await stopDashboard();
357
- await new Promise((r) => setTimeout(r, 400)); // let the port free up before rebinding
380
+ // Windows doesn't deliver real signals — process.kill() returns once the request is issued, not
381
+ // once the process (and the port it held) is actually gone. A short gap here, plus startDashboard()
382
+ // now confirming the new one actually came up, keeps a restart honest under load instead of racing
383
+ // a rebind against a socket the OS hasn't finished releasing yet.
384
+ await new Promise((r) => setTimeout(r, 1000));
358
385
  return startDashboard();
359
386
  }
360
387
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spectoflow",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "Agent-agnostic spec-driven development framework + real-time local control plane. Markdown artifacts, intent router, workflow-by-scope.",
5
5
  "keywords": [
6
6
  "spec-driven-development",
@@ -0,0 +1,108 @@
1
+ 'use strict';
2
+ /*
3
+ * File Explorer backend — browse, read, write and create files/folders anywhere under the project
4
+ * root. Kept separate from server.js (own module, like runner.js/summarize.js), since it owns a
5
+ * distinct concern: safe filesystem access scoped to the whole project, not just plans/specs/agents.
6
+ *
7
+ * Trust model matches the rest of this local dashboard (POST /api/run already spawns an arbitrary
8
+ * configured agent command): this is a single-user localhost dev tool, not a hosted multi-tenant
9
+ * service. The guard here exists to stop a path like "../../etc/passwd" from a buggy or malicious
10
+ * client, not to sandbox an untrusted operator.
11
+ */
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+
15
+ const DENY_DIRS = new Set(['.git', 'node_modules']);
16
+ const MAX_READ_BYTES = 2 * 1024 * 1024;
17
+
18
+ // Resolves `rel` against `root`, rejecting anything that normalizes outside it (path traversal).
19
+ // `root` itself is normalized first — ROOT can arrive with mixed separators (e.g. from an env var
20
+ // built by joining a Windows base path with a forward-slash suffix), and comparing an un-normalized
21
+ // root against path.resolve()'s always-normalized output would reject even legitimate children.
22
+ function safePath(root, rel) {
23
+ const rootAbs = path.resolve(root);
24
+ const cleaned = String(rel || '').replace(/^[/\\]+/, '');
25
+ const abs = path.resolve(rootAbs, cleaned);
26
+ if (abs !== rootAbs && !abs.startsWith(rootAbs + path.sep)) return null;
27
+ return abs;
28
+ }
29
+
30
+ // Symlink guard: an existing path must REALLY resolve under root, not just syntactically.
31
+ // A path that doesn't exist yet (e.g. a file about to be created) is trusted as-is — nothing to
32
+ // resolve through.
33
+ function realUnderRoot(root, abs) {
34
+ let real;
35
+ try { real = fs.realpathSync(abs); } catch { return abs; }
36
+ const realRoot = (() => { try { return fs.realpathSync(root); } catch { return root; } })();
37
+ if (real !== realRoot && !real.startsWith(realRoot + path.sep)) return null;
38
+ return real;
39
+ }
40
+
41
+ function isUnderGit(rel) {
42
+ const n = String(rel || '').replace(/\\/g, '/').replace(/^\/+/, '');
43
+ return n === '.git' || n.startsWith('.git/');
44
+ }
45
+
46
+ function buildTree(dir, relBase) {
47
+ let entries;
48
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return []; }
49
+ entries.sort((a, b) => (a.isDirectory() === b.isDirectory()) ? a.name.localeCompare(b.name) : (a.isDirectory() ? -1 : 1));
50
+ const out = [];
51
+ for (const e of entries) {
52
+ if (DENY_DIRS.has(e.name)) continue;
53
+ const rel = relBase ? relBase + '/' + e.name : e.name;
54
+ if (e.isDirectory()) out.push({ name: e.name, path: rel, type: 'dir', children: buildTree(path.join(dir, e.name), rel) });
55
+ else out.push({ name: e.name, path: rel, type: 'file' });
56
+ }
57
+ return out;
58
+ }
59
+
60
+ // A NUL byte anywhere in the first chunk means "binary" — cheap and reliable enough for a local
61
+ // preview tool (the same heuristic git and most editors use).
62
+ function isProbablyText(buf) {
63
+ const n = Math.min(buf.length, 8000);
64
+ for (let i = 0; i < n; i++) if (buf[i] === 0) return false;
65
+ return true;
66
+ }
67
+
68
+ function tree(root) {
69
+ return buildTree(root, '');
70
+ }
71
+
72
+ function readFile(root, rel) {
73
+ const abs = safePath(root, rel);
74
+ if (!abs) return { error: 'Invalid path.' };
75
+ const real = realUnderRoot(root, abs);
76
+ if (!real) return { error: 'Invalid path.' };
77
+ let stat;
78
+ try { stat = fs.statSync(real); } catch { return { error: 'Not found.' }; }
79
+ if (stat.isDirectory()) return { error: 'That is a folder.' };
80
+ if (stat.size > MAX_READ_BYTES) return { error: 'File too large to open here (>2MB).' };
81
+ const buf = fs.readFileSync(real);
82
+ if (!isProbablyText(buf)) return { binary: true, size: stat.size };
83
+ return { content: buf.toString('utf8') };
84
+ }
85
+
86
+ function writeFile(root, rel, content) {
87
+ if (typeof content !== 'string') return { error: 'Missing content.' };
88
+ if (isUnderGit(rel)) return { error: 'Writes under .git are blocked.' };
89
+ const abs = safePath(root, rel);
90
+ if (!abs) return { error: 'Invalid path.' };
91
+ const parentDir = path.dirname(abs);
92
+ if (fs.existsSync(parentDir) && !realUnderRoot(root, parentDir)) return { error: 'Invalid path.' };
93
+ fs.mkdirSync(parentDir, { recursive: true });
94
+ fs.writeFileSync(abs, content, 'utf8');
95
+ return { ok: true };
96
+ }
97
+
98
+ function mkdir(root, rel) {
99
+ if (isUnderGit(rel)) return { error: 'Cannot create folders under .git.' };
100
+ const abs = safePath(root, rel);
101
+ if (!abs) return { error: 'Invalid path.' };
102
+ const parentDir = path.dirname(abs);
103
+ if (fs.existsSync(parentDir) && !realUnderRoot(root, parentDir)) return { error: 'Invalid path.' };
104
+ fs.mkdirSync(abs, { recursive: true });
105
+ return { ok: true };
106
+ }
107
+
108
+ module.exports = { tree, readFile, writeFile, mkdir };