@mafianacho/nacholens 0.0.0-stage → 0.3.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/LICENSE +202 -0
- package/NOTICE +13 -0
- package/README.de.md +46 -0
- package/README.fr.md +46 -0
- package/README.ja.md +46 -0
- package/README.ko.md +46 -0
- package/README.md +45 -2
- package/README.zh.md +46 -0
- package/demo/shop/v1/README.md +7 -0
- package/demo/shop/v1/api.js +78 -0
- package/demo/shop/v1/db.js +38 -0
- package/demo/shop/v1/gateway.js +50 -0
- package/demo/shop/v1/mock-external.js +17 -0
- package/demo/shop/v1/public/app.js +32 -0
- package/demo/shop/v1/public/format.js +2 -0
- package/demo/shop/v1/public/index.html +13 -0
- package/demo/shop/v1/public/styles.css +4 -0
- package/demo/shop/v2/README.md +7 -0
- package/demo/shop/v2/api.js +88 -0
- package/demo/shop/v2/db.js +38 -0
- package/demo/shop/v2/gateway.js +50 -0
- package/demo/shop/v2/mock-external.js +17 -0
- package/demo/shop/v2/public/app.js +33 -0
- package/demo/shop/v2/public/format.js +3 -0
- package/demo/shop/v2/public/index.html +13 -0
- package/demo/shop/v2/public/styles.css +5 -0
- package/docs/guide.ko.md +97 -0
- package/docs/guide.md +142 -0
- package/docs/model-schema.md +137 -0
- package/docs/review.md +78 -0
- package/examples/demo-shop.artifact.html +1535 -0
- package/examples/demo-shop.artifact.json +7 -0
- package/examples/demo-shop.html +1535 -0
- package/examples/external/README.md +12 -0
- package/examples/external/full-stack-fastapi-template/LICENSE +21 -0
- package/examples/external/full-stack-fastapi-template/changes.json +56 -0
- package/examples/external/full-stack-fastapi-template/full-stack-fastapi-template-backend.html +1535 -0
- package/models/demo-shop/changes.auto.json +121 -0
- package/models/demo-shop/changes.example.json +16 -0
- package/models/demo-shop/intent.example.json +75 -0
- package/models/demo-shop/model.json +3151 -0
- package/package.json +60 -4
- package/skills/nacholens/SKILL.md +235 -0
- package/src/cli.js +377 -0
- package/src/demo.js +42 -0
- package/src/diff.js +254 -0
- package/src/gitio.js +67 -0
- package/src/install.js +94 -0
- package/src/model.js +434 -0
- package/src/page.js +87 -0
- package/src/player/fonts/FONTLOG.md +30 -0
- package/src/player/fonts/OFL-Anton.txt +93 -0
- package/src/player/fonts/OFL-IBMPlex.txt +94 -0
- package/src/player/fonts/anton-400.woff2 +0 -0
- package/src/player/fonts/nacho-mono-500.woff2 +0 -0
- package/src/player/fonts/nacho-mono-600.woff2 +0 -0
- package/src/player/fonts/nacho-sans-400.woff2 +0 -0
- package/src/player/fonts/nacho-sans-500.woff2 +0 -0
- package/src/player/fonts/nacho-sans-600.woff2 +0 -0
- package/src/player/fonts/nacho-sans-700.woff2 +0 -0
- package/src/player/player.css +318 -0
- package/src/player/player.js +872 -0
- package/src/player/review.css +49 -0
- package/src/player/review.js +230 -0
- package/src/player/template.html +58 -0
- package/src/review.js +321 -0
- package/src/scan.js +255 -0
- package/src/template.js +36 -0
- package/src/verify.js +90 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Edge gateway: serves the static client and forwards /api/* to the API after a rate-limit check.
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { join, dirname } from "node:path";
|
|
7
|
+
|
|
8
|
+
const API = process.env.API_URL ?? "http://127.0.0.1:8081";
|
|
9
|
+
const PUBLIC_DIR = join(dirname(fileURLToPath(import.meta.url)), "public");
|
|
10
|
+
const LIMIT_PER_MINUTE = 120;
|
|
11
|
+
const hits = new Map();
|
|
12
|
+
|
|
13
|
+
function allowed(ip) {
|
|
14
|
+
const minute = Math.floor(Date.now() / 60000);
|
|
15
|
+
const h = hits.get(ip);
|
|
16
|
+
if (!h || h.minute !== minute) { hits.set(ip, { minute, n: 1 }); return true; }
|
|
17
|
+
return ++h.n <= LIMIT_PER_MINUTE;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const TYPES = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css" };
|
|
21
|
+
|
|
22
|
+
const server = http.createServer(async (req, res) => {
|
|
23
|
+
const requestId = randomUUID();
|
|
24
|
+
if (!allowed(req.socket.remoteAddress ?? "?")) { res.writeHead(429); return res.end("slow down"); }
|
|
25
|
+
|
|
26
|
+
if (req.url.startsWith("/api/")) {
|
|
27
|
+
const upstream = await fetch(API + req.url, {
|
|
28
|
+
method: req.method,
|
|
29
|
+
headers: { ...req.headers, host: new URL(API).host, "x-request-id": requestId },
|
|
30
|
+
body: ["GET", "HEAD"].includes(req.method) ? undefined : req,
|
|
31
|
+
duplex: "half",
|
|
32
|
+
redirect: "manual",
|
|
33
|
+
}).catch(() => null);
|
|
34
|
+
if (!upstream) { res.writeHead(502); return res.end("bad gateway"); }
|
|
35
|
+
const headers = Object.fromEntries(upstream.headers);
|
|
36
|
+
delete headers["content-encoding"];
|
|
37
|
+
res.writeHead(upstream.status, { ...headers, "x-request-id": requestId });
|
|
38
|
+
return res.end(Buffer.from(await upstream.arrayBuffer()));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const name = req.url === "/" ? "index.html" : req.url.slice(1).replace(/[^\w.-]/g, "");
|
|
42
|
+
const ext = name.slice(name.lastIndexOf("."));
|
|
43
|
+
try {
|
|
44
|
+
res.writeHead(200, { "content-type": TYPES[ext] ?? "application/octet-stream" });
|
|
45
|
+
res.end(await readFile(join(PUBLIC_DIR, name)));
|
|
46
|
+
} catch {
|
|
47
|
+
res.writeHead(404); res.end("not found");
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
server.listen(Number(process.env.PORT ?? 8080), "127.0.0.1", () => console.log("listening on :" + server.address().port));
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Stand-in for the three third-party services, so the demo runs fully offline.
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
|
|
4
|
+
const server = http.createServer((req, res) => {
|
|
5
|
+
const url = new URL(req.url, "http://x");
|
|
6
|
+
const json = (code, body) => { res.writeHead(code, { "content-type": "application/json" }); res.end(JSON.stringify(body)); };
|
|
7
|
+
if (url.pathname === "/authorize") { // login provider: send the user straight back
|
|
8
|
+
res.writeHead(302, { location: `${url.searchParams.get("redirect_uri")}?code=demo-code&state=${url.searchParams.get("state")}` });
|
|
9
|
+
return res.end();
|
|
10
|
+
}
|
|
11
|
+
if (url.pathname === "/token") return json(200, { email: "buyer@example.com" });
|
|
12
|
+
if (url.pathname === "/track") return json(200, { status: "in_transit" });
|
|
13
|
+
if (url.pathname === "/sales") return json(200, { total: 12840, orders: 37 });
|
|
14
|
+
json(404, { error: "not_found" });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
server.listen(Number(process.env.EXTERNAL_PORT ?? 8090), "127.0.0.1", () => console.log("listening on :" + server.address().port));
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Browser client of the demo shop. Same-origin calls only; the gateway forwards /api/*.
|
|
2
|
+
const $ = (id) => document.getElementById(id);
|
|
3
|
+
|
|
4
|
+
async function api(path, options) {
|
|
5
|
+
const res = await fetch("/api" + path, { credentials: "same-origin", ...options });
|
|
6
|
+
if (res.status === 401) { $("login").hidden = false; throw new Error("signed out"); }
|
|
7
|
+
return res.json();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async function loadOrders() {
|
|
11
|
+
const { orders } = await api("/orders");
|
|
12
|
+
$("login").hidden = true;
|
|
13
|
+
$("orders").replaceChildren(...orders.map((o) => {
|
|
14
|
+
const li = document.createElement("li");
|
|
15
|
+
li.textContent = `#${o.id} ${o.item} × ${o.qty}`;
|
|
16
|
+
return li;
|
|
17
|
+
}));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
$("order-form").addEventListener("submit", async (e) => {
|
|
21
|
+
e.preventDefault();
|
|
22
|
+
const form = new FormData(e.target);
|
|
23
|
+
await api("/orders", {
|
|
24
|
+
method: "POST",
|
|
25
|
+
headers: { "content-type": "application/json" },
|
|
26
|
+
body: JSON.stringify({ item: form.get("item"), qty: Number(form.get("qty")) }),
|
|
27
|
+
});
|
|
28
|
+
await loadOrders();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
loadOrders().catch(() => {});
|
|
32
|
+
api("/reports/sales").then((s) => { $("sales").textContent = `Sales ${s.total} (${s.orders} orders)`; }).catch(() => {});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head><meta charset="utf-8"><title>Demo shop</title><link rel="stylesheet" href="/styles.css"></head>
|
|
4
|
+
<body>
|
|
5
|
+
<header><h1>Demo shop</h1><a id="login" href="/api/auth/start">Sign in</a></header>
|
|
6
|
+
<main>
|
|
7
|
+
<form id="order-form"><input name="item" placeholder="Item" required><input name="qty" type="number" min="1" max="99" value="1"><button>Order</button></form>
|
|
8
|
+
<ul id="orders"></ul>
|
|
9
|
+
<p id="sales"></p>
|
|
10
|
+
</main>
|
|
11
|
+
<script src="/app.js" type="module"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
body { font: 15px/1.5 system-ui, sans-serif; margin: 0 auto; max-width: 640px; padding: 24px; }
|
|
2
|
+
header { display: flex; justify-content: space-between; align-items: baseline; }
|
|
3
|
+
#orders { list-style: none; padding: 0; }
|
|
4
|
+
#orders li { padding: 6px 0; border-bottom: 1px solid #ddd; }
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# demo shop (v2)
|
|
2
|
+
|
|
3
|
+
Tiny zero-dependency Node app used as the public example for `nacholens`:
|
|
4
|
+
browser (`public/`) → gateway (`gateway.js`) → API (`api.js`) → SQLite (`db.js`),
|
|
5
|
+
plus three third-party services (login provider, shipping tracker, analytics) (`mock-external.js`).
|
|
6
|
+
|
|
7
|
+
node mock-external.js & node api.js & node gateway.js # Node >= 22.13
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Order API: sessions, orders, sales report. Talks to SQLite and to external services.
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { saveState, consumeState, saveSession, findSession, listOrders, insertOrder } from "./db.js";
|
|
5
|
+
|
|
6
|
+
const EXTERNAL = process.env.EXTERNAL_URL ?? "http://127.0.0.1:8090";
|
|
7
|
+
const PUBLIC_ORIGIN = process.env.PUBLIC_ORIGIN ?? "http://127.0.0.1:8080";
|
|
8
|
+
const SESSION_TTL_MS = 8 * 60 * 60 * 1000;
|
|
9
|
+
const ADMINS = new Set((process.env.ADMIN_EMAILS ?? "buyer@example.com").split(","));
|
|
10
|
+
|
|
11
|
+
// Ask the shipping provider for the status of each tracking code; a failure degrades to "unknown".
|
|
12
|
+
async function trackShipping(rows) {
|
|
13
|
+
return Promise.all(rows.map(async (o) => {
|
|
14
|
+
if (!o.tracking) return "none";
|
|
15
|
+
const r = await fetch(`${EXTERNAL}/track?code=${encodeURIComponent(o.tracking)}`).catch(() => null);
|
|
16
|
+
return r && r.ok ? (await r.json()).status : "unknown";
|
|
17
|
+
}));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const send = (res, code, body, headers = {}) => {
|
|
21
|
+
res.writeHead(code, { "content-type": "application/json", ...headers });
|
|
22
|
+
res.end(JSON.stringify(body));
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const readBody = (req) => new Promise((resolve) => {
|
|
26
|
+
let raw = "";
|
|
27
|
+
req.on("data", (c) => (raw += c));
|
|
28
|
+
req.on("end", () => { try { resolve(JSON.parse(raw || "{}")); } catch { resolve(null); } });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
function sessionOf(req) {
|
|
32
|
+
const m = /(?:^|; )sid=([\w-]+)/.exec(req.headers.cookie ?? "");
|
|
33
|
+
return m ? findSession(m[1]) : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function route(req, res) {
|
|
37
|
+
const url = new URL(req.url, "http://x");
|
|
38
|
+
const key = `${req.method} ${url.pathname}`;
|
|
39
|
+
|
|
40
|
+
if (key === "GET /api/auth/start") {
|
|
41
|
+
const state = randomUUID();
|
|
42
|
+
saveState(state);
|
|
43
|
+
const to = new URL(EXTERNAL + "/authorize");
|
|
44
|
+
to.searchParams.set("state", state);
|
|
45
|
+
to.searchParams.set("redirect_uri", PUBLIC_ORIGIN + "/api/auth/callback");
|
|
46
|
+
return send(res, 302, { redirect: to.toString() }, { location: to.toString() });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (key === "GET /api/auth/callback") {
|
|
50
|
+
if (!consumeState(url.searchParams.get("state") ?? "")) return send(res, 400, { error: "bad_state" });
|
|
51
|
+
const token = await fetch(EXTERNAL + "/token", { method: "POST", body: JSON.stringify({ code: url.searchParams.get("code") }) });
|
|
52
|
+
if (!token.ok) return send(res, 502, { error: "provider_failed" });
|
|
53
|
+
const { email } = await token.json();
|
|
54
|
+
const sid = randomUUID();
|
|
55
|
+
saveSession(sid, email, SESSION_TTL_MS);
|
|
56
|
+
return send(res, 302, { ok: true }, { location: "/", "set-cookie": `sid=${sid}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${SESSION_TTL_MS / 1000}` });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const me = sessionOf(req);
|
|
60
|
+
if (!me) return send(res, 401, { error: "unauthenticated" });
|
|
61
|
+
|
|
62
|
+
if (key === "GET /api/orders") {
|
|
63
|
+
const rows = listOrders(me.email);
|
|
64
|
+
const shipping = await trackShipping(rows);
|
|
65
|
+
return send(res, 200, { orders: rows.map((o, i) => ({ id: o.id, item: o.item, qty: o.qty, shipping: shipping[i], createdAt: o.created_at })) });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (key === "POST /api/orders") {
|
|
69
|
+
if (req.headers.origin !== PUBLIC_ORIGIN) return send(res, 403, { error: "bad_origin" });
|
|
70
|
+
const body = await readBody(req);
|
|
71
|
+
if (!body || typeof body.item !== "string" || !Number.isInteger(body.qty) || body.qty < 1 || body.qty > 99)
|
|
72
|
+
return send(res, 400, { error: "invalid_order" });
|
|
73
|
+
const id = insertOrder(me.email, body.item.slice(0, 80), body.qty, "TRK-" + randomUUID().slice(0, 8));
|
|
74
|
+
return send(res, 201, { id });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (key === "GET /api/reports/sales") {
|
|
78
|
+
if (!ADMINS.has(me.email)) return send(res, 403, { error: "forbidden" });
|
|
79
|
+
const r = await fetch(EXTERNAL + "/sales");
|
|
80
|
+
if (!r.ok) return send(res, 502, { error: "analytics_unavailable" });
|
|
81
|
+
return send(res, 200, await r.json());
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
send(res, 404, { error: "not_found" });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const server = http.createServer((req, res) => route(req, res).catch(() => send(res, 500, { error: "internal" })));
|
|
88
|
+
server.listen(Number(process.env.API_PORT ?? 8081), "127.0.0.1", () => console.log("listening on :" + server.address().port));
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// SQLite storage for the demo shop (node:sqlite, Node >= 22.13).
|
|
2
|
+
import { DatabaseSync } from "node:sqlite";
|
|
3
|
+
|
|
4
|
+
const db = new DatabaseSync(process.env.SHOP_DB ?? ":memory:");
|
|
5
|
+
db.exec(`
|
|
6
|
+
CREATE TABLE IF NOT EXISTS oauth_state (state TEXT PRIMARY KEY, created_at INTEGER NOT NULL);
|
|
7
|
+
CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY, email TEXT NOT NULL, expires_at INTEGER NOT NULL);
|
|
8
|
+
CREATE TABLE IF NOT EXISTS orders (
|
|
9
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL, item TEXT NOT NULL,
|
|
10
|
+
qty INTEGER NOT NULL, tracking TEXT, created_at INTEGER NOT NULL
|
|
11
|
+
);
|
|
12
|
+
`);
|
|
13
|
+
|
|
14
|
+
export function saveState(state) {
|
|
15
|
+
db.prepare("INSERT INTO oauth_state (state, created_at) VALUES (?, ?)").run(state, Date.now());
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// A state value works once: the row is removed in the same statement that reads it.
|
|
19
|
+
export function consumeState(state) {
|
|
20
|
+
return db.prepare("DELETE FROM oauth_state WHERE state = ? RETURNING state").get(state) ?? null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function saveSession(id, email, ttlMs) {
|
|
24
|
+
db.prepare("INSERT INTO sessions (id, email, expires_at) VALUES (?, ?, ?)").run(id, email, Date.now() + ttlMs);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function findSession(id) {
|
|
28
|
+
return db.prepare("SELECT email FROM sessions WHERE id = ? AND expires_at > ?").get(id, Date.now()) ?? null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function listOrders(email) {
|
|
32
|
+
return db.prepare("SELECT id, item, qty, tracking, created_at FROM orders WHERE email = ? ORDER BY id DESC LIMIT 50").all(email);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function insertOrder(email, item, qty, tracking) {
|
|
36
|
+
const r = db.prepare("INSERT INTO orders (email, item, qty, tracking, created_at) VALUES (?, ?, ?, ?, ?)").run(email, item, qty, tracking ?? null, Date.now());
|
|
37
|
+
return Number(r.lastInsertRowid);
|
|
38
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Edge gateway: serves the static client and forwards /api/* to the API after a rate-limit check.
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { join, dirname } from "node:path";
|
|
7
|
+
|
|
8
|
+
const API = process.env.API_URL ?? "http://127.0.0.1:8081";
|
|
9
|
+
const PUBLIC_DIR = join(dirname(fileURLToPath(import.meta.url)), "public");
|
|
10
|
+
const LIMIT_PER_MINUTE = 120;
|
|
11
|
+
const hits = new Map();
|
|
12
|
+
|
|
13
|
+
function allowed(ip) {
|
|
14
|
+
const minute = Math.floor(Date.now() / 60000);
|
|
15
|
+
const h = hits.get(ip);
|
|
16
|
+
if (!h || h.minute !== minute) { hits.set(ip, { minute, n: 1 }); return true; }
|
|
17
|
+
return ++h.n <= LIMIT_PER_MINUTE;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const TYPES = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css" };
|
|
21
|
+
|
|
22
|
+
const server = http.createServer(async (req, res) => {
|
|
23
|
+
const requestId = randomUUID();
|
|
24
|
+
if (!allowed(req.socket.remoteAddress ?? "?")) { res.writeHead(429); return res.end("slow down"); }
|
|
25
|
+
|
|
26
|
+
if (req.url.startsWith("/api/")) {
|
|
27
|
+
const upstream = await fetch(API + req.url, {
|
|
28
|
+
method: req.method,
|
|
29
|
+
headers: { ...req.headers, host: new URL(API).host, "x-request-id": requestId },
|
|
30
|
+
body: ["GET", "HEAD"].includes(req.method) ? undefined : req,
|
|
31
|
+
duplex: "half",
|
|
32
|
+
redirect: "manual",
|
|
33
|
+
}).catch(() => null);
|
|
34
|
+
if (!upstream) { res.writeHead(502); return res.end("bad gateway"); }
|
|
35
|
+
const headers = Object.fromEntries(upstream.headers);
|
|
36
|
+
delete headers["content-encoding"];
|
|
37
|
+
res.writeHead(upstream.status, { ...headers, "x-request-id": requestId });
|
|
38
|
+
return res.end(Buffer.from(await upstream.arrayBuffer()));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const name = req.url === "/" ? "index.html" : req.url.slice(1).replace(/[^\w.-]/g, "");
|
|
42
|
+
const ext = name.slice(name.lastIndexOf("."));
|
|
43
|
+
try {
|
|
44
|
+
res.writeHead(200, { "content-type": TYPES[ext] ?? "application/octet-stream" });
|
|
45
|
+
res.end(await readFile(join(PUBLIC_DIR, name)));
|
|
46
|
+
} catch {
|
|
47
|
+
res.writeHead(404); res.end("not found");
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
server.listen(Number(process.env.PORT ?? 8080), "127.0.0.1", () => console.log("listening on :" + server.address().port));
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Stand-in for the three third-party services, so the demo runs fully offline.
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
|
|
4
|
+
const server = http.createServer((req, res) => {
|
|
5
|
+
const url = new URL(req.url, "http://x");
|
|
6
|
+
const json = (code, body) => { res.writeHead(code, { "content-type": "application/json" }); res.end(JSON.stringify(body)); };
|
|
7
|
+
if (url.pathname === "/authorize") { // login provider: send the user straight back
|
|
8
|
+
res.writeHead(302, { location: `${url.searchParams.get("redirect_uri")}?code=demo-code&state=${url.searchParams.get("state")}` });
|
|
9
|
+
return res.end();
|
|
10
|
+
}
|
|
11
|
+
if (url.pathname === "/token") return json(200, { email: "buyer@example.com" });
|
|
12
|
+
if (url.pathname === "/track") return json(200, { status: "in_transit" });
|
|
13
|
+
if (url.pathname === "/sales") return json(200, { total: 12840, orders: 37 });
|
|
14
|
+
json(404, { error: "not_found" });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
server.listen(Number(process.env.EXTERNAL_PORT ?? 8090), "127.0.0.1", () => console.log("listening on :" + server.address().port));
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Browser client of the demo shop. Same-origin calls only; the gateway forwards /api/*.
|
|
2
|
+
import { statusLabel } from "/format.js";
|
|
3
|
+
const $ = (id) => document.getElementById(id);
|
|
4
|
+
|
|
5
|
+
async function api(path, options) {
|
|
6
|
+
const res = await fetch("/api" + path, { credentials: "same-origin", ...options });
|
|
7
|
+
if (res.status === 401) { $("login").hidden = false; throw new Error("signed out"); }
|
|
8
|
+
return res.json();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function loadOrders() {
|
|
12
|
+
const { orders } = await api("/orders");
|
|
13
|
+
$("login").hidden = true;
|
|
14
|
+
$("orders").replaceChildren(...orders.map((o) => {
|
|
15
|
+
const li = document.createElement("li");
|
|
16
|
+
li.textContent = `#${o.id} ${o.item} × ${o.qty} · ${statusLabel(o.shipping)}`;
|
|
17
|
+
return li;
|
|
18
|
+
}));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
$("order-form").addEventListener("submit", async (e) => {
|
|
22
|
+
e.preventDefault();
|
|
23
|
+
const form = new FormData(e.target);
|
|
24
|
+
await api("/orders", {
|
|
25
|
+
method: "POST",
|
|
26
|
+
headers: { "content-type": "application/json" },
|
|
27
|
+
body: JSON.stringify({ item: form.get("item"), qty: Number(form.get("qty")) }),
|
|
28
|
+
});
|
|
29
|
+
await loadOrders();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
loadOrders().catch(() => {});
|
|
33
|
+
api("/reports/sales").then((s) => { $("sales").textContent = `Sales ${s.total} (${s.orders} orders)`; }).catch(() => {});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head><meta charset="utf-8"><title>Demo shop</title><link rel="stylesheet" href="/styles.css"></head>
|
|
4
|
+
<body>
|
|
5
|
+
<header><h1>Demo shop</h1><a id="login" href="/api/auth/start">Sign in</a></header>
|
|
6
|
+
<main>
|
|
7
|
+
<form id="order-form"><input name="item" placeholder="Item" required><input name="qty" type="number" min="1" max="99" value="1"><button>Order</button></form>
|
|
8
|
+
<ul id="orders"></ul>
|
|
9
|
+
<p id="sales"></p>
|
|
10
|
+
</main>
|
|
11
|
+
<script src="/app.js" type="module"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
body { font: 15px/1.5 system-ui, sans-serif; margin: 0 auto; max-width: 640px; padding: 24px; }
|
|
2
|
+
header { display: flex; justify-content: space-between; align-items: baseline; }
|
|
3
|
+
#orders { list-style: none; padding: 0; }
|
|
4
|
+
#orders li { padding: 6px 0; border-bottom: 1px solid #ddd; }
|
|
5
|
+
#orders li small { color: #666; }
|
package/docs/guide.ko.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# nacholens 안내서 (한국어)
|
|
2
|
+
|
|
3
|
+
**AI가 바꾼 백엔드를 코드를 읽지 않고 파악하세요.** ([README](../README.ko.md) · [English guide](guide.md))
|
|
4
|
+
|
|
5
|
+
유즈케이스("로그인", "주문 목록 조회")를 고르면 요청이 클라이언트 → 게이트웨이 → 서버 → DB·외부 API로 움직이고 결과가 돌아오는 과정이 단계별 애니메이션으로 보입니다. 이번 작업에서 바뀐 곳은 색으로 강조되며, 바뀐 곳은 `git diff`에서 자동으로 뽑습니다.
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
*함께 들어 있는 데모 쇼핑몰(게이트웨이 → API → SQLite + 외부 서비스 3개)에서 '주문 목록 보기' 4단계의 요청이 움직이는 장면. 초록 = 신규, 주황 = 변경. 모델의 어떤 단계도 다루지 않는 변경 파일은 조용히 버리지 않고 하단 검토 독에 모읍니다.*
|
|
10
|
+
|
|
11
|
+
## 5분 시작
|
|
12
|
+
|
|
13
|
+
필요한 것: **Node.js ≥ 20, git** (설치할 패키지 없음).
|
|
14
|
+
|
|
15
|
+
**1. 데모 보기 (30초)**
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
git clone https://github.com/mafianacho/nacholens.git && cd nacholens
|
|
19
|
+
node src/cli.js demo
|
|
20
|
+
# → ./nacholens-demo/demo-shop.html (브라우저로 열기)
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
**2. 내 저장소에서 AI 에이전트로 쓰기**
|
|
24
|
+
|
|
25
|
+
설명하고 싶은 저장소에 스킬을 설치합니다.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
node src/cli.js install --claude --into /path/to/your/repo # Claude Code: .claude/skills/nacholens
|
|
29
|
+
node src/cli.js install --agents --into /path/to/your/repo # Codex 등 AGENTS.md 방식: .nacholens/ + AGENTS.md 블록
|
|
30
|
+
node src/cli.js install --to ~/.my-agent/skills # SKILL.md 폴더를 읽는 그 밖의 에이전트
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
그리고 에이전트에게 말합니다.
|
|
34
|
+
|
|
35
|
+
> nacholens 스킬로 이 저장소의 최근 커밋 5개에서 뭐가 바뀌었는지 보여 줘.
|
|
36
|
+
|
|
37
|
+
에이전트가 저장소를 훑고(`scan`), 근거(파일:줄)가 달린 모델을 쓰고, 검증하고, 페이지를 만든 뒤 비개발자 말투로 요약합니다. Claude는 아티팩트, 다른 에이전트는 단일 HTML 파일이 나옵니다.
|
|
38
|
+
|
|
39
|
+
**3. 에이전트 없이 CLI로**
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
SV="node $PWD/src/cli.js"
|
|
43
|
+
$SV build --repo ~/work/my-api --base main --model ~/work/my-api.model.json
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
모델이 없으면 같은 명령이 빈 템플릿을 만들고 채울 곳을 JSON 경로로 나열한 뒤 종료 코드 1로 끝납니다. 자세한 절차와 모델 작성 기준(근거 고르는 법, 단계 크기, 좋은 예·나쁜 예)은 [`skills/nacholens/SKILL.md`](../skills/nacholens/SKILL.md)(영어)를 보세요.
|
|
47
|
+
|
|
48
|
+
## 화면 구성
|
|
49
|
+
|
|
50
|
+
- **왼쪽**: 흐름(유즈케이스) 목록과 선택한 흐름의 단계. 바뀐 단계에는 신규/변경 표시와 리뷰 체크가 붙습니다.
|
|
51
|
+
- **가운데**: 아키텍처 위로 요청이 점으로 움직입니다(재생·일시정지·단계 이동·속도). 상자를 누르면 그 안(부품과 배선 · 테이블 · 엔드포인트)으로 들어가 같은 단계를 안에서 봅니다.
|
|
52
|
+
- **오른쪽**: 지금 단계에서 일어나는 일, 바뀐 이유, 오가는 데이터, 근거 코드.
|
|
53
|
+
- **아래 검토 독**: 아래 세 탭. 탭마다 상태 점과 '남은 건수'가 붙어 탭 줄이 곧 할 일 요약입니다.
|
|
54
|
+
|
|
55
|
+
## 검토 독 (기획 의도 · 회귀 테스트 · 모델 밖 변경)
|
|
56
|
+
|
|
57
|
+
무대 아래 탭에서 봅니다 (자세한 설명: [`review.md`](review.md)).
|
|
58
|
+
|
|
59
|
+
- **전/후 비교**: 변경 전 모델(`--before-model`)을 주면 각 단계의 오른쪽 설명에 '변경 전' 내용이, 흐름 개요에 '없어진 단계'가 나옵니다. 없으면 변경 오버레이(바뀐 이유와 근거)만 보여 줍니다.
|
|
60
|
+
- **기획 의도 대조**: 기획자의 기대사항(`intent.json`)을 모델에 대조해 충족/미충족/확인 불가로 표시합니다. 모델로 증명할 수 없는 항목은 억지로 통과시키지 않고 `확인 불가`로 둡니다. 어떤 기대사항과도 연결되지 않은 변경(범위 이탈 후보)도 따로 모읍니다.
|
|
61
|
+
- **회귀 지도**: 이번 변경이 닿는 유즈케이스(직접 변경 · 같은 테이블/외부 엔드포인트/파트를 쓰는 간접 영향)와, 그 경로를 언급하는 테스트가 있는지를 보여 줍니다. 테스트를 못 찾았다는 것은 '테스트 없음'이 아니라 '경로 문자열로는 못 찾음'입니다.
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
nacholens build --repo R --base B --model M --intent intent.json [--before-model before.json] --strict-intent
|
|
65
|
+
nacholens intent --model M --intent intent.json --repo R --base B
|
|
66
|
+
nacholens impact --repo R --base B --model M
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`--strict-intent` 에서 미충족 기대사항이 있으면 종료 코드 4.
|
|
70
|
+
|
|
71
|
+
## 종료 코드
|
|
72
|
+
|
|
73
|
+
| 코드 | 뜻 |
|
|
74
|
+
|---|---|
|
|
75
|
+
| 0 | 성공 |
|
|
76
|
+
| 1 | 모델/근거 문제 (템플릿 미작성, 스키마 오류, 근거가 소스와 다름) |
|
|
77
|
+
| 2 | 사용법·입출력·git 오류 |
|
|
78
|
+
| 3 | `--strict`: 모델에 없는 **코드** 변경 발견 (스타일 파일만이면 해당 없음) |
|
|
79
|
+
|
|
80
|
+
## 솔직한 한계
|
|
81
|
+
|
|
82
|
+
- **모델은 사람이(또는 에이전트가) 쓰는 선언입니다.** `verify`가 틀린 근거를 잡고 `unmapped`가 새 경로를 알려 주지만, 새 경로를 모델에 넣는 일은 수동입니다. 처음 만드는 모델은 코드를 실제로 읽어야 합니다.
|
|
83
|
+
- **의미 변화는 감지하지 못합니다.** 줄 겹침 기준이라, 근거 줄 밖에서 일어난 동작 변화는 `unmapped` 파일로만 신호가 옵니다. 정적 분석·런타임 추적은 하지 않습니다.
|
|
84
|
+
- 실패 분기(401/403 등)는 별도 경로로 그리지 않습니다. 중요하면 단계로 모델링하세요.
|
|
85
|
+
- **화면 문구는 기본이 영어이고, `build`·`demo`에 `--lang ko`를 붙이면 한국어로 나옵니다.** CLI 메시지와 검증 안내는 아직 한국어입니다. 모델의 라벨·설명은 자유롭게 쓸 수 있습니다.
|
|
86
|
+
- Chromium(headless 포함)에서만 확인했고 Claude.ai 아티팩트 패널은 시험하지 않았습니다.
|
|
87
|
+
- `scan`은 Node(Express·Hono·Fastify·Koa·Next·Nest), Python(FastAPI·Flask·Django), Go(`net/http`·chi·gin·echo), Spring 어노테이션과 흔한 게이트웨이·인프라 파일을 압니다. 그 밖의 스택도 쓸 수 있지만 단서가 적습니다.
|
|
88
|
+
|
|
89
|
+
## 문서
|
|
90
|
+
|
|
91
|
+
[`model-schema.md`](model-schema.md) 모델 형식 · [`CONTRIBUTING.md`](../CONTRIBUTING.md) · [`SECURITY.md`](../SECURITY.md)
|
|
92
|
+
|
|
93
|
+
## 라이선스
|
|
94
|
+
|
|
95
|
+
Apache License 2.0 ([`LICENSE`](../LICENSE), [`NOTICE`](../NOTICE)). 이름과 로고는 상표로 사용할 권리를 부여하지 않습니다. 외부 코드·이미지는 포함하지 않습니다. 페이지에 내장하는 글꼴(IBM Plex Sans / Mono의 서브셋을 Nacho Sans / Nacho Mono로 이름 바꾼 것, Anton)은 SIL Open Font License 1.1을 따릅니다([`src/player/fonts/FONTLOG.md`](../src/player/fonts/FONTLOG.md)).
|
|
96
|
+
|
|
97
|
+
화면은 [Nacho Combo 디자인 시스템](https://github.com/mafianacho/nachocombo)을 따르며 라이트·다크는 시스템 설정을 따릅니다.
|
package/docs/guide.md
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# nacholens guide
|
|
2
|
+
|
|
3
|
+
[README](../README.md) · [한국어 안내서](guide.ko.md)
|
|
4
|
+
|
|
5
|
+
**See what an AI changed in a backend, without reading code.**
|
|
6
|
+
|
|
7
|
+
Pick a use case ("sign in", "list orders") and watch a request travel from the client through the gateway to your services, database and third parties, step by step. Whatever changed in this piece of work is highlighted in colour. The changed parts come straight from `git diff`.
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
*The bundled demo shop (gateway → API → SQLite + three third-party services), step 4 of "List my orders" while the request travels. Green = new, orange = changed. Changed files that no step covers are listed in the review dock: nacholens never drops those silently.*
|
|
12
|
+
|
|
13
|
+
- **Backend and infra at a glance**: flows on the left, the architecture in the middle with the request moving as a dot (play / pause / step / speed), a plain sentence for the current step on the right. Click a box to go inside it (parts and wiring, tables, endpoints) and watch the same step move there.
|
|
14
|
+
- **Evidence, not guesses**: every component, link and step cites `file:lines`; `verify` checks each citation against the real source.
|
|
15
|
+
- **Change overlay from git**: `base..head` is matched against that evidence. Changes that match no step are listed as "not in the model".
|
|
16
|
+
- **Review dock**: three tracks under the stage, each tab showing what is still to check: the planner's expectations (met / unmet / unknown), the use cases to re-test (and whether a test mentions them), and changed files no step covers. With a before-model, a step also shows what it said before. [`review.md`](review.md)
|
|
17
|
+
- **One offline file**: single HTML, no CDN, no network, no dependencies. If the agent running it is Claude, it can emit an artifact instead.
|
|
18
|
+
- **Made for agents**: a `SKILL.md` an AI agent can follow end to end. You ask; the agent writes the model, verifies it and builds the page.
|
|
19
|
+
|
|
20
|
+
## 5-minute start
|
|
21
|
+
|
|
22
|
+
You need **Node.js ≥ 20** and **git**. Nothing to install.
|
|
23
|
+
|
|
24
|
+
### 1. Look at the demo (30 seconds)
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
git clone https://github.com/mafianacho/nacholens.git && cd nacholens
|
|
28
|
+
node src/cli.js demo
|
|
29
|
+
# → ./nacholens-demo/demo-shop.html (open it in a browser)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### 2. Use it on your own repository with an AI agent
|
|
33
|
+
|
|
34
|
+
Install the skill into the repository you want to explain. Pick the line for your agent:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
node src/cli.js install --claude --into /path/to/your/repo # Claude Code: .claude/skills/nacholens
|
|
38
|
+
node src/cli.js install --agents --into /path/to/your/repo # Codex and other AGENTS.md agents: .nacholens/ + a block in AGENTS.md
|
|
39
|
+
node src/cli.js install --to ~/.my-agent/skills # any agent that reads SKILL.md folders
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Then ask your agent, in plain words:
|
|
43
|
+
|
|
44
|
+
> Use the nacholens skill to show me what changed in the last 5 commits of this repo.
|
|
45
|
+
|
|
46
|
+
The agent scans the repo, writes a model with evidence, verifies it, builds the page and tells you what changed, in non-developer words. Claude gets an artifact; every other agent gets a single `.html` file. The installed folder is self-contained (the skill plus a copy of the CLI); it needs no network.
|
|
47
|
+
|
|
48
|
+
### 3. No agent: the CLI by hand
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
SV="node $PWD/src/cli.js"
|
|
52
|
+
$SV build --repo ~/work/my-api --base main --model ~/work/my-api.model.json
|
|
53
|
+
# → ./nacholens-out/<product>.html
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
With no model yet, the same command writes an empty template and lists every blank as a JSON path (exit code 1). Fill it in (`scan` shows leads), then `validate`, `verify`, `build`. Details: [`skills/nacholens/SKILL.md`](../skills/nacholens/SKILL.md).
|
|
57
|
+
|
|
58
|
+
## Commands
|
|
59
|
+
|
|
60
|
+
| Command | What it does |
|
|
61
|
+
|---|---|
|
|
62
|
+
| `demo [--out dir]` | Build the bundled demo (a throw-away git repo with two commits) |
|
|
63
|
+
| `build --repo R --base B [--head H]` | Validate → verify evidence → **derive the change overlay** → write the page |
|
|
64
|
+
| `build <model.json> [--changes c.json]` | Page from a model (and an optional hand-written overlay), no repository needed |
|
|
65
|
+
| `init --repo R` | Empty model template; every blank starts with `TODO` |
|
|
66
|
+
| `scan --repo R [--json] [--limit N]` | **Leads** for drafting a model: stack, routes, gateways, data drivers, outbound hosts, env-injected addresses, HTTP calls |
|
|
67
|
+
| `validate [model] [--changes c]` | Schema check; for a template, the remaining `TODO`s |
|
|
68
|
+
| `verify [model] (--repo R [--ref X] \| --source DIR)` | Every evidence range exists in the source (reads `git show`, no checkout needed) |
|
|
69
|
+
| `diff --repo R --base B [--head H] [--out c.json] [--strict]` | Only the change overlay |
|
|
70
|
+
| `install --claude \| --agents \| --to DIR` | Put the skill and a self-contained CLI where an agent finds it |
|
|
71
|
+
| `intent --model M --intent I [--repo R --base B]` | Check a planner's expectations (`intent.json`) against the model: met / unmet / unknown, plus changes no expectation points at. `--strict-intent` exits 4 on unmet |
|
|
72
|
+
| `impact --repo R --base B` | Regression map: use cases to re-check (direct, or sharing a changed table / endpoint / part) and whether a test mentions them |
|
|
73
|
+
|
|
74
|
+
Review options for `build`: `--before-model <file>` (model written against the base commit; compares the flow itself) and `--intent <intent.json>`. See [`review.md`](review.md).
|
|
75
|
+
Common options: `--model <file>` (default `<repo>/.nacholens/model.json`), `--out <dir>` (default `./nacholens-out`), `--format html\|artifact\|both\|auto`, `--agent <name>` or `NACHOLENS_AGENT`, `--lang en\|ko` (the page's own words; default `en`), `--strict`.
|
|
76
|
+
**The analysed repository is only read.** The CLI writes `--out` and the model file; keep the model outside the repo with `--model`.
|
|
77
|
+
|
|
78
|
+
## How the change overlay works
|
|
79
|
+
|
|
80
|
+
`git diff base..head` is matched against the model's evidence (`file:lines`):
|
|
81
|
+
|
|
82
|
+
- **Changed step**: its evidence overlaps a changed line. `added` when *all* its evidence is brand-new code, `removed` when its files are gone, otherwise `modified`.
|
|
83
|
+
- **Changed component**: its own evidence overlaps, or it is an end point of a changed step or link **and lives in this repository** (`client`, `gateway`, `service`). Databases and third parties are not marked just because a step talks to them.
|
|
84
|
+
- **Not in the model (`unmapped`)**: changed files no step cites. Listed in the review dock ("모델 밖 변경") and in `changes.json` with file, lines and reason. Stylesheets are reported separately (`category: "style"`) and ignored by `--strict`.
|
|
85
|
+
- **Ignored**: tests, docs, lock files, static assets: named with a reason, never silently dropped.
|
|
86
|
+
- Evidence lines refer to `model.source.commit`; if `head` differs, lines are translated through the diff.
|
|
87
|
+
|
|
88
|
+
## Output rules
|
|
89
|
+
|
|
90
|
+
| Running agent | Output |
|
|
91
|
+
|---|---|
|
|
92
|
+
| **Claude** (`--agent claude`) | an **artifact** (`<product>.artifact.html` + `.json`) |
|
|
93
|
+
| any other agent, or a human | a **single HTML file** (`<product>.html`): offline, no CDN |
|
|
94
|
+
|
|
95
|
+
Both come from the same model data and the same player. Exit codes: `0` ok · `1` model/evidence problem · `2` usage, I/O or git error · `3` `--strict` found changed *code* the model does not cover. `report.json` in `--out` is meant for CI (nothing is wired up here).
|
|
96
|
+
|
|
97
|
+
| Code | Meaning |
|
|
98
|
+
|---|---|
|
|
99
|
+
| 0 | success |
|
|
100
|
+
| 1 | model or evidence problem |
|
|
101
|
+
| 2 | usage, I/O or git error |
|
|
102
|
+
| 3 | `--strict`: uncovered code changes |
|
|
103
|
+
|
|
104
|
+
Model and overlay formats: [`model-schema.md`](model-schema.md).
|
|
105
|
+
|
|
106
|
+
## Honest limits
|
|
107
|
+
|
|
108
|
+
- **The model is hand-made.** An agent (or you) reads the code and declares components, use cases and steps. `verify` catches wrong citations and `unmapped` flags new paths, but *adding a new path to the model is manual work*. A first model of a repo takes real reading.
|
|
109
|
+
- **Meaning is not detected.** The overlay is line-overlap based. A behaviour change outside the cited lines (a new field in a response whose evidence lines did not move) shows up only as an unmapped file. There is no static analysis, no runtime tracing.
|
|
110
|
+
- **A file the model cites only in part hides its other changed lines.** If a step cites `models.py:89` and the commit also edits `models.py:54`, the file counts as covered; only files no step cites are listed under "not in the model". Cite the code you want watched.
|
|
111
|
+
- **Failure branches** (401/403/409 …) are not drawn as separate paths; model them as steps if they matter.
|
|
112
|
+
- **The page UI is English by default and Korean with `--lang ko`.** CLI messages and validation hints are still Korean. Model text (labels, narration) is yours and can be any language. Translating the CLI messages is the most wanted contribution.
|
|
113
|
+
- Checked in Chromium only. The Claude.ai artifact panel was not tested.
|
|
114
|
+
- Tested on an unrelated public repository (Python/FastAPI) with a model written by an agent from `SKILL.md` alone: see [`examples/external/`](../examples/external/) (source and licence) and `models/external/`. It is an *example of the process*, not an endorsed description of that project.
|
|
115
|
+
- `scan` knows Node (Express, Hono, Fastify, Koa, Next, Nest), Python (FastAPI, Flask, Django), Go (`net/http`, chi, gin, echo), Spring annotations and common gateway/infra files. Other stacks still work; you just get fewer leads.
|
|
116
|
+
|
|
117
|
+
## Docs
|
|
118
|
+
|
|
119
|
+
- [`skills/nacholens/SKILL.md`](../skills/nacholens/SKILL.md): the agent playbook (read this to understand quality criteria: evidence, step size, good/bad examples)
|
|
120
|
+
- [`model-schema.md`](model-schema.md): `model.json` and `changes.json`
|
|
121
|
+
- [`guide.ko.md`](guide.ko.md): 한국어 안내서
|
|
122
|
+
- [`CONTRIBUTING.md`](../CONTRIBUTING.md), [`SECURITY.md`](../SECURITY.md)
|
|
123
|
+
|
|
124
|
+
## Layout
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
src/cli.js commands, exit codes src/diff.js git diff × evidence → overlay
|
|
128
|
+
src/model.js schema validation src/verify.js evidence vs source
|
|
129
|
+
src/scan.js leads for a model src/page.js model → page (html | artifact)
|
|
130
|
+
src/install.js skill installer src/demo.js deterministic demo repository
|
|
131
|
+
src/player/ template, CSS, JS (SVG player, text via textContent only)
|
|
132
|
+
demo/shop/ public demo app v1 → v2 models/demo-shop/ its model and overlays
|
|
133
|
+
skills/nacholens/SKILL.md examples/ generated demo pages (tested)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`npm test` runs everything (about a second). The demo app needs Node ≥ 22.13 (`node:sqlite`) to run; the tool itself needs Node ≥ 20.
|
|
137
|
+
|
|
138
|
+
## License
|
|
139
|
+
|
|
140
|
+
Apache License 2.0, see [`LICENSE`](../LICENSE) and [`NOTICE`](../NOTICE). The name and logo are not licensed for use as a trademark. No third-party code or images are included. The embedded fonts (subsets of IBM Plex Sans / Mono, renamed Nacho Sans / Nacho Mono, and Anton) are under the SIL Open Font License 1.1, see [`src/player/fonts/FONTLOG.md`](../src/player/fonts/FONTLOG.md).
|
|
141
|
+
|
|
142
|
+
The look follows the [Nacho Combo design system](https://github.com/mafianacho/nachocombo) in light and dark (it follows the system setting).
|