@marver-design/marver 0.15.0 → 0.16.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/CHANGELOG.md +75 -0
- package/README.md +2 -1
- package/dist/boards-6hKVW42a.mjs +57 -0
- package/dist/boards-BdG1TJwU.mjs +267 -0
- package/dist/{build-DfuTQZlY.mjs → build-Ct0iMoSi.mjs} +68 -29
- package/dist/cli.mjs +13 -4
- package/dist/{daemon-DalgvoA9.mjs → daemon-Cb4JjpgL.mjs} +1 -1
- package/dist/{dev-BxCmeU_H.mjs → dev-CdcIuhZJ.mjs} +4 -4
- package/dist/{init-QKNi9gvF.mjs → init-BQIpIpIv.mjs} +6 -3
- package/dist/{manifest-BzxSMoDB.mjs → manifest-CpbsqQ_v.mjs} +77 -11
- package/dist/{plugin-DJyjmQeh.mjs → plugin-BXyezwfN.mjs} +164 -69
- package/dist/{poster-CbpzSzJu.mjs → poster-DOY7pax8.mjs} +1 -1
- package/dist/{shot-BWhoz6cU.mjs → shot-CwmHO5T4.mjs} +2 -2
- package/docs/publish.md +4 -1
- package/package.json +1 -1
- package/src/client/shell/App.tsx +4 -246
- package/src/client/shell/BoardList.tsx +408 -0
- package/src/client/shell/ContextMenu.tsx +59 -0
- package/src/client/shell/icons.tsx +6 -0
- package/src/client/shell/store.ts +105 -47
- package/src/client/shell/styles.css +36 -4
- package/src/shared/board-tree.ts +285 -0
- package/templates/AGENTS-embedded.md +18 -5
- package/templates/AGENTS-studio.md +18 -5
- package/templates/instructions/boards.md +73 -2
- package/templates/instructions/craft.md +4 -0
- package/templates/instructions/discover.md +7 -2
- package/templates/instructions/iterate.md +5 -4
- package/templates/instructions/review.md +4 -0
- package/templates/instructions/shape.md +1 -1
- package/templates/instructions/welcome.md +4 -1
- package/templates/instructions/wireframe.md +3 -0
- package/dist/{comments-DHB_8BRa.mjs → comments-oYcZ3cE-.mjs} +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,81 @@
|
|
|
2
2
|
|
|
3
3
|
Notable changes to `@marver-design/marver`. Format follows [Keep a Changelog](https://keepachangelog.com); versions follow semver.
|
|
4
4
|
|
|
5
|
+
## 0.16.0 - 2026-09-03
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Board folders.** The sidebar's boards can live in folders, one level deep.
|
|
10
|
+
Right-click the Boards header (or its `+`) for a new folder and name it
|
|
11
|
+
inline - what you type becomes a slug ("Old stuff" → `old-stuff`, shown "Old
|
|
12
|
+
Stuff"); right-click a board for **Move to new folder** (the folder takes the
|
|
13
|
+
board's slot, the input has focus) or **Move to top level**; right-click a
|
|
14
|
+
folder to **Rename** or **Delete** it (its boards go back to the top level -
|
|
15
|
+
folders organise, never own). Moving into an existing folder is a **drag**:
|
|
16
|
+
the board drag-and-drop now lands boards inside folders (drop on the folder
|
|
17
|
+
row), in any slot inside one, back out to the root (or the left gutter of a
|
|
18
|
+
folder's rows), and drags folders among boards. A click collapses a folder; the
|
|
19
|
+
choice is remembered per browser. The folder holding the active board keeps
|
|
20
|
+
the ancestor wash so its home stays visible collapsed.
|
|
21
|
+
- **Folders are files, for agents too.** `"folder": "<name>"` on a board file
|
|
22
|
+
puts it in a folder (`order` ranks it among its folder siblings);
|
|
23
|
+
`design/boards/_folders.json` names empty folders and ranks folders at the
|
|
24
|
+
root. A folder two boards name exists without the registry. The landing
|
|
25
|
+
board is the first board in sidebar order, folders included. **`npx marver
|
|
26
|
+
boards`** prints the sidebar as the files say it is (folders, boards in
|
|
27
|
+
reading order with their `order`, the landing board; `--json` for the tree),
|
|
28
|
+
so an agent looks before it organises. The agent contract and
|
|
29
|
+
`instructions/boards.md` carry the grammar, every move (create, file in,
|
|
30
|
+
move out, rank, rename, delete) as a file edit, and a nudge to group
|
|
31
|
+
proactively past six or eight boards.
|
|
32
|
+
- **Published bundles** carry the folder tree of the published boards only; a
|
|
33
|
+
folder with nothing published never reaches the bundle. The published
|
|
34
|
+
sidebar shows folders read-only.
|
|
35
|
+
- **Descriptions - purpose notes on every object, for agents.** One optional
|
|
36
|
+
`description` (one sentence: what it is for, its state) on the project
|
|
37
|
+
(`description` in `design/config.ts`), a board (its JSON - preserved by
|
|
38
|
+
autosave and by sidebar drags like `order`/`folder`), a folder (its
|
|
39
|
+
`_folders.json` entry - it rides along through renames), a scene (the first
|
|
40
|
+
non-blank line of its `_brief.md`, `#` stripped, front matter skipped - no
|
|
41
|
+
new file) and a frame
|
|
42
|
+
(`meta.description`). `design/manifest.json` becomes the orientation file it
|
|
43
|
+
was meant to be: `project`, `folders`, `boards` (sidebar order, with folder
|
|
44
|
+
and description), `scenes` (with description and brief path) and `frames`
|
|
45
|
+
(with description). `marver dev` regenerates it on board and brief edits too,
|
|
46
|
+
and broadcasts `sh:manifest` only when the frames changed - a description
|
|
47
|
+
edit never re-keys the live iframes. Published bundles ship descriptions of
|
|
48
|
+
published things only (brief paths only with source revealed). `marver
|
|
49
|
+
boards` prints them. Editing `description` in `design/config.ts` under `dev`
|
|
50
|
+
refreshes the manifest live (the rest of the config still needs a restart).
|
|
51
|
+
The agent contract teaches: write it at creation, keep it true, fix what your
|
|
52
|
+
session made false before it ends (the review walk ends on it). Nothing
|
|
53
|
+
renders in the canvas.
|
|
54
|
+
- `export const meta` picks a literal even when the prose holds the other
|
|
55
|
+
quote (`"the buyer's path"`), and refuses a computed value (`"Draft" + phase`)
|
|
56
|
+
instead of taking its literal half.
|
|
57
|
+
|
|
58
|
+
### Changed
|
|
59
|
+
|
|
60
|
+
- `POST /__mv/api/boards/reorder` takes the WHOLE tree (`{ tree, base }`) in
|
|
61
|
+
place of `{ order }`: root boards as strings, folders as `{ folder, boards }`,
|
|
62
|
+
plus the sha256 the client last saw for every board it names and for the
|
|
63
|
+
registry. The server preflights every named board (present, a regular file,
|
|
64
|
+
well-formed, unchanged) BEFORE writing anything - a 409 names the stale
|
|
65
|
+
boards and the shell re-reads and replays its move once, so an agent's
|
|
66
|
+
concurrent `folder` edit and a human's drag can never silently erase each
|
|
67
|
+
other. It answers the new hashes, and the shell's autosave of the active
|
|
68
|
+
board keeps its CAS token current - a reorder no longer reboots the board
|
|
69
|
+
you are editing. `GET /__mv/api/boards` gains `folder`; `GET
|
|
70
|
+
/__mv/api/folders` is new.
|
|
71
|
+
- Board enumeration (dev API, build, `--all-boards`) counts only regular files
|
|
72
|
+
on the board-name grammar: `_folders.json`, temp files and symlinks are never
|
|
73
|
+
boards - the build fails closed on a symlinked board. A malformed
|
|
74
|
+
`_folders.json` is a 422 the sidebar toasts (and a build error), never a
|
|
75
|
+
silently empty registry the next drag would overwrite.
|
|
76
|
+
- The sidebar re-reads its list on a coalesced `sh:boards` broadcast (any add,
|
|
77
|
+
write or delete under `design/boards/`), so agent-written boards and folders
|
|
78
|
+
show in under half a second instead of the 8 s poll.
|
|
79
|
+
|
|
5
80
|
## 0.15.0 - 2026-09-02
|
|
6
81
|
|
|
7
82
|
### Added
|
package/README.md
CHANGED
|
@@ -40,7 +40,7 @@ Frames appear on the canvas the moment the files land. That's the loop.
|
|
|
40
40
|
|
|
41
41
|
## The canvas
|
|
42
42
|
|
|
43
|
-
- **Frames, scenes, boards.** Frames are screens, scenes group them (`design/scenes/<scene>/<frame>.tsx`), boards arrange them. Agents write `design/boards/<name>.json` (a frame list is enough); switch boards at the top of the sidebar. `all-scenes` is auto-managed. Right-click any board, scene, or frame in the sidebar to copy its path - the exact string to paste to your agent - and rename or drag-reorder boards from there too.
|
|
43
|
+
- **Frames, scenes, boards.** Frames are screens, scenes group them (`design/scenes/<scene>/<frame>.tsx`), boards arrange them. Agents write `design/boards/<name>.json` (a frame list is enough); switch boards at the top of the sidebar. `all-scenes` is auto-managed. Right-click any board, scene, or frame in the sidebar to copy its path - the exact string to paste to your agent - and rename or drag-reorder boards from there too. Boards can live in folders: right-click the Boards header (or its `+`) for a new one, name it inline, drag boards in and out and folders among boards; agents do the same by writing `"folder": "<name>"` on a board and `design/boards/_folders.json` for empty or ranked folders (`npx marver boards` prints the tree). Every object takes a one-sentence `description` (project in `design/config.ts`, boards and folders in their JSON, a scene's first `_brief.md` line, `meta.description` on a frame) and `design/manifest.json` carries them all - a new agent session orients in one read.
|
|
44
44
|
- **Devices view.** Hotkeys `1`-`5` (or the Devices menu) size every frame to mobile / tablet / laptop / monitor / tv to sweep your breakpoints; `0` restores your own layout exactly. Widths live in `design/config.ts`.
|
|
45
45
|
- **Prototype links.** `data-goto="scene/frame"` on any element links frames into a walkable prototype - across boards, too.
|
|
46
46
|
- **Five ways to view a board.** The canvas (frames on a plane), the board (the same, tidy), **present** (`p`: a full-screen clickable walkthrough - `data-goto` navigates, arrows step, `[` / `]` cycle variants, laser, comments, theme and device pickers in the toolbar), **focus** (one frame as a document - the reading preset for specs), and **slides** (a deck). A published board names its landing view; a frame deep link opens straight into it.
|
|
@@ -98,6 +98,7 @@ The same glow, driven from the terminal. When your agent takes a request, it cre
|
|
|
98
98
|
| `npx marver comments …` | The agent's queue: `connect <url>` · `sync` · `list` · `reply` · `resolve` · `invite <email>` · `revoke <email>` |
|
|
99
99
|
| `npx marver work …` | Working glow from the terminal: `start <scene/frame …>` · `done … \| --all` · `list` |
|
|
100
100
|
| `npx marver shot <frame> [--scale 1-4]` | Render one frame headless and print the PNG path (needs `dev` running); 2x by default |
|
|
101
|
+
| `npx marver boards [--json]` | The sidebar as the files say it is: folders, boards in reading order with `order` and description, the landing board |
|
|
101
102
|
|
|
102
103
|
## Shortcuts
|
|
103
104
|
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { d as isBoardName, i as listBoardFiles, l as buildTree, n as checkBoardsDir, o as readRegistry, t as boardFields, u as flatten } from "./boards-BdG1TJwU.mjs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
//#region src/cli/boards.ts
|
|
4
|
+
/**
|
|
5
|
+
* `marver boards` - the sidebar as the agent sees it: every folder and board in reading
|
|
6
|
+
* order, from the files (no dev server needed). One call answers "what folders exist, what
|
|
7
|
+
* is in them, what ranks where" before the agent writes `folder` on a board or edits
|
|
8
|
+
* `design/boards/_folders.json`. `--json` gives the tree shape the shell uses.
|
|
9
|
+
*/
|
|
10
|
+
function boardsCommand(root, opts) {
|
|
11
|
+
const dir = join(root, "design", "boards");
|
|
12
|
+
const de = checkBoardsDir(root, dir);
|
|
13
|
+
if (de) throw new Error(de);
|
|
14
|
+
const { boards, skipped } = listBoardFiles(dir);
|
|
15
|
+
const reg = readRegistry(dir);
|
|
16
|
+
if (reg.state === "malformed") throw new Error(reg.error);
|
|
17
|
+
const rows = boards.map((b) => ({
|
|
18
|
+
name: b.name,
|
|
19
|
+
...boardFields(b.json, isBoardName)
|
|
20
|
+
}));
|
|
21
|
+
const tree = buildTree(rows, reg.folders);
|
|
22
|
+
const hasAll = boards.some((b) => b.name === "all-scenes");
|
|
23
|
+
if (opts.json) {
|
|
24
|
+
console.log(JSON.stringify({
|
|
25
|
+
tree,
|
|
26
|
+
boards: rows.filter((r) => r.name !== "all-scenes"),
|
|
27
|
+
landing: flatten(tree)[0] ?? (hasAll ? "all-scenes" : null),
|
|
28
|
+
registry: reg.state === "ok" ? "design/boards/_folders.json" : null
|
|
29
|
+
}, null, 2));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (!tree.length && !hasAll) {
|
|
33
|
+
console.log("no boards yet - design/boards/ is empty");
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const order = (n) => {
|
|
37
|
+
const o = rows.find((r) => r.name === n)?.order;
|
|
38
|
+
return o === void 0 ? "" : ` order ${o}`;
|
|
39
|
+
};
|
|
40
|
+
const desc = (d) => d ? ` - ${d}` : "";
|
|
41
|
+
for (const it of tree) {
|
|
42
|
+
if (it.kind === "board") {
|
|
43
|
+
console.log(`${it.name}${order(it.name)}${desc(rows.find((r) => r.name === it.name)?.description)}`);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
console.log(`${it.name}/ (folder, ${it.boards.length} board${it.boards.length === 1 ? "" : "s"}${reg.folders.some((f) => f.name === it.name) ? "" : ", implied by its boards - not in _folders.json"})${desc(it.description)}`);
|
|
47
|
+
for (const b of it.boards) console.log(` ${b}${order(b)}${desc(rows.find((r) => r.name === b)?.description)}`);
|
|
48
|
+
if (!it.boards.length) console.log(" (empty)");
|
|
49
|
+
}
|
|
50
|
+
if (hasAll) console.log("all-scenes (auto, always last)");
|
|
51
|
+
const landing = flatten(tree)[0];
|
|
52
|
+
if (landing) console.log(`\nlanding board: ${landing}`);
|
|
53
|
+
console.log(`registry: ${reg.state === "ok" ? "design/boards/_folders.json" : "none (no empty or ranked folders yet)"}`);
|
|
54
|
+
if (skipped.length) console.log(`skipped (not regular files): ${skipped.join(", ")}`);
|
|
55
|
+
}
|
|
56
|
+
//#endregion
|
|
57
|
+
export { boardsCommand };
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from "node:fs";
|
|
2
|
+
import { join, sep } from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
//#region src/server/hash.ts
|
|
5
|
+
/** sha256 hex of a string - the CAS token for board files, the registry, the manifest. */
|
|
6
|
+
const hash = (s) => createHash("sha256").update(s).digest("hex");
|
|
7
|
+
//#endregion
|
|
8
|
+
//#region src/shared/board-tree.ts
|
|
9
|
+
/**
|
|
10
|
+
* Board folders - the pure tree shared by the sidebar, the dev API, the build and the
|
|
11
|
+
* tests. Files are the truth: a board says which folder it sits in (`folder` on the
|
|
12
|
+
* board file, ranked among its siblings by `order`), and `design/boards/_folders.json`
|
|
13
|
+
* says which folders exist and where they rank at the root. One level only: folders
|
|
14
|
+
* hold boards, never folders. `all-scenes` never enters the tree - callers pin it last.
|
|
15
|
+
*/
|
|
16
|
+
/** The on-disk name grammar shared by boards and folders (a board name is a filename). */
|
|
17
|
+
const BOARD_NAME = /^[a-z0-9][a-z0-9-]*$/;
|
|
18
|
+
const isBoardName = (n) => typeof n === "string" && n.length >= 1 && n.length <= 64 && BOARD_NAME.test(n);
|
|
19
|
+
/** The folder registry beside the boards - underscore = infrastructure, never a board. */
|
|
20
|
+
const FOLDERS_FILE = "_folders.json";
|
|
21
|
+
/** Is this basename in design/boards/ a board file? `_folders.json`, temp files and any
|
|
22
|
+
* off-grammar name are not - every lister (dev API, build, watcher) shares this rule. */
|
|
23
|
+
const isBoardFile = (f) => f.endsWith(".json") && isBoardName(f.slice(0, -5));
|
|
24
|
+
const readDescription = (v) => {
|
|
25
|
+
if (typeof v !== "string") return void 0;
|
|
26
|
+
return v.trim().replace(/\s+/g, " ").slice(0, 300) || void 0;
|
|
27
|
+
};
|
|
28
|
+
const rank = (o) => typeof o === "number" && Number.isFinite(o) ? o : Infinity;
|
|
29
|
+
/** The registry file's shape. Returns the rows, or a string naming what is wrong - a
|
|
30
|
+
* malformed registry is an ERROR the human must fix (silently reading it as empty would
|
|
31
|
+
* let the next drag overwrite their folders), while a missing file is simply no folders. */
|
|
32
|
+
function parseFolders(raw) {
|
|
33
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return "expected an object";
|
|
34
|
+
const { version, folders } = raw;
|
|
35
|
+
if (version !== void 0 && version !== 1) return `unsupported version ${String(version)}`;
|
|
36
|
+
if (!Array.isArray(folders)) return "expected a \"folders\" array";
|
|
37
|
+
const out = [];
|
|
38
|
+
const seen = /* @__PURE__ */ new Set();
|
|
39
|
+
for (const f of folders) {
|
|
40
|
+
const name = f?.name;
|
|
41
|
+
if (!isBoardName(name)) return "a folder needs a name - lowercase letters, numbers and dashes";
|
|
42
|
+
if (seen.has(name)) return `folder "${name}" is listed twice`;
|
|
43
|
+
seen.add(name);
|
|
44
|
+
const o = f.order;
|
|
45
|
+
const d = readDescription(f.description);
|
|
46
|
+
out.push({
|
|
47
|
+
name,
|
|
48
|
+
...typeof o === "number" && Number.isFinite(o) ? { order: o } : {},
|
|
49
|
+
...d ? { description: d } : {}
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
/** Sidebar order from the files. Root: boards with no folder + every folder (registered
|
|
55
|
+
* or implied by a board), ranked by `order` then kind (board before folder) then name.
|
|
56
|
+
* Inside a folder: its boards by `order` then name. Unranked sorts after ranked. */
|
|
57
|
+
function buildTree(boards, folders) {
|
|
58
|
+
const folderOrder = /* @__PURE__ */ new Map();
|
|
59
|
+
const folderDesc = /* @__PURE__ */ new Map();
|
|
60
|
+
for (const f of folders) if (isBoardName(f.name) && !folderOrder.has(f.name)) {
|
|
61
|
+
folderOrder.set(f.name, f.order);
|
|
62
|
+
if (f.description) folderDesc.set(f.name, f.description);
|
|
63
|
+
}
|
|
64
|
+
const members = /* @__PURE__ */ new Map();
|
|
65
|
+
const rootBoards = [];
|
|
66
|
+
for (const b of boards) {
|
|
67
|
+
if (!isBoardName(b.name) || b.name === "all-scenes") continue;
|
|
68
|
+
const folder = isBoardName(b.folder) ? b.folder : void 0;
|
|
69
|
+
if (!folder) {
|
|
70
|
+
rootBoards.push(b);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (!folderOrder.has(folder)) folderOrder.set(folder, void 0);
|
|
74
|
+
const list = members.get(folder) ?? [];
|
|
75
|
+
list.push(b);
|
|
76
|
+
members.set(folder, list);
|
|
77
|
+
}
|
|
78
|
+
const byRank = (a, b) => rank(a.order) - rank(b.order) || a.name.localeCompare(b.name);
|
|
79
|
+
const root = [...rootBoards.map((b) => ({
|
|
80
|
+
item: {
|
|
81
|
+
kind: "board",
|
|
82
|
+
name: b.name
|
|
83
|
+
},
|
|
84
|
+
order: b.order
|
|
85
|
+
})), ...[...folderOrder].map(([name, order]) => ({
|
|
86
|
+
item: {
|
|
87
|
+
kind: "folder",
|
|
88
|
+
name,
|
|
89
|
+
boards: (members.get(name) ?? []).sort(byRank).map((b) => b.name),
|
|
90
|
+
...folderDesc.has(name) ? { description: folderDesc.get(name) } : {}
|
|
91
|
+
},
|
|
92
|
+
order
|
|
93
|
+
}))];
|
|
94
|
+
root.sort((a, b) => rank(a.order) - rank(b.order) || (a.item.kind === b.item.kind ? 0 : a.item.kind === "board" ? -1 : 1) || a.item.name.localeCompare(b.item.name));
|
|
95
|
+
return root.map((r) => r.item);
|
|
96
|
+
}
|
|
97
|
+
/** Every board in reading order - the order the switchers and the landing pick use. */
|
|
98
|
+
function flatten(tree) {
|
|
99
|
+
const out = [];
|
|
100
|
+
for (const it of tree) if (it.kind === "board") out.push(it.name);
|
|
101
|
+
else out.push(...it.boards);
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
function validateWire(wire) {
|
|
105
|
+
if (!Array.isArray(wire)) return "invalid tree";
|
|
106
|
+
const boards = /* @__PURE__ */ new Set(), folders = /* @__PURE__ */ new Set();
|
|
107
|
+
const board = (n) => {
|
|
108
|
+
if (!isBoardName(n) || n === "all-scenes") return "invalid board name in tree";
|
|
109
|
+
if (boards.has(n)) return `board "${n}" appears twice`;
|
|
110
|
+
boards.add(n);
|
|
111
|
+
return null;
|
|
112
|
+
};
|
|
113
|
+
for (const w of wire) {
|
|
114
|
+
if (typeof w === "string") {
|
|
115
|
+
const e = board(w);
|
|
116
|
+
if (e) return e;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (!w || typeof w !== "object" || Array.isArray(w)) return "invalid tree item";
|
|
120
|
+
const { folder, boards: kids, description } = w;
|
|
121
|
+
if (!isBoardName(folder)) return "invalid folder name in tree";
|
|
122
|
+
if (description !== void 0 && (typeof description !== "string" || description.length > 300)) return "invalid folder description";
|
|
123
|
+
if (folders.has(folder)) return `folder "${folder}" appears twice`;
|
|
124
|
+
folders.add(folder);
|
|
125
|
+
if (!Array.isArray(kids)) return "invalid folder in tree";
|
|
126
|
+
for (const k of kids) {
|
|
127
|
+
const e = board(k);
|
|
128
|
+
if (e) return e;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (boards.size > 200 || folders.size > 50) return "tree too large";
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
//#endregion
|
|
135
|
+
//#region src/server/boards.ts
|
|
136
|
+
/**
|
|
137
|
+
* Reading design/boards/ safely - the one enumerator the dev API, the build and the tests
|
|
138
|
+
* share. Only REGULAR files on the board-name grammar count as boards (a symlink, dangling or
|
|
139
|
+
* live, could read or publish JSON from outside the project - it is skipped, and reported so
|
|
140
|
+
* the build can fail closed). The folder registry is read the same way: absent = no folders,
|
|
141
|
+
* malformed = an error the human must fix, never a silently empty registry.
|
|
142
|
+
*/
|
|
143
|
+
/** Does realpath(dir) stay inside realpath(root)? A symlinked design/boards can't escape. */
|
|
144
|
+
function underRoot(root, dir) {
|
|
145
|
+
try {
|
|
146
|
+
const rr = realpathSync(root);
|
|
147
|
+
const rd = realpathSync(dir);
|
|
148
|
+
return rd === rr || rd.startsWith(rr + sep);
|
|
149
|
+
} catch {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/** Is design/boards a directory we may read and write? It must not be a symlink at all (a
|
|
154
|
+
* link to the repo root would list package.json as a board and let a tree write rewrite
|
|
155
|
+
* it; a link outside would publish foreign JSON) and must resolve inside the root. Absent
|
|
156
|
+
* is fine (no boards yet). Returns the error, or null. */
|
|
157
|
+
function checkBoardsDir(root, boardsDir) {
|
|
158
|
+
const design = join(boardsDir, "..");
|
|
159
|
+
for (const [p, label] of [[design, "design"], [boardsDir, "design/boards"]]) {
|
|
160
|
+
try {
|
|
161
|
+
if (lstatSync(p).isSymbolicLink()) return `${label} must be a real directory, not a symlink`;
|
|
162
|
+
} catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
if (!underRoot(root, p)) return `${label} escapes the project`;
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
/** A regular file (lstat: a symlink is never followed, a dangling one is not "absent"). */
|
|
170
|
+
const isRegularFile = (p) => {
|
|
171
|
+
try {
|
|
172
|
+
return lstatSync(p).isFile();
|
|
173
|
+
} catch {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
/** Is there ANY node at p (a dangling symlink counts)? */
|
|
178
|
+
const nodeExists = (p) => {
|
|
179
|
+
try {
|
|
180
|
+
lstatSync(p);
|
|
181
|
+
return true;
|
|
182
|
+
} catch {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
/** Every board file: name, raw content, hash, and its JSON (null when malformed). `skipped`
|
|
187
|
+
* names the entries that looked like boards but were not regular files. */
|
|
188
|
+
function listBoardFiles(boardsDir) {
|
|
189
|
+
const boards = [], skipped = [];
|
|
190
|
+
if (!existsSync(boardsDir)) return {
|
|
191
|
+
boards,
|
|
192
|
+
skipped
|
|
193
|
+
};
|
|
194
|
+
for (const f of readdirSync(boardsDir)) {
|
|
195
|
+
if (!isBoardFile(f)) continue;
|
|
196
|
+
const file = join(boardsDir, f);
|
|
197
|
+
if (!isRegularFile(file)) {
|
|
198
|
+
skipped.push(f);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const content = readFileSync(file, "utf8");
|
|
202
|
+
let json = null;
|
|
203
|
+
try {
|
|
204
|
+
json = JSON.parse(content);
|
|
205
|
+
} catch {}
|
|
206
|
+
boards.push({
|
|
207
|
+
name: f.slice(0, -5),
|
|
208
|
+
file,
|
|
209
|
+
content,
|
|
210
|
+
sha256: hash(content),
|
|
211
|
+
json
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
boards,
|
|
216
|
+
skipped
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
/** The author-owned sidebar fields off a board's JSON, leniently. */
|
|
220
|
+
function boardFields(json, validName) {
|
|
221
|
+
const o = json;
|
|
222
|
+
const description = readDescription(o?.description);
|
|
223
|
+
return {
|
|
224
|
+
...typeof o?.order === "number" && Number.isFinite(o.order) ? { order: o.order } : {},
|
|
225
|
+
...validName(o?.folder) ? { folder: o.folder } : {},
|
|
226
|
+
...description ? { description } : {}
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
/** The folder registry. `sha256` is the CAS token a tree write must echo (null = "there was
|
|
230
|
+
* no file"), so a write can never silently replace a registry it never saw. */
|
|
231
|
+
function readRegistry(boardsDir) {
|
|
232
|
+
const p = join(boardsDir, FOLDERS_FILE);
|
|
233
|
+
if (!nodeExists(p)) return {
|
|
234
|
+
state: "absent",
|
|
235
|
+
folders: [],
|
|
236
|
+
sha256: null
|
|
237
|
+
};
|
|
238
|
+
if (!isRegularFile(p)) return {
|
|
239
|
+
state: "malformed",
|
|
240
|
+
error: `design/boards/${FOLDERS_FILE} must be a regular file, not a symlink`,
|
|
241
|
+
sha256: null
|
|
242
|
+
};
|
|
243
|
+
const content = readFileSync(p, "utf8");
|
|
244
|
+
let raw;
|
|
245
|
+
try {
|
|
246
|
+
raw = JSON.parse(content);
|
|
247
|
+
} catch {
|
|
248
|
+
return {
|
|
249
|
+
state: "malformed",
|
|
250
|
+
error: `design/boards/${FOLDERS_FILE} is not valid JSON - fix the file`,
|
|
251
|
+
sha256: hash(content)
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
const parsed = parseFolders(raw);
|
|
255
|
+
if (typeof parsed === "string") return {
|
|
256
|
+
state: "malformed",
|
|
257
|
+
error: `design/boards/${FOLDERS_FILE}: ${parsed}`,
|
|
258
|
+
sha256: hash(content)
|
|
259
|
+
};
|
|
260
|
+
return {
|
|
261
|
+
state: "ok",
|
|
262
|
+
folders: parsed,
|
|
263
|
+
sha256: hash(content)
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
//#endregion
|
|
267
|
+
export { nodeExists as a, FOLDERS_FILE as c, isBoardName as d, readDescription as f, listBoardFiles as i, buildTree as l, hash as m, checkBoardsDir as n, readRegistry as o, validateWire as p, isRegularFile as r, BOARD_NAME as s, boardFields as t, flatten as u };
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { i as ROUTE, n as NAME } from "./cli.mjs";
|
|
2
|
-
import { l as
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { i as listBoardFiles, l as buildTree, n as checkBoardsDir, o as readRegistry, u as flatten } from "./boards-BdG1TJwU.mjs";
|
|
3
|
+
import { c as detectHost, n as scanFrames, o as loadConfig } from "./manifest-CpbsqQ_v.mjs";
|
|
4
|
+
import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-BXyezwfN.mjs";
|
|
5
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
5
6
|
import { basename, dirname, join, sep } from "node:path";
|
|
6
7
|
import { fileURLToPath } from "node:url";
|
|
7
8
|
import { createHash, randomBytes } from "node:crypto";
|
|
@@ -148,22 +149,67 @@ function resolvePolicy(root, allBoards, boardsFlag, allBoardsFlag) {
|
|
|
148
149
|
reveal
|
|
149
150
|
};
|
|
150
151
|
}
|
|
151
|
-
/** Read every board file; returns name -> parsed json. Bad JSON fails the build loudly
|
|
152
|
+
/** Read every board file; returns name -> parsed json. Bad JSON fails the build loudly, and so
|
|
153
|
+
* does a board-named entry that is not a regular file (a symlink could publish JSON from
|
|
154
|
+
* outside the project - the build fails closed rather than skipping it quietly). */
|
|
152
155
|
function readBoards(root) {
|
|
153
156
|
const dir = join(root, "design", "boards");
|
|
157
|
+
const de = checkBoardsDir(root, dir);
|
|
158
|
+
if (de) throw new Error(de);
|
|
159
|
+
const { boards, skipped } = listBoardFiles(dir);
|
|
160
|
+
if (skipped.length) throw new Error(`design/boards/${skipped[0]} is not a regular file (a symlinked board cannot be published)`);
|
|
154
161
|
const out = {};
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
const name = f.replace(/\.json$/, "");
|
|
159
|
-
try {
|
|
160
|
-
out[name] = JSON.parse(readFileSync(join(dir, f), "utf8"));
|
|
161
|
-
} catch {
|
|
162
|
-
throw new Error(`design/boards/${f} is not valid JSON`);
|
|
163
|
-
}
|
|
162
|
+
for (const b of boards) {
|
|
163
|
+
if (b.json === null) throw new Error(`design/boards/${b.name}.json is not valid JSON`);
|
|
164
|
+
out[b.name] = b.json;
|
|
164
165
|
}
|
|
165
166
|
return out;
|
|
166
167
|
}
|
|
168
|
+
/** Switcher order = the sidebar's reading order: the folder tree over the PUBLISHED boards only
|
|
169
|
+
* (a folder left with no published board drops out - its name never reaches the bundle),
|
|
170
|
+
* flattened depth-first; all-scenes always LAST (it is the expensive everything-board, never
|
|
171
|
+
* the landing). `names[0]` is where `/` opens. Folder names of published boards are structure,
|
|
172
|
+
* like board names: they ship. */
|
|
173
|
+
function publishedTree(published, allBoards, folders) {
|
|
174
|
+
const field = (n, k) => allBoards[n]?.[k];
|
|
175
|
+
const tree = buildTree(published.filter((n) => n !== "all-scenes").map((n) => ({
|
|
176
|
+
name: n,
|
|
177
|
+
order: field(n, "order"),
|
|
178
|
+
folder: field(n, "folder")
|
|
179
|
+
})), folders).filter((it) => it.kind === "board" || it.boards.length > 0);
|
|
180
|
+
return {
|
|
181
|
+
tree,
|
|
182
|
+
names: [...flatten(tree), ...published.includes("all-scenes") ? ["all-scenes"] : []]
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
/** The bundle's manifest: the published frames, and descriptions of published things ONLY -
|
|
186
|
+
* the project's, the published scenes' (their brief path only when source is revealed),
|
|
187
|
+
* the published boards' and the folders those sit in. No unpublished name or sentence. */
|
|
188
|
+
function publishedManifest(manifest, pubFrames, publishedNames, strip) {
|
|
189
|
+
const pubScenes = new Set(pubFrames.map((f) => f.scene));
|
|
190
|
+
const pubBoardSet = new Set(publishedNames);
|
|
191
|
+
const pubBoards = (manifest.boards ?? []).filter((b) => pubBoardSet.has(b.name));
|
|
192
|
+
const pubFolderSet = new Set(pubBoards.map((b) => b.folder).filter(Boolean));
|
|
193
|
+
const pubFolders = (manifest.folders ?? []).filter((f) => pubFolderSet.has(f.name));
|
|
194
|
+
return {
|
|
195
|
+
...manifest.project ? { project: manifest.project } : {},
|
|
196
|
+
...pubFolders.length ? { folders: pubFolders } : {},
|
|
197
|
+
...pubBoards.length ? { boards: pubBoards } : {},
|
|
198
|
+
scenes: manifest.scenes.filter((s) => pubScenes.has(s.name)).map(({ name, description, brief }) => ({
|
|
199
|
+
name,
|
|
200
|
+
frames: pubFrames.filter((f) => f.scene === name).length,
|
|
201
|
+
...description ? { description } : {},
|
|
202
|
+
...brief && !strip ? { brief } : {}
|
|
203
|
+
})),
|
|
204
|
+
frames: pubFrames
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
/** The folder registry: absent = no folders (boards still imply theirs); malformed fails the build. */
|
|
208
|
+
function readFolders(root) {
|
|
209
|
+
const reg = readRegistry(join(root, "design", "boards"));
|
|
210
|
+
if (reg.state === "malformed") throw new Error(reg.error);
|
|
211
|
+
return reg.folders;
|
|
212
|
+
}
|
|
167
213
|
/**
|
|
168
214
|
* The generated registry with OPAQUE keys - the source strip's half of the
|
|
169
215
|
* bundle (01-sharing §6.2). Object keys are string literals that survive
|
|
@@ -267,18 +313,17 @@ async function buildSite(root, boardsFlag, allBoardsFlag, embedSeeds) {
|
|
|
267
313
|
const pkgDir = packageDir();
|
|
268
314
|
const clientDir = join(pkgDir, "src", "client");
|
|
269
315
|
const outDir = join(root, "design", ".dist");
|
|
270
|
-
const manifest = scanFrames(root
|
|
316
|
+
const manifest = scanFrames(root, {
|
|
317
|
+
name: config.share.name || basename(root),
|
|
318
|
+
description: config.description
|
|
319
|
+
});
|
|
271
320
|
const allBoards = readBoards(root);
|
|
272
321
|
const policy = resolvePolicy(root, allBoards, boardsFlag, allBoardsFlag);
|
|
273
322
|
const rights = Object.fromEntries(Object.entries(policy.boards).map(([n, p]) => [n, p.max]));
|
|
274
323
|
const strip = !policy.reveal.source;
|
|
275
324
|
const buildSalt = randomBytes(16).toString("hex");
|
|
276
325
|
const opaquePath = (id, ext) => `__mv/f/mv${createHash("sha256").update(buildSalt).update(id).digest("hex").slice(0, 12)}${ext}`;
|
|
277
|
-
const
|
|
278
|
-
const o = allBoards[n]?.order;
|
|
279
|
-
return typeof o === "number" && Number.isFinite(o) ? o : Infinity;
|
|
280
|
-
};
|
|
281
|
-
const publishedNames = Object.keys(rights).sort((a, b) => a === "all-scenes" ? 1 : b === "all-scenes" ? -1 : boardOrder(a) - boardOrder(b) || a.localeCompare(b));
|
|
326
|
+
const { tree, names: publishedNames } = publishedTree(Object.keys(rights), allBoards, readFolders(root));
|
|
282
327
|
const includeAll = publishedNames.includes("all-scenes");
|
|
283
328
|
const boards = {};
|
|
284
329
|
for (const n of publishedNames) if (allBoards[n]) boards[n] = allBoards[n];
|
|
@@ -288,17 +333,10 @@ async function buildSite(root, boardsFlag, allBoardsFlag, embedSeeds) {
|
|
|
288
333
|
for (const b of Object.values(boards)) for (const node of Array.isArray(b?.nodes) ? b.nodes : []) if (typeof node?.frame === "string") wanted.add(node.frame);
|
|
289
334
|
frames = manifest.frames.filter((f) => wanted.has(f.id));
|
|
290
335
|
}
|
|
291
|
-
const
|
|
336
|
+
const pubManifest = publishedManifest(manifest, strip ? frames.map((f) => ({
|
|
292
337
|
...f,
|
|
293
338
|
file: opaquePath(f.id, f.kind === "html" ? ".html" : "")
|
|
294
|
-
})) : frames;
|
|
295
|
-
const pubManifest = {
|
|
296
|
-
frames: pubFrames,
|
|
297
|
-
scenes: [...new Set(pubFrames.map((f) => f.scene))].sort().map((name) => ({
|
|
298
|
-
name,
|
|
299
|
-
frames: pubFrames.filter((f) => f.scene === name).length
|
|
300
|
-
}))
|
|
301
|
-
};
|
|
339
|
+
})) : frames, publishedNames, strip);
|
|
302
340
|
for (const n of publishedNames) {
|
|
303
341
|
const pb = policy.boards[n];
|
|
304
342
|
if (pb?.type !== "slides" && pb?.open !== "slides") continue;
|
|
@@ -330,6 +368,7 @@ async function buildSite(root, boardsFlag, allBoardsFlag, embedSeeds) {
|
|
|
330
368
|
manifest: pubManifest,
|
|
331
369
|
boards,
|
|
332
370
|
names: publishedNames,
|
|
371
|
+
tree,
|
|
333
372
|
default: publishedNames.find((n) => n !== "all-scenes") ?? publishedNames[0],
|
|
334
373
|
rights,
|
|
335
374
|
policy: {
|
|
@@ -461,7 +500,7 @@ async function buildSite(root, boardsFlag, allBoardsFlag, embedSeeds) {
|
|
|
461
500
|
const realAssets = existsSync(assetsDir) ? realpathSync(assetsDir) : null;
|
|
462
501
|
for (const r of refs) {
|
|
463
502
|
if (!isLocalAssetRef(r) || !r.endsWith(".poster.png") || existsSync(join(assetsDir, r))) continue;
|
|
464
|
-
const { ensurePoster } = await import("./poster-
|
|
503
|
+
const { ensurePoster } = await import("./poster-DOY7pax8.mjs");
|
|
465
504
|
const g = await ensurePoster(assetsDir, r.slice(0, -11));
|
|
466
505
|
if (!g.ok) throw new Error(`design/assets/${r}: ${g.error}`);
|
|
467
506
|
console.log(` poster: rendered design/assets/${r} (${g.width}×${g.height})`);
|
package/dist/cli.mjs
CHANGED
|
@@ -53,14 +53,14 @@ function version() {
|
|
|
53
53
|
}
|
|
54
54
|
const cli = cac(NAME);
|
|
55
55
|
cli.command("init", "Scaffold design/ in this repo").option("--mode <mode>", "studio | embedded", { default: "studio" }).option("--no-demo", "Skip the demo scene (the demo ships unless this flag is passed)").option("--root <dir>", "Host repo root", { default: "." }).action(async (opts) => {
|
|
56
|
-
const { init } = await import("./init-
|
|
56
|
+
const { init } = await import("./init-BQIpIpIv.mjs");
|
|
57
57
|
init(resolve(opts.root), {
|
|
58
58
|
mode: opts.mode === "embedded" ? "embedded" : "studio",
|
|
59
59
|
demo: opts.demo !== false
|
|
60
60
|
});
|
|
61
61
|
});
|
|
62
62
|
for (const [name, desc] of [["dev", "Start the local canvas (everything on: hot reload, comments, Live Jam)"], ["canvas", "Start the local canvas - same as dev"]]) cli.command(name, desc).option("--root <dir>", "Host repo root", { default: "." }).option("--port <port>", "Port (default 5199)").action(async (opts) => {
|
|
63
|
-
const { dev } = await import("./dev-
|
|
63
|
+
const { dev } = await import("./dev-CdcIuhZJ.mjs");
|
|
64
64
|
let port;
|
|
65
65
|
if (opts.port !== void 0) {
|
|
66
66
|
const n = Number(opts.port);
|
|
@@ -70,7 +70,7 @@ for (const [name, desc] of [["dev", "Start the local canvas (everything on: hot
|
|
|
70
70
|
await dev(resolve(opts.root), port);
|
|
71
71
|
});
|
|
72
72
|
cli.command("build", "Static export → design/.dist (what ships comes from design/publish.json - publishing is default-closed)").option("--boards <names>", "Publish only these boards (comma-separated); overrides the publish policy").option("--all-boards", "Publish every board - the loud override for the default-closed policy").option("--embed-seeds", "Copy comment history INTO the web root (identifying - every event carries its author's email)").option("--root <dir>", "Host repo root", { default: "." }).action(async (opts) => {
|
|
73
|
-
const { buildSite } = await import("./build-
|
|
73
|
+
const { buildSite } = await import("./build-Ct0iMoSi.mjs");
|
|
74
74
|
try {
|
|
75
75
|
const boards = opts.boards === void 0 ? void 0 : typeof opts.boards === "string" ? opts.boards : "";
|
|
76
76
|
await buildSite(resolve(opts.root), boards, opts.allBoards === true, opts.embedSeeds === true);
|
|
@@ -89,7 +89,7 @@ cli.command("serve", "Serve design/.dist (set MARVER_PASSWORD to gate it)").opti
|
|
|
89
89
|
serve(resolve(opts.root), port);
|
|
90
90
|
});
|
|
91
91
|
cli.command("comments <action> [value]", "Comment collaboration: connect <url> · sync · list · reply <thread> · resolve <thread> · invite <email> · revoke <email>").option("--root <dir>", "Host repo root", { default: "." }).option("--token <token>", "connect: the canvas's MARVER_CLI_TOKEN (default $MARVER_CLI_TOKEN) - the identity-mode path").option("--invite <token>", "connect: claim this invite instead of signing in").option("--canvas-password <password>", "connect: the canvas gate password (default $MARVER_PASSWORD or prompt)").option("--email <email>", "connect: account email (skips the prompt)").option("--password <password>", "connect: account password (skips the prompt - mind your shell history)").option("--name <name>", "connect --invite: display name for the new account").option("--open", "list: only unresolved threads").option("--json", "list: machine-readable output").option("--board <board>", "scope to one board").option("--body <text>", "reply: the reply text").option("--addressed-in <frame>", "resolve: the variant frame that answered the feedback").action(async (action, value, opts) => {
|
|
92
|
-
const { commentsCommand } = await import("./comments-
|
|
92
|
+
const { commentsCommand } = await import("./comments-oYcZ3cE-.mjs");
|
|
93
93
|
try {
|
|
94
94
|
await commentsCommand(resolve(opts.root), action, value, opts);
|
|
95
95
|
} catch (err) {
|
|
@@ -115,6 +115,15 @@ cli.command("work <action> [...frames]", "Working state on the canvas: start <sc
|
|
|
115
115
|
process.exit(1);
|
|
116
116
|
}
|
|
117
117
|
});
|
|
118
|
+
cli.command("boards", "The sidebar as files: every folder and board in reading order, the landing board, the registry").option("--root <dir>", "Host repo root", { default: "." }).option("--json", "The tree as JSON ({ tree, landing, registry })").action(async (opts) => {
|
|
119
|
+
const { boardsCommand } = await import("./boards-6hKVW42a.mjs");
|
|
120
|
+
try {
|
|
121
|
+
boardsCommand(resolve(opts.root), opts);
|
|
122
|
+
} catch (err) {
|
|
123
|
+
console.error(`[${NAME}] ${err.message}`);
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
118
127
|
cli.command("shot <frame>", "Render one frame headless and print the PNG path (needs `dev` running)").option("--root <dir>", "Host repo root", { default: "." }).option("--theme <name>", "Theme to render (default: light)").option("--scale <n>", "Device pixels per CSS px, 1-4 (default: 2; 4 for print-quality stills)").action(async (frame, opts) => {
|
|
119
128
|
const { shotCommand } = await import("./shot-By1AItpD.mjs");
|
|
120
129
|
try {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { n as replay } from "./events-B3LBn74P.mjs";
|
|
2
2
|
import { i as readLog, r as listBoards, t as appendEvents } from "./comments-DZyobpxG.mjs";
|
|
3
3
|
import { n as localProfile } from "./profile-BjAPAJSb.mjs";
|
|
4
|
-
import {
|
|
4
|
+
import { r as toFrameId } from "./manifest-CpbsqQ_v.mjs";
|
|
5
5
|
import { workActivity } from "./work-CLrmY-vQ.mjs";
|
|
6
6
|
import { r as deviceId, t as has } from "./ledger-Bu0BjqIe.mjs";
|
|
7
7
|
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, watch, writeFileSync, writeSync } from "node:fs";
|