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 +126 -76
- package/bin/spectoflow.js +30 -3
- package/package.json +1 -1
- package/templates/dashboard/files.js +108 -0
- package/templates/dashboard/public/app.js +280 -13
- package/templates/dashboard/public/designs/console.css +35 -9
- package/templates/dashboard/public/designs/console.js +37 -0
- package/templates/dashboard/public/i18n.js +24 -24
- package/templates/dashboard/public/icons.js +8 -6
- package/templates/dashboard/public/index.html +83 -32
- package/templates/dashboard/public/styles.css +66 -21
- package/templates/dashboard/server.js +29 -18
- package/templates/dashboard/summarize.js +25 -6
- package/templates/lib/store.js +43 -0
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
|
|
105
|
-
|
|
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.
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
(
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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)
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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`)
|
|
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
|
-
|
|
271
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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 };
|