@jenga-ai/agent 3.2.0 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -12
- package/agents/developer.md +16 -1
- package/agents/scrum-master.md +1 -0
- package/bin/jenga.js +10 -0
- package/lib/commands/dashboard.js +92 -0
- package/lib/skill-allow-list.json +6 -2
- package/package.json +21 -2
- package/project/app/api/lib/resolve-project-root.js +120 -0
- package/project/app/api/package.json +16 -0
- package/project/app/api/parsers/architecture.js +72 -0
- package/project/app/api/parsers/board.js +141 -0
- package/project/app/api/parsers/documentation.js +125 -0
- package/project/app/api/parsers/git-log.js +52 -0
- package/project/app/api/parsers/ideas.js +62 -0
- package/project/app/api/parsers/knowledge-graph.js +73 -0
- package/project/app/api/parsers/lib/markdown-dir-reader.js +163 -0
- package/project/app/api/parsers/rapports.js +148 -0
- package/project/app/api/parsers/todo.js +179 -0
- package/project/app/api/response.js +47 -0
- package/project/app/api/routes/architecture.js +23 -0
- package/project/app/api/routes/board.js +46 -0
- package/project/app/api/routes/documentation.js +24 -0
- package/project/app/api/routes/health.js +25 -0
- package/project/app/api/routes/history.js +55 -0
- package/project/app/api/routes/rapports.js +24 -0
- package/project/app/api/scripts/capture-snapshot.js +294 -0
- package/project/app/api/server.js +112 -0
- package/project/app/api/types.js +40 -0
- package/project/app/package.json +21 -0
- package/project/app/ui/dist/assets/index-7fj-vllY.js +104 -0
- package/project/app/ui/dist/assets/index-CdK3Qrep.css +1 -0
- package/project/app/ui/dist/index.html +13 -0
- package/project/app/ui/package.json +23 -0
- package/project/app/ui/scripts/build-snapshot-html.cjs +214 -0
- package/project/app/ui/scripts/dashboard-open.cjs +88 -0
- package/project/app/ui/scripts/dashboard-start.cjs +87 -0
- package/scripts/acquire-concurrency-slot.sh +220 -0
- package/scripts/compute-deploy-reconcile.sh +439 -0
- package/scripts/jenga-permission-level-switch.sh +19 -3
- package/scripts/mark-deployed.sh +532 -0
- package/scripts/populate-knowledge-graph.js +429 -0
- package/scripts/release-concurrency-slot.sh +129 -0
- package/scripts/validate-board.sh +60 -2
- package/scripts/verify-consumer-install.sh +470 -0
- package/skills/j-cloud-connect/SKILL.md +95 -0
- package/skills/j-cloud-connect/scripts/configure-backend.sh +267 -0
- package/skills/j-cloud-connect/scripts/install-rclone.sh +153 -0
- package/skills/j-dashboard/SKILL.md +144 -0
- package/skills/j-dashboard/scripts/launch.sh +121 -0
- package/skills/j-dashboard/scripts/resolve-app-dir.sh +164 -0
- package/skills/j-dashboard/scripts/snapshot.sh +267 -0
- package/skills/j-dashboard-share/SKILL.md +96 -0
- package/skills/j-dashboard-share/scripts/upload-snapshot.sh +173 -0
- package/skills/j-playbook/SKILL.md +12 -0
- package/skills/j-playbook-new/SKILL.md +155 -0
- package/skills/j-playbook-new/scripts/playbook-new.sh +332 -0
- package/skills/j-publish/scripts/npm_ci_pipeline.sh +6 -0
- package/skills/jenga/scripts/load-nl-catalog.js +22 -6
- package/skills/jenga/scripts/load-playbooks.sh +123 -24
- package/templates/SCRUM_BOARD_SCHEMA.md +18 -6
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.loading-spinner{padding:2rem;text-align:center;color:#888;font-size:1rem}.error-message{padding:1rem;background:#fef2f2;border:1px solid #fca5a5;border-radius:4px;color:#b91c1c}.history-tab h2{margin-bottom:1rem}.history-empty{color:#888;padding:2rem;text-align:center}.history-list{list-style:none;padding:0;margin:0;border:1px solid #e5e7eb;border-radius:6px;overflow:hidden}.history-entry{display:flex;align-items:center;gap:.75rem;padding:.65rem 1rem;cursor:pointer;border-bottom:1px solid #f3f4f6;transition:background .12s}.history-entry:last-child{border-bottom:none}.history-entry:hover,.history-entry:focus{background:#f0f7ff;outline:none}.history-entry.selected{background:#dbeafe}.history-badge{flex-shrink:0;font-size:.75rem;font-weight:600;padding:.2rem .55rem;border-radius:999px;white-space:nowrap}.badge-commit{background:#dbeafe;color:#1d4ed8}.badge-rapport{background:#dcfce7;color:#15803d}.history-subject{flex:1;font-size:.9rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#1f2937}.history-date{flex-shrink:0;font-size:.78rem;color:#9ca3af}.panel-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;background:#00000040;z-index:100}.detail-panel{position:fixed;top:0;right:0;bottom:0;width:min(480px,100vw);background:#fff;box-shadow:-4px 0 24px #00000026;z-index:101;overflow-y:auto;padding:1.5rem;animation:slideIn .2s ease-out}@keyframes slideIn{0%{transform:translate(100%)}to{transform:translate(0)}}.panel-close{float:right;background:none;border:none;font-size:1.25rem;cursor:pointer;color:#6b7280;padding:.25rem;line-height:1}.panel-close:hover{color:#111827}.panel-title{margin:0 0 1rem;font-size:1.1rem;color:#1f2937}.panel-meta{display:grid;grid-template-columns:auto 1fr;gap:.3rem 1rem;font-size:.85rem;margin-bottom:1rem}.panel-meta dt{font-weight:600;color:#6b7280;white-space:nowrap}.panel-meta dd{margin:0;color:#1f2937;word-break:break-all}.panel-subject{font-size:.95rem;color:#1f2937}.panel-body{font-size:.82rem;white-space:pre-wrap;background:#f9fafb;border:1px solid #e5e7eb;border-radius:4px;padding:.75rem;max-height:40vh;overflow-y:auto}.panel-empty{color:#9ca3af;font-style:italic}.markdown-body{font-size:.9rem;line-height:1.6;color:#1f2937}.markdown-body h1,.markdown-body h2,.markdown-body h3,.markdown-body h4{margin:1em 0 .4em;line-height:1.3}.markdown-body p{margin:.5em 0}.markdown-body code{background:#f3f4f6;padding:.1em .35em;border-radius:3px;font-size:.85em}.markdown-body pre{background:#f3f4f6;padding:.75rem;border-radius:4px;overflow-x:auto;font-size:.82em}.markdown-body pre code{background:none;padding:0}.markdown-body blockquote{border-left:3px solid #d1d5db;margin:.75em 0;padding:.25em 1em;color:#6b7280}.markdown-body ul,.markdown-body ol{padding-left:1.5em;margin:.5em 0}@media (max-width: 480px){.history-entry{flex-wrap:wrap;row-gap:var(--space-xs)}.history-subject{flex-basis:100%;order:3;white-space:normal}.detail-panel{box-sizing:border-box;padding:var(--space-md)}.panel-close{padding:var(--space-sm);font-size:1.5rem;display:inline-flex;align-items:center;justify-content:center;min-width:44px;min-height:44px}.panel-meta{grid-template-columns:1fr;gap:.15rem 0}.panel-meta dt{margin-top:var(--space-xs)}.markdown-body code{overflow-wrap:break-word;word-break:break-word}.markdown-body table{display:block;max-width:100%;overflow-x:auto}}.board-view{display:flex;flex-direction:column;gap:1.5rem}.epic-card{background:#fff;border:1px solid #e5e7eb;border-radius:6px;padding:1rem}.epic-header{display:flex;align-items:center;gap:.75rem;margin-bottom:.75rem}.epic-title{font-size:1.1rem;font-weight:600;margin:0;cursor:pointer}.epic-id{color:#6b7280;font-size:.85rem}.stories-list{display:flex;flex-direction:column;gap:.75rem;padding-left:1rem}.story-card{background:#f9fafb;border:1px solid #e5e7eb;border-radius:4px;padding:.75rem}.story-header{display:flex;align-items:center;gap:.5rem;margin-bottom:.5rem}.story-title{font-size:1rem;font-weight:500;margin:0;cursor:pointer}.story-id{color:#6b7280;font-size:.8rem}.tasks-list{display:flex;flex-direction:column;gap:.4rem;padding-left:1rem}.task-card{background:#fff;border:1px solid #e5e7eb;border-radius:3px;padding:.5rem .75rem;display:flex;align-items:center;gap:.5rem}.task-title{font-size:.9rem;flex:1;cursor:pointer}.task-id{color:#9ca3af;font-size:.75rem}.task-assigned{color:#6b7280;font-size:.8rem}.status-badge{padding:.2rem .5rem;border-radius:9999px;font-size:.75rem;font-weight:500}.badge-pending{background:#f3f4f6;color:#374151}.badge-running{background:#dbeafe;color:#1d4ed8}.badge-in-progress{background:#ffedd5;color:#c2410c}.badge-passed{background:#dcfce7;color:#15803d}.badge-passed-remarks{background:#fff;color:#15803d;border:1px solid #dcfce7}.badge-done{background:#dbeafe;color:#1d4ed8}.badge-failed{background:#fee2e2;color:#b91c1c}.empty-state{padding:2rem;text-align:center;color:#6b7280}.status-filter{display:flex;flex-wrap:wrap;gap:.75rem;padding:.75rem 1rem;background:#f9fafb;border:1px solid #e5e7eb;border-radius:6px}.status-filter-option{display:flex;align-items:center;gap:.35rem;font-size:.85rem;color:#374151;cursor:pointer}@media (max-width: 480px){.epic-header,.story-header{flex-wrap:wrap;row-gap:var(--space-xs)}.epic-title,.story-title{flex-basis:100%}.task-card{flex-wrap:wrap;row-gap:var(--space-xs)}.task-card .task-title{flex-basis:100%}.stories-list,.tasks-list{padding-left:var(--space-sm)}}.kanban-board{display:flex;align-items:flex-start;gap:.75rem;overflow-x:auto;padding-bottom:.5rem}.kanban-column{flex:0 0 220px;width:220px;background:#f9fafb;border:1px solid #e5e7eb;border-radius:6px;padding:.6rem;display:flex;flex-direction:column;gap:.5rem}.kanban-column-empty{flex:0 0 140px;width:140px;opacity:.6}.kanban-column-header{display:flex;align-items:center;justify-content:space-between;gap:.4rem}.kanban-column-title{font-size:.8rem;font-weight:600;color:#374151;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.kanban-column-count{font-size:.7rem;color:#6b7280;background:#e5e7eb;border-radius:9999px;padding:.05rem .4rem}.kanban-column-empty-state{font-size:.75rem;color:#9ca3af;text-align:center;padding:.5rem 0}.kanban-column-items{display:flex;flex-direction:column;gap:.5rem}.kanban-card{background:#fff;border:1px solid #e5e7eb;border-radius:4px;padding:.5rem .6rem;display:flex;flex-direction:column;gap:.3rem}.kanban-card-header{display:flex;align-items:center;justify-content:space-between;gap:.4rem}.kanban-card-type{font-size:.65rem;text-transform:uppercase;letter-spacing:.03em;color:#9ca3af}.kanban-card-id{font-size:.7rem;color:#9ca3af}.kanban-card-title{font-size:.85rem;color:#111827;cursor:pointer}.kanban-card-queued{border-style:dashed;border-color:#c4b5fd;background:#fdfcff}.kanban-card-footer{display:flex;align-items:center;justify-content:space-between;gap:.4rem}.kanban-card-queued-marker{font-size:.65rem;text-transform:uppercase;letter-spacing:.03em;color:#6d28d9;background:#ede9fe;border:1px solid #ddd6fe;border-radius:9999px;padding:.05rem .4rem;white-space:nowrap;cursor:help}@media (max-width: 480px){.kanban-board{position:relative;gap:var(--space-sm);scroll-snap-type:x mandatory;-webkit-overflow-scrolling:touch}.kanban-column{flex:0 0 85%;width:85%;scroll-snap-align:start;scroll-snap-stop:always}.kanban-column-empty{flex:0 0 140px;width:140px}.kanban-board:after{content:"";position:sticky;top:0;right:0;flex:0 0 var(--space-xl);align-self:stretch;background:linear-gradient(to right,transparent,rgba(249,250,251,.95));pointer-events:none}}.architecture-tab{overflow-x:hidden}.architecture-tab h2{margin-bottom:1rem}.arch-empty{color:#9ca3af;font-style:italic}.arch-section{margin-bottom:2rem}.arch-section h3{font-size:1rem;font-weight:600;color:#374151;margin-bottom:.75rem;border-bottom:1px solid #e5e7eb;padding-bottom:.35rem}.tech-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:.75rem}.tech-card{background:#fff;border:1px solid #e5e7eb;border-radius:8px;padding:.85rem 1rem;display:flex;flex-direction:column;gap:.3rem;box-shadow:0 1px 3px #0000000d}.tech-name{font-weight:600;font-size:.9rem;color:#1f2937}.tech-description{font-size:.78rem;color:#6b7280}.dep-table{width:100%;border-collapse:collapse;font-size:.875rem}.dep-table th{text-align:left;padding:.5rem .75rem;background:#f9fafb;border:1px solid #e5e7eb;font-weight:600;color:#374151}.dep-table td{padding:.45rem .75rem;border:1px solid #f3f4f6;color:#1f2937}.dep-table tr:hover td{background:#f9fafb}.dep-name{font-family:monospace}.dep-version{color:#6b7280;font-family:monospace}.dep-badge{display:inline-block;font-size:.7rem;font-weight:600;padding:.18rem .55rem;border-radius:999px;white-space:nowrap}.dep-runtime{background:#dbeafe;color:#1d4ed8}.dep-dev{background:#f3f4f6;color:#4b5563}.dep-table-wrap{overflow-x:auto}.sad-map-scroll{width:100%;min-height:320px;overflow:auto;-webkit-overflow-scrolling:touch;border:1px solid var(--border, #e2e8f0);border-radius:8px;background:var(--bg-secondary, #f8fafc)}.sad-map-svg{display:block;min-width:800px;min-height:500px;padding:8px}.sad-empty{color:var(--text-muted, #94a3b8);font-style:italic}@media (max-width: 480px){.architecture-tab{padding-bottom:var(--space-sm)}.arch-section{margin-bottom:var(--space-lg)}.arch-section h3{padding-bottom:var(--space-xs)}.tech-grid{gap:var(--space-sm)}.tech-card{padding:var(--space-sm) var(--space-md)}.sad-map-scroll{min-height:180px}}.entry-list-empty{color:#888;padding:2rem;text-align:center}.entry-list{list-style:none;padding:0;margin:0;border:1px solid #e5e7eb;border-radius:6px;overflow:hidden}.entry-list-item{display:flex;align-items:center;gap:.75rem;padding:.65rem 1rem;cursor:pointer;border-bottom:1px solid #f3f4f6;transition:background .12s}.entry-list-item:last-child{border-bottom:none}.entry-list-item:hover,.entry-list-item:focus{background:#f0f7ff;outline:none}.entry-list-item.selected{background:#dbeafe}.entry-badge{flex-shrink:0;font-size:.75rem;font-weight:600;padding:.2rem .55rem;border-radius:999px;white-space:nowrap;text-transform:capitalize;background:#ede9fe;color:#6d28d9}.entry-title{flex:1;font-size:.9rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#1f2937}.entry-date{flex-shrink:0;font-size:.78rem;color:#9ca3af}@media (max-width: 480px){.entry-list-item{flex-wrap:wrap;row-gap:var(--space-xs)}.entry-title{flex-basis:100%;order:3;white-space:normal}}.pagination-controls{display:flex;align-items:center;justify-content:center;gap:var(--space-md);margin-top:var(--space-md)}.pagination-btn{padding:.4rem .9rem;font-size:.85rem;border:1px solid #e5e7eb;border-radius:6px;background:#fff;color:#1f2937;cursor:pointer}.pagination-btn:hover:not(:disabled){background:#f0f7ff;border-color:#93c5fd}.pagination-btn:disabled{opacity:.45;cursor:not-allowed}.pagination-status{font-size:.82rem;color:#6b7280;white-space:nowrap}@media (max-width: 480px){.pagination-controls{gap:var(--space-sm)}.pagination-btn{padding:.5rem .85rem;font-size:.9rem}.pagination-status{font-size:.78rem}}.rapports-search-input{display:block;width:100%;box-sizing:border-box;padding:.55rem .75rem;margin-bottom:var(--space-md);font-size:.9rem;border:1px solid #e5e7eb;border-radius:6px;color:#1f2937}.rapports-search-input:focus{outline:none;border-color:#93c5fd;box-shadow:0 0 0 2px #3b82f640}@media (max-width: 480px){.rapports-search-input{font-size:1rem;padding:.65rem .75rem}}:root{--space-xs: .25rem;--space-sm: .5rem;--space-md: 1rem;--space-lg: 1.5rem;--space-xl: 2rem;--breakpoint-mobile: 480px;--breakpoint-tablet: 768px}body{font-family:sans-serif;margin:0;background:#f5f5f5}.app{max-width:1200px;margin:0 auto;padding:1rem}.tabs{display:flex;gap:0;border-bottom:1px solid #ddd}.tab-btn{padding:.6rem 1.2rem;border:none;background:transparent;cursor:pointer;font-size:1rem;color:#555;border-bottom:2px solid transparent}.tab-btn.active{color:#2563eb;border-bottom-color:#2563eb;font-weight:600}.tab-content{padding:1rem 0}@media (max-width: 480px){.app{width:100%;box-sizing:border-box;padding:var(--space-sm)}.tabs{flex-wrap:nowrap;overflow-x:auto;-webkit-overflow-scrolling:touch}.tab-btn{flex:0 0 auto;padding:var(--space-sm) var(--space-md)}}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Jenga AI Dashboard</title>
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-7fj-vllY.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="/assets/index-CdK3Qrep.css">
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<div id="root"></div>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "jenga-dashboard",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite",
|
|
8
|
+
"build": "vite build",
|
|
9
|
+
"preview": "vite preview",
|
|
10
|
+
"dashboard:start": "node scripts/dashboard-start.cjs",
|
|
11
|
+
"dashboard:open": "node scripts/dashboard-open.cjs"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"dompurify": "^3.4.2",
|
|
15
|
+
"marked": "^18.0.3",
|
|
16
|
+
"react": "^18.2.0",
|
|
17
|
+
"react-dom": "^18.2.0"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@vitejs/plugin-react": "^4.2.1",
|
|
21
|
+
"vite": "^5.0.8"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* build-snapshot-html.cjs
|
|
4
|
+
*
|
|
5
|
+
* Turns an already-built dashboard `dist/` into ONE self-contained HTML file
|
|
6
|
+
* with a captured data snapshot embedded inline — with zero dependencies, so it
|
|
7
|
+
* runs identically in this monorepo and in a consumer's node_modules.
|
|
8
|
+
*
|
|
9
|
+
* Why this replaced the vite path
|
|
10
|
+
* -------------------------------
|
|
11
|
+
* `--snapshot` used to shell out to `npm run build:snapshot` (vite build --mode
|
|
12
|
+
* snapshot) inside project/app/ui. That can only ever work in this monorepo:
|
|
13
|
+
* the published tarball ships `project/app/ui/dist/**` and `scripts/**` and
|
|
14
|
+
* nothing else — no package.json, no vite.config.js, no src/ — so there is no
|
|
15
|
+
* vite to run and no sources to build. `--snapshot` was therefore dead on every
|
|
16
|
+
* consumer install, and the bats suite could not see it because it faked `npm`
|
|
17
|
+
* on PATH.
|
|
18
|
+
*
|
|
19
|
+
* Inlining a prebuilt dist needs no build tooling at all, so the SAME code path
|
|
20
|
+
* now runs in both places. That is the point: the consumer path is no longer a
|
|
21
|
+
* separate, never-exercised branch — every local `--snapshot` run exercises it.
|
|
22
|
+
*
|
|
23
|
+
* What it does, given <dist>/index.html:
|
|
24
|
+
* 1. Inlines every <script src> into <script> with the file's contents.
|
|
25
|
+
* 2. Inlines every <link rel=stylesheet href> into <style>.
|
|
26
|
+
* 3. Drops <link rel=modulepreload> — the module it preloads is now inline.
|
|
27
|
+
* 4. Injects the captured JSON as
|
|
28
|
+
* <script id="jenga-dashboard-data" type="application/json"> before </head>,
|
|
29
|
+
* which src/api/client.js's get() already reads instead of calling fetch()
|
|
30
|
+
* whenever it is present. That runtime branch is NOT build-mode-gated, so
|
|
31
|
+
* the ordinary `vite build` output is already snapshot-capable.
|
|
32
|
+
* 5. Fails loudly if any local asset reference survives, so a future UI change
|
|
33
|
+
* that adds an image/font/chunk can never silently ship a broken snapshot.
|
|
34
|
+
*
|
|
35
|
+
* Usage:
|
|
36
|
+
* node build-snapshot-html.cjs --dist <dir> --data <json> --out <html>
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
'use strict';
|
|
40
|
+
|
|
41
|
+
const fs = require('fs');
|
|
42
|
+
const path = require('path');
|
|
43
|
+
|
|
44
|
+
function die(msg) {
|
|
45
|
+
console.error(`Error: ${msg}`);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Parse args ───────────────────────────────────────────────────────────────
|
|
50
|
+
const args = process.argv.slice(2);
|
|
51
|
+
const opts = { dist: '', data: '', out: '' };
|
|
52
|
+
|
|
53
|
+
for (let i = 0; i < args.length; i++) {
|
|
54
|
+
const flag = args[i];
|
|
55
|
+
if (flag === '--dist' || flag === '--data' || flag === '--out') {
|
|
56
|
+
const value = args[++i];
|
|
57
|
+
if (value === undefined) die(`${flag} requires a value`);
|
|
58
|
+
opts[flag.slice(2)] = value;
|
|
59
|
+
} else if (flag === '-h' || flag === '--help') {
|
|
60
|
+
console.log('Usage: build-snapshot-html.cjs --dist <dir> --data <json> --out <html>');
|
|
61
|
+
process.exit(0);
|
|
62
|
+
} else {
|
|
63
|
+
die(`unknown argument: ${flag}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
for (const required of ['dist', 'data', 'out']) {
|
|
68
|
+
if (!opts[required]) die(`--${required} is required`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const DIST_DIR = path.resolve(opts.dist);
|
|
72
|
+
const INDEX_HTML = path.join(DIST_DIR, 'index.html');
|
|
73
|
+
|
|
74
|
+
if (!fs.existsSync(INDEX_HTML)) {
|
|
75
|
+
die(
|
|
76
|
+
`no built dashboard found at ${INDEX_HTML}. ` +
|
|
77
|
+
'In this monorepo run `npm run ui:build --prefix project/app`; in a consumer install ' +
|
|
78
|
+
'this means the package shipped without project/app/ui/dist (a packaging regression).'
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Read the captured data ───────────────────────────────────────────────────
|
|
83
|
+
// Parse rather than pass through, so a malformed capture artifact fails here,
|
|
84
|
+
// loudly, instead of producing a snapshot that only breaks once opened.
|
|
85
|
+
let snapshotData;
|
|
86
|
+
try {
|
|
87
|
+
snapshotData = JSON.parse(fs.readFileSync(opts.data, 'utf8'));
|
|
88
|
+
} catch (err) {
|
|
89
|
+
die(`could not read/parse snapshot data at ${opts.data}: ${err.message}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let html = fs.readFileSync(INDEX_HTML, 'utf8');
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Resolves an asset reference from index.html to a path on disk. Vite emits
|
|
96
|
+
* root-absolute refs ("/assets/index-abc.js") by default, but a custom `base`
|
|
97
|
+
* can make them relative ("./assets/...") — both resolve against dist.
|
|
98
|
+
* Returns null for anything we must not try to inline (protocol-absolute URLs,
|
|
99
|
+
* data: URIs), which the caller leaves untouched.
|
|
100
|
+
*/
|
|
101
|
+
function resolveAsset(ref) {
|
|
102
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(ref) || ref.startsWith('//')) return null;
|
|
103
|
+
const relative = ref.replace(/^\//, '').replace(/^\.\//, '').split('?')[0].split('#')[0];
|
|
104
|
+
return path.join(DIST_DIR, relative);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function readAsset(ref, kind) {
|
|
108
|
+
const assetPath = resolveAsset(ref);
|
|
109
|
+
if (assetPath === null) return null;
|
|
110
|
+
if (!fs.existsSync(assetPath)) {
|
|
111
|
+
die(`${kind} referenced by index.html not found on disk: ${ref} (resolved to ${assetPath})`);
|
|
112
|
+
}
|
|
113
|
+
return fs.readFileSync(assetPath, 'utf8');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Escapes a closing tag inside inlined content so it cannot terminate the
|
|
117
|
+
// wrapper element early. `<\/script` is valid inside a JS string literal and
|
|
118
|
+
// parses identically; the same trick applies to `</style` in CSS.
|
|
119
|
+
function escapeClosingTag(content, tagName) {
|
|
120
|
+
return content.replace(new RegExp(`</${tagName}`, 'gi'), `<\\/${tagName}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function attr(tag, name) {
|
|
124
|
+
const match = tag.match(new RegExp(`\\b${name}=["']([^"']*)["']`, 'i'));
|
|
125
|
+
return match ? match[1] : null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── 0. Reserve the real </head> injection point ──────────────────────────────
|
|
129
|
+
// This MUST run before step 1. Step 4 injects the payload before `</head>` with
|
|
130
|
+
// a first-match regex, and step 1 inlines the ESM bundle INTO the head. The
|
|
131
|
+
// bundle carries its own literal '</head>' -- DOMPurify builds an XHTML wrapper
|
|
132
|
+
// string, `'<html xmlns="..."><head></head><body>' + dirty + '</body></html>'`
|
|
133
|
+
// -- so after inlining, the first '</head>' in the document is that JS string
|
|
134
|
+
// literal, not the document's own. Injecting there splices the payload into the
|
|
135
|
+
// middle of the bundle's source, and the payload's trailing '</script>'
|
|
136
|
+
// terminates the module element early, dumping the rest of the bundle onto the
|
|
137
|
+
// page as visible text. Step 1 already escapes '</script' in inlined code for
|
|
138
|
+
// the same class of reason; it cannot escape '</head>', because that sequence
|
|
139
|
+
// is only dangerous to the anchor below, not to the script element itself.
|
|
140
|
+
// Claiming the anchor up front makes the injection point order-independent.
|
|
141
|
+
if (!/<\/head>/i.test(html)) {
|
|
142
|
+
die(`${INDEX_HTML} has no </head> to inject the snapshot data before`);
|
|
143
|
+
}
|
|
144
|
+
const DATA_PLACEHOLDER = '<!--__JENGA_SNAPSHOT_DATA__-->';
|
|
145
|
+
html = html.replace(/<\/head>/i, () => `${DATA_PLACEHOLDER}\n </head>`);
|
|
146
|
+
|
|
147
|
+
// ── 1. Inline <script src="..."> ─────────────────────────────────────────────
|
|
148
|
+
html = html.replace(/<script\b([^>]*)\bsrc=["']([^"']+)["']([^>]*)><\/script>/gi, (tag, pre, src, post) => {
|
|
149
|
+
const code = readAsset(src, 'script');
|
|
150
|
+
if (code === null) return tag; // external URL — leave as-is
|
|
151
|
+
// Preserve type="module": the dist bundle is ESM and breaks when run as a
|
|
152
|
+
// classic script. crossorigin is dropped; it is meaningless once inline.
|
|
153
|
+
const isModule = /type=["']module["']/i.test(pre + post);
|
|
154
|
+
const typeAttr = isModule ? ' type="module"' : '';
|
|
155
|
+
return `<script${typeAttr}>\n${escapeClosingTag(code, 'script')}\n</script>`;
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// ── 2 & 3. Inline stylesheets, drop modulepreload ────────────────────────────
|
|
159
|
+
html = html.replace(/<link\b[^>]*>/gi, (tag) => {
|
|
160
|
+
const rel = (attr(tag, 'rel') || '').toLowerCase();
|
|
161
|
+
if (rel === 'modulepreload' || rel === 'preload') return '';
|
|
162
|
+
if (rel !== 'stylesheet') return tag; // icons, manifests, etc. stay untouched
|
|
163
|
+
const href = attr(tag, 'href');
|
|
164
|
+
if (!href) return tag;
|
|
165
|
+
const css = readAsset(href, 'stylesheet');
|
|
166
|
+
if (css === null) return tag;
|
|
167
|
+
return `<style>\n${escapeClosingTag(css, 'style')}\n</style>`;
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// ── 4. Inject the captured snapshot data ─────────────────────────────────────
|
|
171
|
+
// Escaping every '<' as its JSON unicode escape keeps the payload from
|
|
172
|
+
// prematurely closing its own <script> tag, whatever the board/history
|
|
173
|
+
// content happens to contain.
|
|
174
|
+
const serialized = JSON.stringify(snapshotData).replace(/</g, '\\u003c');
|
|
175
|
+
const dataTag = `<script id="jenga-dashboard-data" type="application/json">${serialized}</script>`;
|
|
176
|
+
|
|
177
|
+
// A replacer FUNCTION, not a string: board content legitimately contains '$&',
|
|
178
|
+
// '$`' and similar sequences (markdown code spans like `^[1-5]$`), which a
|
|
179
|
+
// string replacement would interpret as backreferences and splice the document
|
|
180
|
+
// into itself. A function's return value is used verbatim.
|
|
181
|
+
// The placeholder was claimed in step 0, before any inlining could contribute a
|
|
182
|
+
// competing '</head>'; see there for why the anchor cannot be taken here.
|
|
183
|
+
html = html.replace(DATA_PLACEHOLDER, () => ` ${dataTag}`);
|
|
184
|
+
|
|
185
|
+
// ── 5. Refuse to emit a snapshot that still points at files it does not carry ─
|
|
186
|
+
// Without this, adding (say) a background image to the UI would produce a
|
|
187
|
+
// snapshot that looks fine here and renders broken on the recipient's machine.
|
|
188
|
+
const leftovers = [...html.matchAll(/\b(?:src|href)=["'](?!data:|https?:|#|\/\/)([^"']+)["']/gi)]
|
|
189
|
+
.map((m) => m[1])
|
|
190
|
+
.filter((ref) => {
|
|
191
|
+
const resolved = resolveAsset(ref);
|
|
192
|
+
return resolved !== null && fs.existsSync(resolved);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// Same check for url() references inside the CSS we just inlined — a font or
|
|
196
|
+
// background image reached that way is just as missing on the recipient's
|
|
197
|
+
// machine, and never appears as an src=/href= attribute.
|
|
198
|
+
for (const match of html.matchAll(/url\(\s*["']?(?!data:|https?:|#|\/\/)([^"')]+)["']?\s*\)/gi)) {
|
|
199
|
+
const resolved = resolveAsset(match[1]);
|
|
200
|
+
if (resolved !== null && fs.existsSync(resolved)) leftovers.push(match[1]);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (leftovers.length > 0) {
|
|
204
|
+
die(
|
|
205
|
+
'snapshot would not be self-contained — these local asset references survived inlining: ' +
|
|
206
|
+
`${[...new Set(leftovers)].join(', ')}. ` +
|
|
207
|
+
'Teach build-snapshot-html.cjs to inline them before shipping this UI change.'
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ── Write ────────────────────────────────────────────────────────────────────
|
|
212
|
+
fs.mkdirSync(path.dirname(path.resolve(opts.out)), { recursive: true });
|
|
213
|
+
fs.writeFileSync(opts.out, html);
|
|
214
|
+
console.log(`Inlined single-file dashboard: ${opts.out}`);
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* jenga dashboard open
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* node scripts/dashboard-open.js [--port <number>]
|
|
7
|
+
*
|
|
8
|
+
* Performs a health check against the API server, then opens the dashboard
|
|
9
|
+
* URL in the system's default browser. Always exits with code 0.
|
|
10
|
+
*
|
|
11
|
+
* --port <number> Port to check / open (default: 3001).
|
|
12
|
+
* Also respects JENGA_API_PORT env var.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
const http = require('http');
|
|
18
|
+
const { execFile } = require('child_process');
|
|
19
|
+
|
|
20
|
+
// ── Parse CLI flags ───────────────────────────────────────────────────────────
|
|
21
|
+
const args = process.argv.slice(2);
|
|
22
|
+
|
|
23
|
+
let port = parseInt(process.env.JENGA_API_PORT || '3001', 10);
|
|
24
|
+
|
|
25
|
+
for (let i = 0; i < args.length; i++) {
|
|
26
|
+
if (args[i] === '--port' && args[i + 1]) {
|
|
27
|
+
const p = parseInt(args[++i], 10);
|
|
28
|
+
if (!isNaN(p) && p >= 1 && p <= 65535) {
|
|
29
|
+
port = p;
|
|
30
|
+
} else {
|
|
31
|
+
console.error(`Error: --port must be a valid port number (1-65535), got: ${args[i]}`);
|
|
32
|
+
process.exit(0); // still exit 0 per spec
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const url = `http://localhost:${port}`;
|
|
38
|
+
|
|
39
|
+
// ── Health check ─────────────────────────────────────────────────────────────
|
|
40
|
+
function healthCheck(timeoutMs = 2000) {
|
|
41
|
+
return new Promise(resolve => {
|
|
42
|
+
const req = http.get(`${url}/v1/health`, res => {
|
|
43
|
+
resolve(res.statusCode >= 200 && res.statusCode < 300);
|
|
44
|
+
res.resume();
|
|
45
|
+
});
|
|
46
|
+
req.setTimeout(timeoutMs, () => {
|
|
47
|
+
req.destroy();
|
|
48
|
+
resolve(false);
|
|
49
|
+
});
|
|
50
|
+
req.on('error', () => resolve(false));
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── Open browser ──────────────────────────────────────────────────────────────
|
|
55
|
+
function openBrowser(targetUrl) {
|
|
56
|
+
return new Promise(resolve => {
|
|
57
|
+
let cmd, args;
|
|
58
|
+
switch (process.platform) {
|
|
59
|
+
case 'darwin': cmd = 'open'; args = [targetUrl]; break;
|
|
60
|
+
case 'win32': cmd = 'cmd'; args = ['/c', 'start', '', targetUrl]; break;
|
|
61
|
+
default: cmd = 'xdg-open'; args = [targetUrl]; break;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
execFile(cmd, args, err => {
|
|
65
|
+
if (err && err.code === 'ENOENT') {
|
|
66
|
+
console.log(`Open your browser at: ${targetUrl}`);
|
|
67
|
+
} else if (err) {
|
|
68
|
+
console.log(`Open your browser at: ${targetUrl}`);
|
|
69
|
+
}
|
|
70
|
+
resolve();
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Main ──────────────────────────────────────────────────────────────────────
|
|
76
|
+
(async () => {
|
|
77
|
+
const healthy = await healthCheck();
|
|
78
|
+
|
|
79
|
+
if (!healthy) {
|
|
80
|
+
console.warn(`⚠ Warning: Dashboard server does not appear to be running at ${url}`);
|
|
81
|
+
console.warn(' Start it with: node project/app/ui/scripts/dashboard-start.cjs');
|
|
82
|
+
console.log(`\nOpen your browser at: ${url}`);
|
|
83
|
+
process.exit(0);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
await openBrowser(url);
|
|
87
|
+
process.exit(0);
|
|
88
|
+
})();
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* jenga dashboard start
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* node scripts/dashboard-start.js [--port <number>] [--serve-app]
|
|
7
|
+
*
|
|
8
|
+
* Starts the Jenga AI API server. Accepts:
|
|
9
|
+
* --port <number> Port to listen on (default: 3001). Also respects
|
|
10
|
+
* JENGA_API_PORT env var. CLI flag takes precedence.
|
|
11
|
+
* --serve-app Also serve the dashboard/dist/ static files from Express.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
|
|
19
|
+
// ── Parse CLI flags ───────────────────────────────────────────────────────────
|
|
20
|
+
const args = process.argv.slice(2);
|
|
21
|
+
|
|
22
|
+
let port = parseInt(process.env.JENGA_API_PORT || '3001', 10);
|
|
23
|
+
let serveApp = false;
|
|
24
|
+
|
|
25
|
+
for (let i = 0; i < args.length; i++) {
|
|
26
|
+
if (args[i] === '--port' && args[i + 1]) {
|
|
27
|
+
const p = parseInt(args[++i], 10);
|
|
28
|
+
if (isNaN(p) || p < 1 || p > 65535) {
|
|
29
|
+
console.error(`Error: --port must be a valid port number (1-65535), got: ${args[i]}`);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
port = p;
|
|
33
|
+
} else if (args[i] === '--serve-app') {
|
|
34
|
+
serveApp = true;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Pass port to the server via env var before requiring it
|
|
39
|
+
process.env.JENGA_API_PORT = String(port);
|
|
40
|
+
|
|
41
|
+
// ── Optionally patch the app to serve static files ────────────────────────────
|
|
42
|
+
const APP_ROOT = path.resolve(__dirname, '../..');
|
|
43
|
+
const DIST_DIR = path.join(APP_ROOT, 'ui', 'dist');
|
|
44
|
+
const SERVER_PATH = path.join(APP_ROOT, 'api', 'server.js');
|
|
45
|
+
|
|
46
|
+
if (!fs.existsSync(SERVER_PATH)) {
|
|
47
|
+
console.error(`Error: API server not found at ${SERVER_PATH}`);
|
|
48
|
+
console.error('Make sure you are running this script from the repository root.');
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── Load the API server ───────────────────────────────────────────────────────
|
|
53
|
+
const { app, registerShutdownHandlers, attachFallbackRoutes } = require(SERVER_PATH);
|
|
54
|
+
|
|
55
|
+
if (serveApp) {
|
|
56
|
+
const express = require('express');
|
|
57
|
+
if (!fs.existsSync(DIST_DIR)) {
|
|
58
|
+
console.warn(`Warning: dashboard/dist/ not found at ${DIST_DIR}`);
|
|
59
|
+
console.warn('Run "npm run build" inside the dashboard/ directory first.');
|
|
60
|
+
}
|
|
61
|
+
app.use(express.static(DIST_DIR));
|
|
62
|
+
// Fallback to index.html for SPA routing, but leave /v1/* API routes alone
|
|
63
|
+
// so unmatched API paths still get a JSON 404 instead of the SPA shell.
|
|
64
|
+
app.get(/^(?!\/v1\/).*/, (_req, res) => {
|
|
65
|
+
const indexPath = path.join(DIST_DIR, 'index.html');
|
|
66
|
+
if (fs.existsSync(indexPath)) {
|
|
67
|
+
res.sendFile(indexPath);
|
|
68
|
+
} else {
|
|
69
|
+
res.status(404).send('Dashboard not built. Run "npm run build" in dashboard/.');
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
// rootRedirect: false — the static middleware above already serves
|
|
73
|
+
// index.html at '/'; the unconditional /v1/health redirect would shadow it.
|
|
74
|
+
attachFallbackRoutes(app, { rootRedirect: false });
|
|
75
|
+
} else {
|
|
76
|
+
attachFallbackRoutes(app);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Start listening ───────────────────────────────────────────────────────────
|
|
80
|
+
const server = app.listen(port, () => {
|
|
81
|
+
console.log(`Dashboard API running at http://localhost:${port}`);
|
|
82
|
+
if (serveApp) {
|
|
83
|
+
console.log(`Dashboard app running at http://localhost:${port}`);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
registerShutdownHandlers(server);
|