@marver-design/marver 0.10.1 → 0.11.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 +176 -0
- package/dist/auth-B5yuwnOq.mjs +494 -0
- package/dist/{build-BkckEcZd.mjs → build-Cr_ZLMdq.mjs} +3 -2
- package/dist/cli.mjs +6 -6
- package/dist/{collab-s3k5byM1.mjs → collab-C-n-uQdm.mjs} +67 -11
- package/dist/{comments-J06jqCVV.mjs → comments-CRqaO2MH.mjs} +26 -11
- package/dist/{comments-BZBKhKRO.mjs → comments-DZyobpxG.mjs} +8 -2
- package/dist/{manifest-DIsp3ldB.mjs → config-t9coJ-Pq.mjs} +3 -233
- package/dist/{daemon-B_8BmmSJ.mjs → daemon-DSmaS453.mjs} +24 -6
- package/dist/{dev-Bm-R_-gs.mjs → dev-CLBjRs0J.mjs} +126 -12
- package/dist/{init-D2EQEbV6.mjs → init-BSclDg4I.mjs} +9 -29
- package/dist/{ledger-BgA7nQoH.mjs → ledger-Bu0BjqIe.mjs} +1 -12
- package/dist/managed-HwHVNI3h.mjs +94 -0
- package/dist/manifest-C2tzkNaC.mjs +233 -0
- package/dist/marver-id-B8-3WiHk.mjs +411 -0
- package/dist/marver-id-gate-BDzW6ahN.mjs +728 -0
- package/dist/{plugin-DhkR3NdW.mjs → plugin-vE7sxEeT.mjs} +18 -80
- package/dist/{profile-BkiWglVE.mjs → profile-DcsJyppw.mjs} +9 -6
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/dist/{serve-CZqPnj19.mjs → serve-CZ1KEQxd.mjs} +194 -42
- package/dist/{shot-BRgPaFCs.mjs → shot-Cyv3GN79.mjs} +140 -13
- package/dist/sync-DELGomPk.mjs +397 -0
- package/dist/update-DuWDj5nR.mjs +77 -0
- package/dist/utm-CxC3QN5X.mjs +20 -0
- package/package.json +1 -1
- package/src/client/content/img-lod.ts +6 -0
- package/src/client/frame-host/bridge.js +3 -1
- package/src/client/frame-host/main.tsx +4 -1
- package/src/client/shell/App.tsx +5 -0
- package/src/client/shell/canvas/FrameNode.tsx +45 -6
- package/src/client/shell/canvas/ready-watch.ts +28 -0
- package/src/client/shell/store.ts +27 -6
- package/src/client/shell/styles.css +8 -0
- package/src/shared/utm.ts +2 -2
- package/templates/instructions/configure.md +4 -0
- package/templates/instructions/jam.md +4 -0
- package/templates/instructions/publish.md +84 -15
- package/dist/auth-KQ9Aj-nB.mjs +0 -245
- package/dist/events-BMtBvvgU.mjs +0 -101
- package/dist/sync-BJKKmy1n.mjs +0 -150
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { n as NAME, r as PKG } from "./cli.mjs";
|
|
2
|
-
import {
|
|
3
|
-
import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-
|
|
4
|
-
import {
|
|
2
|
+
import { i as detectHost, n as loadConfig } from "./config-t9coJ-Pq.mjs";
|
|
3
|
+
import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-vE7sxEeT.mjs";
|
|
4
|
+
import { realpathSync } from "node:fs";
|
|
5
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
5
6
|
import { fileURLToPath } from "node:url";
|
|
6
7
|
import { createLogger, createServer, searchForWorkspaceRoot } from "vite";
|
|
7
8
|
import react from "@vitejs/plugin-react";
|
|
@@ -44,6 +45,106 @@ async function pickPort(root, desired) {
|
|
|
44
45
|
fellBack: true
|
|
45
46
|
};
|
|
46
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* What the dev server must never hand out, however deep in the repo it sits.
|
|
50
|
+
*
|
|
51
|
+
* `marver dev` puts the whole repository on the web so frames can import from it.
|
|
52
|
+
* The collaboration credential no longer lives in there - it moved to
|
|
53
|
+
* ~/.marver/canvases/ precisely because guarding it here could not be finished -
|
|
54
|
+
* but design/.local still holds the local profile and whatever a previous version
|
|
55
|
+
* left behind, and none of that is anybody's business but the author's.
|
|
56
|
+
*
|
|
57
|
+
* Vite's own defaults are restated rather than assumed. `fs.deny` REPLACES the
|
|
58
|
+
* default list, so an array containing only our own rule quietly re-exposes
|
|
59
|
+
* `.env`, private keys, `.npmrc` and `.git` - a much larger hole than the one
|
|
60
|
+
* being closed, opened by closing it. Kept as an exported constant so a test can
|
|
61
|
+
* hold both halves.
|
|
62
|
+
*/
|
|
63
|
+
const FS_DENY = [
|
|
64
|
+
".env",
|
|
65
|
+
".env.*",
|
|
66
|
+
"*.{crt,pem,key,p12,pfx,cer,der}",
|
|
67
|
+
".npmrc",
|
|
68
|
+
".yarnrc.yml",
|
|
69
|
+
"**/.git/**",
|
|
70
|
+
".dev.vars",
|
|
71
|
+
".dev.vars.*",
|
|
72
|
+
"**/design/.local/**"
|
|
73
|
+
];
|
|
74
|
+
/**
|
|
75
|
+
* The half of the credential guard that globs cannot do.
|
|
76
|
+
*
|
|
77
|
+
* `fs.deny` matches the REQUEST path, lexically, and then Vite stats and streams
|
|
78
|
+
* whatever that path resolves to. So a repository containing
|
|
79
|
+
* `leak.json -> design/.local/anything` serves that file at `/leak.json` to any
|
|
80
|
+
* authored frame that asks - the deny rule never sees the name it is looking for.
|
|
81
|
+
* Verified against a real dev server before this existed.
|
|
82
|
+
*
|
|
83
|
+
* More patterns cannot fix that; only resolving can. This runs BEFORE Vite's own
|
|
84
|
+
* middlewares (a `configureServer` hook that does not return a function is
|
|
85
|
+
* installed first) and refuses any request whose real file lands inside
|
|
86
|
+
* design/.local, whatever route it took to get there.
|
|
87
|
+
*
|
|
88
|
+
* "Whatever route" means every place Vite would look, not just the project root.
|
|
89
|
+
* `public/` is mapped onto `/`, so `public/leak.json -> ../design/.local/x` is
|
|
90
|
+
* served at `/leak.json` - and Vite skips its own deny checks entirely for public
|
|
91
|
+
* files, so that spelling was reachable even with the root candidate guarded.
|
|
92
|
+
* Each candidate is resolved; if any lands in design/.local, the request is
|
|
93
|
+
* refused.
|
|
94
|
+
*
|
|
95
|
+
* The boundary this does NOT claim: a repository that can write symlinks can also
|
|
96
|
+
* write `design/config.ts`, which `marver dev` imports and RUNS in this process
|
|
97
|
+
* before any of the above exists. Running `dev` on a repository you do not trust
|
|
98
|
+
* hands it your machine long before it hands it a file, so a symlink is not the
|
|
99
|
+
* interesting way in. What this closes is the frame-in-a-trusted-repo case: a
|
|
100
|
+
* component that runs in the browser, same-origin, that can only ask for URLs and
|
|
101
|
+
* cannot create a file to ask for.
|
|
102
|
+
*/
|
|
103
|
+
function denyLocalSecrets(root) {
|
|
104
|
+
const guardedAt = join(root, "design", ".local");
|
|
105
|
+
const publicAt = join(root, "public");
|
|
106
|
+
const inside = (real, dir) => {
|
|
107
|
+
const rel = relative(dir, real);
|
|
108
|
+
return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
|
|
109
|
+
};
|
|
110
|
+
return {
|
|
111
|
+
name: "marver:deny-local-secrets",
|
|
112
|
+
configureServer(server) {
|
|
113
|
+
server.middlewares.use((req, res, next) => {
|
|
114
|
+
let pathname;
|
|
115
|
+
try {
|
|
116
|
+
pathname = decodeURIComponent((req.url ?? "/").split("?")[0].split("#")[0]);
|
|
117
|
+
} catch {
|
|
118
|
+
return next();
|
|
119
|
+
}
|
|
120
|
+
const rel = "." + (pathname.startsWith("/") ? pathname : `/${pathname}`);
|
|
121
|
+
const derived = (base) => [
|
|
122
|
+
base,
|
|
123
|
+
`${base}.html`,
|
|
124
|
+
join(base, "index"),
|
|
125
|
+
join(base, "index.html")
|
|
126
|
+
];
|
|
127
|
+
const candidates = pathname.startsWith("/@fs/") ? derived(pathname.slice(4)) : [...derived(resolve(root, rel)), ...derived(resolve(publicAt, rel))];
|
|
128
|
+
let guarded;
|
|
129
|
+
try {
|
|
130
|
+
guarded = realpathSync(guardedAt);
|
|
131
|
+
} catch {
|
|
132
|
+
return next();
|
|
133
|
+
}
|
|
134
|
+
if (!candidates.some((c) => {
|
|
135
|
+
try {
|
|
136
|
+
return inside(realpathSync(c), guarded);
|
|
137
|
+
} catch {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
})) return next();
|
|
141
|
+
res.statusCode = 403;
|
|
142
|
+
res.setHeader("content-type", "text/plain; charset=utf-8");
|
|
143
|
+
res.end("design/.local holds this canvas's credentials and is never served");
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
}
|
|
47
148
|
async function dev(root, portFlag) {
|
|
48
149
|
const config = await loadConfig(root);
|
|
49
150
|
const host = detectHost(root);
|
|
@@ -52,7 +153,7 @@ async function dev(root, portFlag) {
|
|
|
52
153
|
const projectName = basename(root);
|
|
53
154
|
const desiredPort = portFlag ?? (config.port !== 5199 ? config.port : void 0) ?? projectPort(root);
|
|
54
155
|
const picked = await pickPort(root, desiredPort);
|
|
55
|
-
const plugins = [react()];
|
|
156
|
+
const plugins = [denyLocalSecrets(root), react()];
|
|
56
157
|
if (host.tailwind === 4) {
|
|
57
158
|
const tw = await tailwind4Plugin(root);
|
|
58
159
|
if (tw) plugins.push(...tw);
|
|
@@ -85,11 +186,14 @@ async function dev(root, portFlag) {
|
|
|
85
186
|
server: {
|
|
86
187
|
port: picked.port,
|
|
87
188
|
strictPort: false,
|
|
88
|
-
fs: {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
189
|
+
fs: {
|
|
190
|
+
allow: [.../* @__PURE__ */ new Set([
|
|
191
|
+
root,
|
|
192
|
+
pkgDir,
|
|
193
|
+
searchForWorkspaceRoot(root)
|
|
194
|
+
])],
|
|
195
|
+
deny: FS_DENY
|
|
196
|
+
},
|
|
93
197
|
watch: { ignored: [
|
|
94
198
|
"**/design/manifest.json",
|
|
95
199
|
"**/design/boards/**",
|
|
@@ -141,8 +245,18 @@ async function dev(root, portFlag) {
|
|
|
141
245
|
const port = typeof addr === "object" && addr ? addr.port : picked.port;
|
|
142
246
|
if (picked.fellBack) console.log(`\n port ${desiredPort} is in use - serving "${projectName}" on ${port} instead`);
|
|
143
247
|
console.log(`\n ${NAME} · ${projectName} → http://localhost:${port}/\n`);
|
|
144
|
-
|
|
145
|
-
|
|
248
|
+
try {
|
|
249
|
+
const { staleManagedInstructions } = await import("./managed-HwHVNI3h.mjs");
|
|
250
|
+
const { installedVersion } = await import("./update-DuWDj5nR.mjs").then((n) => n.r);
|
|
251
|
+
const stale = staleManagedInstructions(root);
|
|
252
|
+
if (stale.length) {
|
|
253
|
+
const shown = stale.slice(0, 4).join(", ") + (stale.length > 4 ? `, +${stale.length - 4} more` : "");
|
|
254
|
+
const v = installedVersion();
|
|
255
|
+
console.log(` instructions predate ${v ? NAME + " " + v : "the installed version"} (${shown}) - run: npx ${NAME} init\n`);
|
|
256
|
+
}
|
|
257
|
+
} catch {}
|
|
258
|
+
const { loadCollab, syncOnce } = await import("./sync-DELGomPk.mjs").then((n) => n.o);
|
|
259
|
+
if (loadCollab(root)) console.log(` comments: syncing with the published canvas\n`);
|
|
146
260
|
let syncing = false;
|
|
147
261
|
const tick = async () => {
|
|
148
262
|
if (syncing) return;
|
|
@@ -171,7 +285,7 @@ async function dev(root, portFlag) {
|
|
|
171
285
|
});
|
|
172
286
|
}
|
|
173
287
|
if (config.jam) {
|
|
174
|
-
const { startJam } = await import("./daemon-
|
|
288
|
+
const { startJam } = await import("./daemon-DSmaS453.mjs");
|
|
175
289
|
const jam = startJam(root, config.jam, (m) => console.log(m), (board) => server.ws.send("sh:jam-comment", { board }));
|
|
176
290
|
if (jam) {
|
|
177
291
|
const close = server.close.bind(server);
|
|
@@ -1,20 +1,10 @@
|
|
|
1
1
|
import { n as NAME } from "./cli.mjs";
|
|
2
|
-
import { a as
|
|
2
|
+
import { a as readJson, i as detectHost, r as detectAgent, t as DEFAULTS } from "./config-t9coJ-Pq.mjs";
|
|
3
|
+
import { a as writeManifest, r as scanFrames } from "./manifest-C2tzkNaC.mjs";
|
|
4
|
+
import { MANAGED_PREFIX, enumerateInstructionTemplates, hashBody, managedFile, pkgDir } from "./managed-HwHVNI3h.mjs";
|
|
3
5
|
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
4
6
|
import { dirname, join, relative } from "node:path";
|
|
5
|
-
import { fileURLToPath } from "node:url";
|
|
6
|
-
import { createHash } from "node:crypto";
|
|
7
7
|
//#region src/cli/init.ts
|
|
8
|
-
/** Package root = the nearest ancestor holding templates/ (one hop from dist/, two
|
|
9
|
-
* from src/cli/ - the walk serves both, and tests run init from source). */
|
|
10
|
-
const pkgDir = () => {
|
|
11
|
-
let dir = dirname(fileURLToPath(import.meta.url));
|
|
12
|
-
for (let i = 0; i < 4; i++) {
|
|
13
|
-
if (existsSync(join(dir, "templates"))) return dir;
|
|
14
|
-
dir = dirname(dir);
|
|
15
|
-
}
|
|
16
|
-
return join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
17
|
-
};
|
|
18
8
|
/** Idempotent scaffolder: never overwrites existing files; every host-repo touch prints a diff. */
|
|
19
9
|
function init(root, opts) {
|
|
20
10
|
const host = detectHost(root);
|
|
@@ -57,7 +47,7 @@ function init(root, opts) {
|
|
|
57
47
|
rmSync(latest, { force: true });
|
|
58
48
|
return;
|
|
59
49
|
}
|
|
60
|
-
if (current.startsWith(
|
|
50
|
+
if (current.startsWith("<!-- marver:managed ")) {
|
|
61
51
|
const recorded = current.slice(MANAGED_PREFIX.length).split(" ")[0];
|
|
62
52
|
const nl = current.indexOf("\n");
|
|
63
53
|
const currentBody = nl >= 0 ? current.slice(nl + 1) : "";
|
|
@@ -75,7 +65,7 @@ function init(root, opts) {
|
|
|
75
65
|
}
|
|
76
66
|
console.warn(` ~ design/${rel}: you customized it and a newer version exists - your edits are untouched. Merge what you want from design/.local/latest/${rel}`);
|
|
77
67
|
}
|
|
78
|
-
} else if (current.startsWith(
|
|
68
|
+
} else if (current.startsWith("<!-- generated by marver init")) {
|
|
79
69
|
writeFileSync(file, next);
|
|
80
70
|
rmSync(latest, { force: true });
|
|
81
71
|
created.push(`design/${rel} (updated)`);
|
|
@@ -93,7 +83,7 @@ function init(root, opts) {
|
|
|
93
83
|
write("providers.tsx", providersTemplate(host.router, host.toaster, host.routerPkg));
|
|
94
84
|
writeManaged("AGENTS.md", readFileSync(join(templates, `AGENTS-${opts.mode}.md`), "utf8").replaceAll("{{UI_GUIDANCE}}", uiGuidance(host, noApp(host))).replace(/\{\{NEXT_NOTES\}\}\n?/, host.router === "next" ? NEXT_NOTES : ""));
|
|
95
85
|
const agentsNow = readFileSync(join(design, "AGENTS.md"), "utf8");
|
|
96
|
-
if (!agentsNow.startsWith(
|
|
86
|
+
if (!agentsNow.startsWith("<!-- marver:managed ") && !agentsNow.startsWith("<!-- generated by marver init") && agentsNow.includes("# Design canvas - agent contract") && !agentsNow.includes("## The method (binding)")) console.warn(` note: design/AGENTS.md predates managed regeneration - if you never edited it, delete it and re-run init to get the current contract (incl. the design/instructions routing).`);
|
|
97
87
|
const claudeMd = join(root, "CLAUDE.md");
|
|
98
88
|
const importLine = "@design/AGENTS.md";
|
|
99
89
|
if (!existsSync(claudeMd)) {
|
|
@@ -118,15 +108,12 @@ function init(root, opts) {
|
|
|
118
108
|
created.push("AGENTS.md (updated)");
|
|
119
109
|
}
|
|
120
110
|
}
|
|
121
|
-
const
|
|
122
|
-
for (const e of readdirSync(instrRoot, { withFileTypes: true })) if (e.isDirectory()) {
|
|
123
|
-
for (const f of readdirSync(join(instrRoot, e.name))) if (f.endsWith(".md")) writeManaged(`instructions/${e.name}/${f}`, readFileSync(join(instrRoot, e.name, f), "utf8"));
|
|
124
|
-
} else if (e.name.endsWith(".md")) writeManaged(`instructions/${e.name}`, readFileSync(join(instrRoot, e.name), "utf8"));
|
|
111
|
+
for (const { rel, body } of enumerateInstructionTemplates(templates)) writeManaged(rel, body);
|
|
125
112
|
const setupPath = join(design, "instructions", "setup.md");
|
|
126
113
|
const setupState = () => {
|
|
127
114
|
if (!existsSync(setupPath)) return "absent";
|
|
128
115
|
const s = readFileSync(setupPath, "utf8");
|
|
129
|
-
if (s.startsWith(
|
|
116
|
+
if (s.startsWith("<!-- marver:managed ")) {
|
|
130
117
|
const recorded = s.slice(MANAGED_PREFIX.length).split(" ")[0];
|
|
131
118
|
const nl = s.indexOf("\n");
|
|
132
119
|
return nl >= 0 && hashBody(s.slice(nl + 1)) === recorded ? "ours-pristine" : "ours-edited";
|
|
@@ -136,7 +123,7 @@ function init(root, opts) {
|
|
|
136
123
|
const setupWas = setupState();
|
|
137
124
|
const appJustAppeared = !noApp(host) && (setupWas === "ours-pristine" || setupWas === "ours-edited");
|
|
138
125
|
if (noApp(host)) {
|
|
139
|
-
if (setupWas === "ours-pristine" && !readFileSync(setupPath, "utf8").startsWith(
|
|
126
|
+
if (setupWas === "ours-pristine" && !readFileSync(setupPath, "utf8").startsWith("<!-- marver:managed ")) rmSync(setupPath);
|
|
140
127
|
if (setupWas !== "foreign") writeManaged("instructions/setup.md", SETUP_MD);
|
|
141
128
|
} else if (setupWas === "ours-pristine") {
|
|
142
129
|
rmSync(setupPath);
|
|
@@ -193,13 +180,6 @@ function init(root, opts) {
|
|
|
193
180
|
console.log(`\n next: npx ${NAME} dev (or: npx ${NAME} canvas - same thing; canvas on http://localhost:${DEFAULTS.port} by default)\n`);
|
|
194
181
|
if (!noApp(host)) console.log(` then, to your agent: "Read design/AGENTS.md. This is our first session - follow design/instructions/welcome.md."\n`);
|
|
195
182
|
}
|
|
196
|
-
const MANAGED_PREFIX = "<!-- marver:managed ";
|
|
197
|
-
const LEGACY_PREFIX = "<!-- generated by marver init";
|
|
198
|
-
const hashBody = (s) => createHash("sha256").update(s).digest("hex");
|
|
199
|
-
/** The marker carries a hash of the generated body: edits are DETECTED, not assumed.
|
|
200
|
-
* Edit freely - init preserves edits and stages upstream updates for merging.
|
|
201
|
-
* Deleting the marker line detaches the file from updates entirely. */
|
|
202
|
-
const managedFile = (body) => `${MANAGED_PREFIX}${hashBody(body)} - edit freely: init preserves your edits and stages upstream updates at design/.local/latest/ for you to merge. Delete this line to detach this file from updates entirely. -->\n${body}`;
|
|
203
183
|
/** The host's path aliases, re-rooted one level down for design/tsconfig.json.
|
|
204
184
|
* "./src/*" becomes "../src/*" so `@/` imports resolve from inside design/. */
|
|
205
185
|
function designPaths(root) {
|
|
@@ -1,19 +1,8 @@
|
|
|
1
|
+
import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
|
|
1
2
|
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, writeSync } from "node:fs";
|
|
2
3
|
import { dirname, join } from "node:path";
|
|
3
4
|
import { createHash } from "node:crypto";
|
|
4
5
|
import { homedir, hostname, userInfo } from "node:os";
|
|
5
|
-
//#region \0rolldown/runtime.js
|
|
6
|
-
var __defProp = Object.defineProperty;
|
|
7
|
-
var __exportAll = (all, no_symbols) => {
|
|
8
|
-
let target = {};
|
|
9
|
-
for (var name in all) __defProp(target, name, {
|
|
10
|
-
get: all[name],
|
|
11
|
-
enumerable: true
|
|
12
|
-
});
|
|
13
|
-
if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
|
|
14
|
-
return target;
|
|
15
|
-
};
|
|
16
|
-
//#endregion
|
|
17
6
|
//#region src/server/jam/device.ts
|
|
18
7
|
/**
|
|
19
8
|
* The device stamp - what makes "device-bound" true for the two files that decide whether
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
//#region src/server/managed.ts
|
|
6
|
+
/**
|
|
7
|
+
* Managed-file identity - the ONE source of truth for the `marver:managed <hash>` marker,
|
|
8
|
+
* shared by `init` (which WRITES managed files) and `dev`'s boot-time staleness probe (which
|
|
9
|
+
* only READS them). Kept side-effect-free on purpose: dev must learn whether the on-disk
|
|
10
|
+
* instructions are behind the installed package WITHOUT importing the mutating init() path.
|
|
11
|
+
*/
|
|
12
|
+
/** The marker records a HASH of the generated body: edits are DETECTED, not assumed. */
|
|
13
|
+
const MANAGED_PREFIX = "<!-- marver:managed ";
|
|
14
|
+
/** Hashless 0.2.2-dev marker: edits are undetectable, so init unconditionally upgrades it. */
|
|
15
|
+
const LEGACY_PREFIX = "<!-- generated by marver init";
|
|
16
|
+
const hashBody = (s) => createHash("sha256").update(s).digest("hex");
|
|
17
|
+
/** Prepend the managed marker to a generated body. Deleting the marker line detaches the
|
|
18
|
+
* file from updates entirely. */
|
|
19
|
+
const managedFile = (body) => `${MANAGED_PREFIX}${hashBody(body)} - edit freely: init preserves your edits and stages upstream updates at design/.local/latest/ for you to merge. Delete this line to detach this file from updates entirely. -->\n${body}`;
|
|
20
|
+
/** Package root = the nearest ancestor holding templates/ (one hop from dist/, two from
|
|
21
|
+
* src/** - the walk serves both, and tests run from source). */
|
|
22
|
+
const pkgDir = () => {
|
|
23
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
for (let i = 0; i < 4; i++) {
|
|
25
|
+
if (existsSync(join(dir, "templates"))) return dir;
|
|
26
|
+
dir = dirname(dir);
|
|
27
|
+
}
|
|
28
|
+
return join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
29
|
+
};
|
|
30
|
+
const templatesDir = () => join(pkgDir(), "templates");
|
|
31
|
+
function classifyManaged(current, shippedBody) {
|
|
32
|
+
if (current == null) return "absent";
|
|
33
|
+
if (current.startsWith("<!-- marver:managed ")) {
|
|
34
|
+
const nl = current.indexOf("\n");
|
|
35
|
+
if (nl < 0) return "detached";
|
|
36
|
+
const recorded = current.slice(20).split(" ")[0];
|
|
37
|
+
if (!/^[0-9a-f]{64}$/.test(recorded)) return "detached";
|
|
38
|
+
const body = current.slice(nl + 1);
|
|
39
|
+
const shipped = hashBody(shippedBody);
|
|
40
|
+
if (hashBody(body) === recorded) return recorded === shipped ? "pristine-current" : "pristine-stale";
|
|
41
|
+
return recorded === shipped ? "edited-current" : "edited-stale";
|
|
42
|
+
}
|
|
43
|
+
if (current.startsWith("<!-- generated by marver init")) return "legacy";
|
|
44
|
+
return "detached";
|
|
45
|
+
}
|
|
46
|
+
/** The managed INSTRUCTION set init writes, enumerated identically (init.ts): top-level
|
|
47
|
+
* templates/instructions/*.md plus one level of subdirectory *.md (reference/). setup.md is
|
|
48
|
+
* the SETUP_MD constant written separately/conditionally - it is not a template file, so it
|
|
49
|
+
* never appears here. */
|
|
50
|
+
function enumerateInstructionTemplates(templatesRoot) {
|
|
51
|
+
const instrRoot = join(templatesRoot, "instructions");
|
|
52
|
+
const out = [];
|
|
53
|
+
if (!existsSync(instrRoot)) return out;
|
|
54
|
+
for (const e of readdirSync(instrRoot, { withFileTypes: true })) if (e.isDirectory()) {
|
|
55
|
+
for (const f of readdirSync(join(instrRoot, e.name))) if (f.endsWith(".md")) out.push({
|
|
56
|
+
rel: `instructions/${e.name}/${f}`,
|
|
57
|
+
body: readFileSync(join(instrRoot, e.name, f), "utf8")
|
|
58
|
+
});
|
|
59
|
+
} else if (e.name.endsWith(".md") && e.name !== "setup.md") out.push({
|
|
60
|
+
rel: `instructions/${e.name}`,
|
|
61
|
+
body: readFileSync(join(instrRoot, e.name), "utf8")
|
|
62
|
+
});
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
const STALE = /* @__PURE__ */ new Set([
|
|
66
|
+
"absent",
|
|
67
|
+
"pristine-stale",
|
|
68
|
+
"edited-stale",
|
|
69
|
+
"legacy"
|
|
70
|
+
]);
|
|
71
|
+
/**
|
|
72
|
+
* Which managed instruction files on disk are behind the INSTALLED package (distinct from the
|
|
73
|
+
* registry update-check). Returns display names (basenames) of the stale files, AGENTS.md
|
|
74
|
+
* included only when the file is entirely ABSENT (a deleted marker is the documented opt-out,
|
|
75
|
+
* and AGENTS.md is host-substituted so its pristine hash never matches the raw template).
|
|
76
|
+
* Never throws: any read/enumeration failure yields an empty list so a boot can't crash on it.
|
|
77
|
+
*/
|
|
78
|
+
function staleManagedInstructions(root) {
|
|
79
|
+
try {
|
|
80
|
+
const design = join(root, "design");
|
|
81
|
+
const stale = [];
|
|
82
|
+
for (const { rel, body } of enumerateInstructionTemplates(templatesDir())) {
|
|
83
|
+
const file = join(design, rel);
|
|
84
|
+
const current = existsSync(file) ? readFileSync(file, "utf8") : null;
|
|
85
|
+
if (STALE.has(classifyManaged(current, body))) stale.push(rel.split("/").pop() ?? rel);
|
|
86
|
+
}
|
|
87
|
+
if (!existsSync(join(design, "AGENTS.md"))) stale.unshift("AGENTS.md");
|
|
88
|
+
return stale;
|
|
89
|
+
} catch {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
//#endregion
|
|
94
|
+
export { LEGACY_PREFIX, MANAGED_PREFIX, enumerateInstructionTemplates, hashBody, managedFile, pkgDir, staleManagedInstructions };
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { r as PKG, t as CONTENT_WIDTH } from "./cli.mjs";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join, relative, sep } from "node:path";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
//#region src/server/manifest.ts
|
|
6
|
+
const FRAME_EXT = /\.(tsx|jsx|html)$/;
|
|
7
|
+
const RESERVED_SCENES = /* @__PURE__ */ new Set(["components", "screens"]);
|
|
8
|
+
/** Extract `export const meta = {...}` with literal string values only. Anything else is silently omitted. */
|
|
9
|
+
function extractMeta(src) {
|
|
10
|
+
const m = /export\s+const\s+meta\s*=\s*\{([\s\S]*?)\}/.exec(src);
|
|
11
|
+
if (!m) return {};
|
|
12
|
+
const body = m[1];
|
|
13
|
+
const pick = (key) => {
|
|
14
|
+
const r = new RegExp(`(?:^|[{,])\\s*${key}\\s*:\\s*(['"\`])([^'"\`]*)\\1`).exec(body);
|
|
15
|
+
return r ? r[2] : void 0;
|
|
16
|
+
};
|
|
17
|
+
const out = {};
|
|
18
|
+
const title = pick("title");
|
|
19
|
+
if (title) out.title = title;
|
|
20
|
+
const viewport = pick("viewport");
|
|
21
|
+
if (viewport) out.viewport = viewport;
|
|
22
|
+
const theme = pick("theme");
|
|
23
|
+
if (theme) out.theme = theme;
|
|
24
|
+
const of = pick("of");
|
|
25
|
+
if (of) out.of = of;
|
|
26
|
+
const variant = pick("variant");
|
|
27
|
+
if (variant) out.variant = variant;
|
|
28
|
+
const intent = pick("intent");
|
|
29
|
+
if (intent) out.intent = intent;
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
/** Content-frame detection: LEXICAL by convention - the frame file
|
|
33
|
+
* itself imports PKG/content (an import specifier scan, so "<Diagram" inside a
|
|
34
|
+
* string in a UI frame can never misbadge it; barrels/re-exports are not
|
|
35
|
+
* detected - meta.intent is the taught path and always works). Returns the
|
|
36
|
+
* inferred intent + natural width, or null for UI frames. */
|
|
37
|
+
const CONTENT_IMPORT = new RegExp(`from\\s+['"]${PKG}/content['"]`);
|
|
38
|
+
const WIDE_DOC = /<Doc\b[^>]*\blayout\s*=\s*["']wide["']/;
|
|
39
|
+
const contentWidthOf = (src) => WIDE_DOC.test(src) ? CONTENT_WIDTH.wide : CONTENT_WIDTH.document;
|
|
40
|
+
function contentScan(src) {
|
|
41
|
+
if (!CONTENT_IMPORT.test(src)) return null;
|
|
42
|
+
const count = (re) => (src.match(re) ?? []).length;
|
|
43
|
+
const diagrams = count(/<Diagram[\s>/]/g);
|
|
44
|
+
const imgs = count(/<Img[\s>/]/g);
|
|
45
|
+
const mds = count(/<Md[\s>/]/g);
|
|
46
|
+
return {
|
|
47
|
+
intent: diagrams > 0 ? "diagram" : imgs > mds ? "moodboard" : "spec",
|
|
48
|
+
width: contentWidthOf(src)
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/** id = path relative to design/, extension dropped, `scenes/` prefix dropped. Always `/`-separated. */
|
|
52
|
+
function toFrameId(designRelPath) {
|
|
53
|
+
const noExt = designRelPath.split(sep).join("/").replace(FRAME_EXT, "");
|
|
54
|
+
return noExt.startsWith("scenes/") ? noExt.slice(7) : noExt;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* A7: which frames does a changed `design/**` file affect? Manifest + directory conventions,
|
|
58
|
+
* NOT a module-graph walk (deterministic, cheap, may over-reload but never misses a
|
|
59
|
+
* conventionally-affected frame). Returns:
|
|
60
|
+
* null -> uncontrolled: leave to default Vite HMR (src/** deps, theme.css, config, assets)
|
|
61
|
+
* [] -> controlled but affects no current frame (e.g. a layout in an empty dir)
|
|
62
|
+
* [ids] -> controlled: the shell drives a rev-stamped reload of these frames
|
|
63
|
+
*/
|
|
64
|
+
function affectedFrameIds(absFile, root, manifest) {
|
|
65
|
+
const design = join(root, "design");
|
|
66
|
+
if (absFile !== design && !absFile.startsWith(design + sep)) return null;
|
|
67
|
+
const rel = relative(design, absFile).split(sep).join("/");
|
|
68
|
+
const name = rel.split("/").pop() ?? rel;
|
|
69
|
+
const designRel = `design/${rel}`;
|
|
70
|
+
const tsxFrames = manifest.frames.filter((f) => f.kind === "tsx");
|
|
71
|
+
if (name === "theme.css") return null;
|
|
72
|
+
if (rel === "providers.tsx" || rel === "providers.jsx") return tsxFrames.map((f) => f.id);
|
|
73
|
+
if (/(^|\/)(_layout\.(tsx|jsx)|_fixtures\.(ts|tsx|js|jsx|json))$/.test(rel)) {
|
|
74
|
+
const prefix = `design/${rel.slice(0, rel.length - name.length)}`;
|
|
75
|
+
return tsxFrames.filter((f) => f.file.startsWith(prefix)).map((f) => f.id);
|
|
76
|
+
}
|
|
77
|
+
const direct = manifest.frames.find((f) => f.file === designRel);
|
|
78
|
+
if (direct) return [direct.id];
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
function walk(dir, out = []) {
|
|
82
|
+
if (!existsSync(dir)) return out;
|
|
83
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
84
|
+
if (e.name.startsWith(".")) continue;
|
|
85
|
+
const p = join(dir, e.name);
|
|
86
|
+
if (e.isDirectory()) walk(p, out);
|
|
87
|
+
else out.push(p);
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
function scanFrames(root) {
|
|
92
|
+
const design = join(root, "design");
|
|
93
|
+
const frames = [];
|
|
94
|
+
for (const base of ["scenes", "components"]) for (const abs of walk(join(design, base))) {
|
|
95
|
+
const name = abs.split(sep).pop();
|
|
96
|
+
if (name.startsWith("_") || !FRAME_EXT.test(name)) continue;
|
|
97
|
+
const rel = relative(design, abs).split(sep).join("/");
|
|
98
|
+
const id = toFrameId(rel);
|
|
99
|
+
const scene = id.includes("/") ? id.split("/")[0] : "";
|
|
100
|
+
if (base === "scenes" && RESERVED_SCENES.has(scene)) {
|
|
101
|
+
console.error(`[marver] scene "${scene}" is a reserved name - "${rel}" skipped. Rename the scene.`);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const kind = name.endsWith(".html") ? "html" : "tsx";
|
|
105
|
+
const entry = {
|
|
106
|
+
id,
|
|
107
|
+
file: `design/${rel}`,
|
|
108
|
+
kind,
|
|
109
|
+
scene
|
|
110
|
+
};
|
|
111
|
+
if (kind === "tsx") {
|
|
112
|
+
const src = readFileSync(abs, "utf8");
|
|
113
|
+
const meta = extractMeta(src);
|
|
114
|
+
if (meta.title) entry.title = meta.title;
|
|
115
|
+
if (meta.viewport) entry.viewport = meta.viewport;
|
|
116
|
+
if (meta.theme) entry.theme = meta.theme;
|
|
117
|
+
if (meta.of) entry.variantGroup = meta.of;
|
|
118
|
+
if (meta.variant) entry.variant = meta.variant;
|
|
119
|
+
const content = contentScan(src);
|
|
120
|
+
if (content || meta.intent) {
|
|
121
|
+
entry.intent = meta.intent ?? content.intent;
|
|
122
|
+
entry.contentWidth = content?.width ?? contentWidthOf(src);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
frames.push(entry);
|
|
126
|
+
}
|
|
127
|
+
const extRank = (file) => file.endsWith(".tsx") ? 0 : file.endsWith(".jsx") ? 1 : 2;
|
|
128
|
+
frames.sort((a, b) => a.id.localeCompare(b.id) || extRank(a.file) - extRank(b.file) || a.file.localeCompare(b.file));
|
|
129
|
+
const seen = /* @__PURE__ */ new Map();
|
|
130
|
+
const deduped = frames.filter((f) => {
|
|
131
|
+
const prev = seen.get(f.id);
|
|
132
|
+
if (prev) {
|
|
133
|
+
console.error(`[marver] duplicate frame id "${f.id}" (${prev} vs ${f.file}) - keeping ${prev}.`);
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
seen.set(f.id, f.file);
|
|
137
|
+
return true;
|
|
138
|
+
});
|
|
139
|
+
frames.length = 0;
|
|
140
|
+
frames.push(...deduped);
|
|
141
|
+
inferVariantGroups(frames);
|
|
142
|
+
const sceneCounts = /* @__PURE__ */ new Map();
|
|
143
|
+
for (const f of frames) sceneCounts.set(f.scene, (sceneCounts.get(f.scene) ?? 0) + 1);
|
|
144
|
+
return {
|
|
145
|
+
frames,
|
|
146
|
+
scenes: [...sceneCounts.entries()].map(([name, n]) => ({
|
|
147
|
+
name,
|
|
148
|
+
frames: n
|
|
149
|
+
})).sort((a, b) => a.name.localeCompare(b.name))
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/** Variant groups. A group = 2+ frames in one DIRECTORY whose basenames
|
|
153
|
+
* are letter-prefixed (`a-terminal`), or frames declaring `meta.of`. Group id = the
|
|
154
|
+
* directory's id prefix (or meta.of); variant key = the letter (or meta.variant).
|
|
155
|
+
* Nested directories scope alternatives inside a busy scene (checkout/payment/a-card).
|
|
156
|
+
* States (empty.tsx, error.tsx) never letter-prefix, so they never misgroup.
|
|
157
|
+
* Mutates entries in place: only frames whose group materializes keep the fields. */
|
|
158
|
+
function inferVariantGroups(frames) {
|
|
159
|
+
const dirOf = (id) => {
|
|
160
|
+
const i = id.lastIndexOf("/");
|
|
161
|
+
return i >= 0 ? id.slice(0, i) : "";
|
|
162
|
+
};
|
|
163
|
+
const candidates = /* @__PURE__ */ new Map();
|
|
164
|
+
for (const f of frames) {
|
|
165
|
+
if (f.kind !== "tsx") {
|
|
166
|
+
delete f.variantGroup;
|
|
167
|
+
delete f.variant;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
let group = f.variantGroup;
|
|
171
|
+
let key = f.variant;
|
|
172
|
+
if (!group) {
|
|
173
|
+
const base = f.id.slice(f.id.lastIndexOf("/") + 1);
|
|
174
|
+
const m = /^([a-z])-.+$/.exec(base);
|
|
175
|
+
if (!m) continue;
|
|
176
|
+
group = dirOf(f.id);
|
|
177
|
+
if (!group) continue;
|
|
178
|
+
key = key ?? m[1];
|
|
179
|
+
} else if (!key) {
|
|
180
|
+
const m = /^([a-z])-.+$/.exec(f.id.slice(f.id.lastIndexOf("/") + 1));
|
|
181
|
+
if (!m) {
|
|
182
|
+
console.warn(`[marver] ${f.file} declares of:"${group}" but no variant key (add meta.variant or a letter prefix) - not grouped.`);
|
|
183
|
+
delete f.variantGroup;
|
|
184
|
+
delete f.variant;
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
key = m[1];
|
|
188
|
+
}
|
|
189
|
+
f.variantGroup = group;
|
|
190
|
+
f.variant = key;
|
|
191
|
+
candidates.set(group, [...candidates.get(group) ?? [], f]);
|
|
192
|
+
}
|
|
193
|
+
for (const [group, members] of candidates) {
|
|
194
|
+
if (new Set(members.map((m) => dirOf(m.id))).size > 1) {
|
|
195
|
+
console.warn(`[marver] group "${group}" spans directories - variants must be siblings; not grouped.`);
|
|
196
|
+
for (const m of members) {
|
|
197
|
+
delete m.variantGroup;
|
|
198
|
+
delete m.variant;
|
|
199
|
+
}
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
const seen = /* @__PURE__ */ new Set();
|
|
203
|
+
const kept = [];
|
|
204
|
+
for (const m of members.sort((a, b) => (a.variant ?? "").localeCompare(b.variant ?? ""))) {
|
|
205
|
+
if (seen.has(m.variant)) {
|
|
206
|
+
console.warn(`[marver] duplicate variant "${m.variant}" in group "${group}" (${m.file}) - not grouped.`);
|
|
207
|
+
delete m.variantGroup;
|
|
208
|
+
delete m.variant;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
seen.add(m.variant);
|
|
212
|
+
kept.push(m);
|
|
213
|
+
}
|
|
214
|
+
if (kept.length < 2) for (const m of kept) {
|
|
215
|
+
delete m.variantGroup;
|
|
216
|
+
delete m.variant;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
for (const f of frames) if (f.variant && !f.variantGroup) delete f.variant;
|
|
220
|
+
}
|
|
221
|
+
/** Write design/manifest.json only when content changed. Returns the manifest either way. */
|
|
222
|
+
function writeManifest(root, manifest) {
|
|
223
|
+
const file = join(root, "design", "manifest.json");
|
|
224
|
+
const next = JSON.stringify(manifest, null, 2) + "\n";
|
|
225
|
+
const prev = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
226
|
+
if (hash(prev) === hash(next)) return false;
|
|
227
|
+
mkdirSync(join(root, "design"), { recursive: true });
|
|
228
|
+
writeFileSync(file, next);
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
const hash = (s) => createHash("sha256").update(s).digest("hex");
|
|
232
|
+
//#endregion
|
|
233
|
+
export { writeManifest as a, toFrameId as i, hash as n, scanFrames as r, affectedFrameIds as t };
|