xtruct 0.1.0__py3-none-any.whl
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.
- xtruct/__init__.py +3 -0
- xtruct/__main__.py +5 -0
- xtruct/_cli/bin/xtruct-foundry.js +3 -0
- xtruct/_cli/bin/xtruct.js +541 -0
- xtruct/_cli/global/claude-md-block.md +5 -0
- xtruct/_cli/global/skills/foundry/SKILL.md +46 -0
- xtruct/_cli/lib/dashboard.html +326 -0
- xtruct/_cli/lib/dashboard.js +212 -0
- xtruct/_cli/lib/paths.js +17 -0
- xtruct/_cli/lib/setup.js +136 -0
- xtruct/_cli/lib/tracker.js +155 -0
- xtruct/_cli/lib/ui.js +95 -0
- xtruct/_cli/package.json +44 -0
- xtruct/_cli/template/.claude/agents/architect.md +35 -0
- xtruct/_cli/template/.claude/agents/integrator.md +16 -0
- xtruct/_cli/template/.claude/agents/junior-dev.md +31 -0
- xtruct/_cli/template/.claude/agents/qa-lead.md +17 -0
- xtruct/_cli/template/.claude/agents/reviewer.md +23 -0
- xtruct/_cli/template/.claude/agents/senior-lead.md +29 -0
- xtruct/_cli/template/.claude/hooks/guard-bash.sh +38 -0
- xtruct/_cli/template/.claude/hooks/guard-files.sh +24 -0
- xtruct/_cli/template/.claude/settings.json +39 -0
- xtruct/_cli/template/.claude/skills/run-epics/SKILL.md +45 -0
- xtruct/_cli/template/.claude/workflows/plan-product.js +136 -0
- xtruct/_cli/template/.claude/workflows/run-epic.js +248 -0
- xtruct/_cli/template/.github/pull_request_template.md +24 -0
- xtruct/_cli/template/CLAUDE.md +65 -0
- xtruct/_cli/template/Makefile +8 -0
- xtruct/_cli/template/_gitignore +13 -0
- xtruct/_cli/template/contracts/README.md +4 -0
- xtruct/_cli/template/docs/CONVENTIONS.md +3 -0
- xtruct/_cli/template/docs/templates/ARCHITECTURE.md +30 -0
- xtruct/_cli/template/docs/templates/PRD.md +25 -0
- xtruct/_cli/template/docs/templates/ci.yml +22 -0
- xtruct/_cli/template/foundry.config.json +25 -0
- xtruct/_cli/template/scripts/bootstrap-github.sh +48 -0
- xtruct/_cli/template/scripts/capacity.mjs +53 -0
- xtruct/_cli/template/scripts/merge-pr.sh +97 -0
- xtruct/launcher.py +56 -0
- xtruct-0.1.0.dist-info/METADATA +41 -0
- xtruct-0.1.0.dist-info/RECORD +44 -0
- xtruct-0.1.0.dist-info/WHEEL +4 -0
- xtruct-0.1.0.dist-info/entry_points.txt +2 -0
- xtruct-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,326 @@
|
|
|
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">
|
|
6
|
+
<title>Xtruct Dashboard</title>
|
|
7
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
8
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
9
|
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700;800&display=swap">
|
|
10
|
+
<style>
|
|
11
|
+
:root {
|
|
12
|
+
--canvas: #020617; --mask: #0B1222; --raise: #0F172A; --border: #1E293B; --hair: #172033;
|
|
13
|
+
--ink: #F8FAFC; --muted: #94A3B8; --dim: #64748B;
|
|
14
|
+
--cyan: #22D3EE; --green: #34D399; --violet: #A78BFA; --amber: #FBBF24; --rose: #FB7185;
|
|
15
|
+
--glow: 0 0 0 1px rgba(34, 211, 238, .18), 0 10px 40px -12px rgba(34, 211, 238, .25);
|
|
16
|
+
--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
17
|
+
color-scheme: dark;
|
|
18
|
+
}
|
|
19
|
+
:root[data-theme="light"] {
|
|
20
|
+
--canvas: #F6F8FB; --mask: #FFFFFF; --raise: #FFFFFF; --border: #E2E8F0; --hair: #EEF2F7;
|
|
21
|
+
--ink: #0B1220; --muted: #475569; --dim: #64748B;
|
|
22
|
+
--cyan: #0891B2; --green: #059669; --violet: #7C3AED; --amber: #B45309; --rose: #E11D48;
|
|
23
|
+
--glow: 0 0 0 1px rgba(8, 145, 178, .18), 0 10px 30px -14px rgba(15, 23, 42, .25);
|
|
24
|
+
color-scheme: light;
|
|
25
|
+
}
|
|
26
|
+
* { box-sizing: border-box; }
|
|
27
|
+
[hidden] { display: none !important; }
|
|
28
|
+
html, body { margin: 0; }
|
|
29
|
+
body {
|
|
30
|
+
background: var(--canvas); color: var(--ink); font: 400 13px/1.55 var(--mono);
|
|
31
|
+
background-image: radial-gradient(ellipse 80% 50% at 15% -10%, color-mix(in srgb, var(--cyan) 9%, transparent), transparent 60%),
|
|
32
|
+
radial-gradient(ellipse 60% 40% at 100% 0%, color-mix(in srgb, var(--violet) 8%, transparent), transparent 60%);
|
|
33
|
+
background-attachment: fixed; min-height: 100vh;
|
|
34
|
+
}
|
|
35
|
+
a { color: inherit; text-decoration: none; }
|
|
36
|
+
a:hover { color: var(--cyan); }
|
|
37
|
+
:focus-visible { outline: 2px solid var(--cyan); outline-offset: 2px; border-radius: 4px; }
|
|
38
|
+
.label { font-size: 10px; font-weight: 700; letter-spacing: .14em; text-transform: uppercase; color: var(--dim); }
|
|
39
|
+
|
|
40
|
+
/* ---------- top bar ---------- */
|
|
41
|
+
.top {
|
|
42
|
+
position: sticky; top: 0; z-index: 5; display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
|
|
43
|
+
padding: 14px clamp(16px, 3vw, 32px); border-bottom: 1px solid var(--border);
|
|
44
|
+
background: color-mix(in srgb, var(--canvas) 82%, transparent); backdrop-filter: blur(14px);
|
|
45
|
+
}
|
|
46
|
+
.brand { display: flex; align-items: center; gap: 10px; font-weight: 800; font-size: 15px; letter-spacing: -.02em; }
|
|
47
|
+
.brand .mark { width: 14px; height: 14px; transform: rotate(45deg); border-radius: 3px; background: linear-gradient(135deg, var(--cyan), var(--violet)); box-shadow: 0 0 18px color-mix(in srgb, var(--cyan) 55%, transparent); }
|
|
48
|
+
.brand .x { background: linear-gradient(90deg, var(--cyan), var(--violet)); -webkit-background-clip: text; background-clip: text; color: transparent; }
|
|
49
|
+
.brand .f { color: var(--ink); font-weight: 600; }
|
|
50
|
+
.product { display: flex; align-items: baseline; gap: 10px; padding-left: 16px; border-left: 1px solid var(--border); min-width: 0; }
|
|
51
|
+
.product b { font-weight: 700; }
|
|
52
|
+
.product span { color: var(--dim); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
53
|
+
.spacer { flex: 1; }
|
|
54
|
+
.chip { display: inline-flex; align-items: center; gap: 6px; font-size: 10.5px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; padding: 4px 9px; border-radius: 999px; border: 1px solid var(--border); color: var(--muted); white-space: nowrap; }
|
|
55
|
+
.chip.demo { color: var(--amber); border-color: color-mix(in srgb, var(--amber) 45%, transparent); background: color-mix(in srgb, var(--amber) 10%, transparent); }
|
|
56
|
+
.live { display: inline-flex; align-items: center; gap: 8px; color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
|
|
57
|
+
.pulse { width: 8px; height: 8px; border-radius: 50%; background: var(--green); box-shadow: 0 0 0 0 color-mix(in srgb, var(--green) 60%, transparent); animation: pulse 2s infinite; }
|
|
58
|
+
.pulse.stale { background: var(--amber); animation: none; }
|
|
59
|
+
@keyframes pulse { 70% { box-shadow: 0 0 0 8px transparent; } 100% { box-shadow: 0 0 0 0 transparent; } }
|
|
60
|
+
.toggle { font: inherit; font-size: 12px; color: var(--muted); background: var(--mask); border: 1px solid var(--border); border-radius: 8px; padding: 6px 10px; cursor: pointer; }
|
|
61
|
+
.toggle:hover { color: var(--ink); border-color: var(--cyan); }
|
|
62
|
+
|
|
63
|
+
/* ---------- layout ---------- */
|
|
64
|
+
main { padding: clamp(16px, 3vw, 32px); display: grid; gap: 20px; max-width: 1600px; margin: 0 auto; }
|
|
65
|
+
.kpis { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; }
|
|
66
|
+
@media (max-width: 980px) { .kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
|
67
|
+
.kpi { background: var(--mask); border: 1px solid var(--border); border-radius: 14px; padding: 16px 18px; display: grid; gap: 10px; min-width: 0; }
|
|
68
|
+
.kpi.hot { box-shadow: var(--glow); border-color: color-mix(in srgb, var(--cyan) 40%, var(--border)); }
|
|
69
|
+
.kpi .value { display: flex; align-items: baseline; gap: 8px; font-size: 34px; line-height: 1; font-weight: 800; letter-spacing: -.03em; font-variant-numeric: tabular-nums; }
|
|
70
|
+
.kpi .value small { font-size: 13px; font-weight: 500; color: var(--dim); letter-spacing: 0; }
|
|
71
|
+
.kpi .sub { color: var(--muted); font-size: 12px; }
|
|
72
|
+
.meter { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); gap: 3px; }
|
|
73
|
+
.meter i { height: 14px; border-radius: 2px; background: var(--hair); border: 1px solid var(--border); }
|
|
74
|
+
.meter i.on { background: var(--cyan); border-color: var(--cyan); box-shadow: 0 0 8px color-mix(in srgb, var(--cyan) 50%, transparent); }
|
|
75
|
+
.meter i.over { background: var(--amber); border-color: var(--amber); }
|
|
76
|
+
|
|
77
|
+
.split { display: grid; grid-template-columns: minmax(0, 1fr) 360px; gap: 20px; align-items: start; }
|
|
78
|
+
@media (max-width: 1180px) { .split { grid-template-columns: 1fr; } }
|
|
79
|
+
|
|
80
|
+
/* ---------- board ---------- */
|
|
81
|
+
.board { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
|
|
82
|
+
@media (max-width: 860px) { .board { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
|
83
|
+
@media (max-width: 520px) { .board { grid-template-columns: 1fr; } }
|
|
84
|
+
.col { background: color-mix(in srgb, var(--mask) 70%, transparent); border: 1px solid var(--border); border-radius: 14px; padding: 12px; display: grid; gap: 10px; align-content: start; min-height: 220px; min-width: 0; }
|
|
85
|
+
.col header { display: flex; align-items: center; justify-content: space-between; padding: 2px 2px 6px; border-bottom: 1px solid var(--hair); }
|
|
86
|
+
.col header .label { display: inline-flex; align-items: center; gap: 8px; color: var(--muted); }
|
|
87
|
+
.col header .label::before { content: ""; width: 7px; height: 7px; border-radius: 2px; background: var(--stage); }
|
|
88
|
+
.col header .n { font-weight: 800; font-size: 13px; color: var(--stage); font-variant-numeric: tabular-nums; }
|
|
89
|
+
.col[data-stage="building"] { --stage: var(--cyan); }
|
|
90
|
+
.col[data-stage="review"] { --stage: var(--amber); }
|
|
91
|
+
.col[data-stage="approved"] { --stage: var(--green); }
|
|
92
|
+
.col[data-stage="merged"] { --stage: var(--violet); }
|
|
93
|
+
.card { background: var(--raise); border: 1px solid var(--border); border-radius: 10px; padding: 11px 12px; display: grid; gap: 8px; transition: border-color .16s, transform .16s; min-width: 0; }
|
|
94
|
+
.card:hover { border-color: color-mix(in srgb, var(--stage) 55%, var(--border)); transform: translateY(-1px); }
|
|
95
|
+
.card.fresh { animation: enter .2s ease-out both; }
|
|
96
|
+
@keyframes enter { from { opacity: .3; transform: translateY(4px); } }
|
|
97
|
+
.card .t { font-weight: 600; font-size: 12.5px; line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
|
98
|
+
.card .num { color: var(--stage); font-weight: 800; margin-right: 6px; }
|
|
99
|
+
.card .br { color: var(--dim); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
100
|
+
.meta { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; font-size: 10.5px; }
|
|
101
|
+
.tag { display: inline-flex; align-items: center; gap: 5px; padding: 2px 7px; border-radius: 6px; border: 1px solid var(--border); color: var(--dim); font-weight: 600; }
|
|
102
|
+
.tag.yes { color: var(--green); border-color: color-mix(in srgb, var(--green) 40%, transparent); background: color-mix(in srgb, var(--green) 8%, transparent); }
|
|
103
|
+
.tag.warn { color: var(--amber); border-color: color-mix(in srgb, var(--amber) 40%, transparent); }
|
|
104
|
+
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--dim); }
|
|
105
|
+
.ci-passing .dot { background: var(--green); }
|
|
106
|
+
.ci-failing .dot { background: var(--rose); box-shadow: 0 0 8px var(--rose); }
|
|
107
|
+
.ci-running .dot { background: var(--amber); animation: blink 1.2s infinite; }
|
|
108
|
+
.ci-failing { color: var(--rose); border-color: color-mix(in srgb, var(--rose) 45%, transparent); }
|
|
109
|
+
@keyframes blink { 50% { opacity: .35; } }
|
|
110
|
+
.delta { margin-left: auto; color: var(--dim); font-variant-numeric: tabular-nums; }
|
|
111
|
+
.delta .a { color: var(--green); } .delta .d { color: var(--rose); }
|
|
112
|
+
.empty { color: var(--dim); font-size: 12px; padding: 14px 4px; text-align: center; border: 1px dashed var(--border); border-radius: 10px; }
|
|
113
|
+
.working { display: inline-flex; gap: 3px; }
|
|
114
|
+
.working i { width: 4px; height: 4px; border-radius: 50%; background: var(--cyan); animation: blink 1s infinite; }
|
|
115
|
+
.working i:nth-child(2) { animation-delay: .2s; } .working i:nth-child(3) { animation-delay: .4s; }
|
|
116
|
+
|
|
117
|
+
/* ---------- side panels ---------- */
|
|
118
|
+
aside { display: grid; gap: 16px; }
|
|
119
|
+
.panel { background: var(--mask); border: 1px solid var(--border); border-radius: 14px; padding: 16px; display: grid; gap: 12px; }
|
|
120
|
+
.panel h2 { margin: 0; display: flex; justify-content: space-between; align-items: baseline; }
|
|
121
|
+
.panel h2 .label { color: var(--muted); }
|
|
122
|
+
.panel h2 small { color: var(--dim); font-size: 11px; font-weight: 500; }
|
|
123
|
+
.epic { display: grid; gap: 7px; }
|
|
124
|
+
.epic .row { display: flex; justify-content: space-between; gap: 10px; font-size: 12.5px; }
|
|
125
|
+
.epic .row b { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
126
|
+
.epic .row span { color: var(--dim); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
127
|
+
.bar { height: 6px; border-radius: 999px; background: var(--hair); overflow: hidden; }
|
|
128
|
+
.bar i { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--cyan), var(--violet)); transition: width .4s ease; }
|
|
129
|
+
.bar.done i { background: var(--green); }
|
|
130
|
+
.note { color: var(--dim); font-size: 12px; margin: 0; }
|
|
131
|
+
.slots { display: grid; grid-template-columns: repeat(auto-fill, 14px); gap: 4px; }
|
|
132
|
+
.slots i { width: 14px; height: 14px; border-radius: 3px; border: 1px solid var(--border); background: var(--hair); }
|
|
133
|
+
.slots i.on { background: var(--cyan); border-color: var(--cyan); }
|
|
134
|
+
.facts { display: grid; grid-template-columns: auto 1fr; gap: 4px 14px; font-size: 12px; margin: 0; }
|
|
135
|
+
.facts dt { color: var(--dim); } .facts dd { margin: 0; font-variant-numeric: tabular-nums; }
|
|
136
|
+
.log { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
|
|
137
|
+
.log li { display: grid; grid-template-columns: auto 1fr; gap: 2px 10px; font-size: 12px; }
|
|
138
|
+
.log .sha { color: var(--violet); font-weight: 700; }
|
|
139
|
+
.log .s { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
140
|
+
.log .w { grid-column: 2; color: var(--dim); font-size: 11px; }
|
|
141
|
+
.banner { display: flex; gap: 10px; align-items: center; padding: 10px 14px; border-radius: 10px; border: 1px solid color-mix(in srgb, var(--amber) 40%, var(--border)); color: var(--muted); font-size: 12px; }
|
|
142
|
+
@media (max-width: 560px) {
|
|
143
|
+
.kpis { grid-template-columns: 1fr; }
|
|
144
|
+
.kpi .value { font-size: 28px; }
|
|
145
|
+
.product span, .live #ago { display: none; }
|
|
146
|
+
.product { padding-left: 12px; }
|
|
147
|
+
}
|
|
148
|
+
body { overflow-x: hidden; }
|
|
149
|
+
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } }
|
|
150
|
+
</style>
|
|
151
|
+
</head>
|
|
152
|
+
<body>
|
|
153
|
+
<header class="top">
|
|
154
|
+
<div class="brand"><span class="mark" aria-hidden="true"></span><span class="x">xtruct</span></div>
|
|
155
|
+
<div class="product"><b id="p-name">…</b><span id="p-repo"></span></div>
|
|
156
|
+
<span class="chip demo" id="demo" hidden>Demo data</span>
|
|
157
|
+
<div class="spacer"></div>
|
|
158
|
+
<span class="live"><span class="pulse" id="pulse"></span><span id="ago">connecting</span></span>
|
|
159
|
+
<button class="toggle" id="theme" type="button" aria-label="Switch theme">◐ Theme</button>
|
|
160
|
+
</header>
|
|
161
|
+
|
|
162
|
+
<main>
|
|
163
|
+
<div class="banner" id="err" hidden></div>
|
|
164
|
+
<section class="kpis" aria-label="Summary">
|
|
165
|
+
<div class="kpi hot"><span class="label">Agents working</span><div class="value" id="k-agents">–</div><div class="meter" id="k-meter" aria-hidden="true"></div></div>
|
|
166
|
+
<div class="kpi"><span class="label">Waiting for review</span><div class="value" id="k-review">–</div><div class="sub" id="k-review-sub"></div></div>
|
|
167
|
+
<div class="kpi"><span class="label">Merged · 24h</span><div class="value" id="k-merged">–</div><div class="sub" id="k-merged-sub"></div></div>
|
|
168
|
+
<div class="kpi"><span class="label">CI</span><div class="value" id="k-ci">–</div><div class="sub" id="k-ci-sub"></div></div>
|
|
169
|
+
</section>
|
|
170
|
+
|
|
171
|
+
<div class="split">
|
|
172
|
+
<section class="board" aria-label="Task pipeline">
|
|
173
|
+
<div class="col" data-stage="building"><header><span class="label">Building</span><span class="n" data-n></span></header><div class="list" style="display:grid;gap:10px"></div></div>
|
|
174
|
+
<div class="col" data-stage="review"><header><span class="label">In review</span><span class="n" data-n></span></header><div class="list" style="display:grid;gap:10px"></div></div>
|
|
175
|
+
<div class="col" data-stage="approved"><header><span class="label">Approved</span><span class="n" data-n></span></header><div class="list" style="display:grid;gap:10px"></div></div>
|
|
176
|
+
<div class="col" data-stage="merged"><header><span class="label">Merged</span><span class="n" data-n></span></header><div class="list" style="display:grid;gap:10px"></div></div>
|
|
177
|
+
</section>
|
|
178
|
+
|
|
179
|
+
<aside>
|
|
180
|
+
<section class="panel" aria-labelledby="h-epics">
|
|
181
|
+
<h2 id="h-epics"><span class="label">Epics</span><small id="bugs"></small></h2>
|
|
182
|
+
<div id="epics" style="display:grid;gap:14px"></div>
|
|
183
|
+
</section>
|
|
184
|
+
<section class="panel" aria-labelledby="h-cap">
|
|
185
|
+
<h2 id="h-cap"><span class="label">Capacity</span><small id="cap-limit"></small></h2>
|
|
186
|
+
<div class="slots" id="slots" role="img"></div>
|
|
187
|
+
<dl class="facts" id="cap-facts"></dl>
|
|
188
|
+
</section>
|
|
189
|
+
<section class="panel" aria-labelledby="h-log">
|
|
190
|
+
<h2 id="h-log"><span class="label">Merged to main</span></h2>
|
|
191
|
+
<ul class="log" id="log"></ul>
|
|
192
|
+
</section>
|
|
193
|
+
</aside>
|
|
194
|
+
</div>
|
|
195
|
+
</main>
|
|
196
|
+
|
|
197
|
+
<script>
|
|
198
|
+
(() => {
|
|
199
|
+
const $ = id => document.getElementById(id)
|
|
200
|
+
const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e }
|
|
201
|
+
const seen = new Set()
|
|
202
|
+
let last = 0
|
|
203
|
+
|
|
204
|
+
// Theme: ?theme= wins, then the viewer's last choice, then the system setting; dark is the console default.
|
|
205
|
+
const root = document.documentElement
|
|
206
|
+
let saved = null
|
|
207
|
+
try { saved = localStorage.getItem('xtruct-theme') } catch {}
|
|
208
|
+
const asked = new URLSearchParams(location.search).get('theme')
|
|
209
|
+
root.dataset.theme = ['light', 'dark'].includes(asked) ? asked : saved || (matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark')
|
|
210
|
+
$('theme').onclick = () => {
|
|
211
|
+
root.dataset.theme = root.dataset.theme === 'light' ? 'dark' : 'light'
|
|
212
|
+
try { localStorage.setItem('xtruct-theme', root.dataset.theme) } catch {}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const rel = iso => {
|
|
216
|
+
if (!iso) return ''
|
|
217
|
+
const s = Math.max(0, (Date.now() - new Date(iso)) / 1000)
|
|
218
|
+
return s < 60 ? 'just now' : s < 3600 ? `${Math.floor(s / 60)}m ago` : s < 86400 ? `${Math.floor(s / 3600)}h ago` : `${Math.floor(s / 86400)}d ago`
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function card(p) {
|
|
222
|
+
const c = el(p.url && p.url !== '#' ? 'a' : 'div', 'card')
|
|
223
|
+
if (c.tagName === 'A') { c.href = p.url; c.target = '_blank'; c.rel = 'noopener' }
|
|
224
|
+
const key = (p.number ?? p.branch) + p.stage
|
|
225
|
+
if (!seen.has(key)) { c.classList.add('fresh'); seen.add(key) }
|
|
226
|
+
const t = el('div', 't')
|
|
227
|
+
if (p.number) t.append(el('span', 'num', '#' + p.number))
|
|
228
|
+
t.append(document.createTextNode(p.title))
|
|
229
|
+
c.append(t, el('div', 'br', p.branch || ''))
|
|
230
|
+
const meta = el('div', 'meta')
|
|
231
|
+
if (p.stage === 'building' && p.worktree) {
|
|
232
|
+
const w = el('span', 'tag'); const d = el('span', 'working'); d.append(el('i'), el('i'), el('i')); w.append(d, document.createTextNode('agent at work')); meta.append(w)
|
|
233
|
+
} else {
|
|
234
|
+
meta.append(el('span', 'tag' + (p.reviewed ? ' yes' : ''), (p.reviewed ? '✓ ' : '') + 'review'))
|
|
235
|
+
meta.append(el('span', 'tag' + (p.led ? ' yes' : ''), (p.led ? '✓ ' : '') + 'lead'))
|
|
236
|
+
if (p.large) meta.append(el('span', 'tag warn', 'large'))
|
|
237
|
+
if (p.ci !== 'none') { const ci = el('span', 'tag ci-' + p.ci); ci.append(el('span', 'dot'), document.createTextNode('ci')); meta.append(ci) }
|
|
238
|
+
if (p.additions != null) { const d = el('span', 'delta'); d.append(el('span', 'a', '+' + p.additions), document.createTextNode(' '), el('span', 'd', '−' + p.deletions)); meta.append(d) }
|
|
239
|
+
}
|
|
240
|
+
c.append(meta)
|
|
241
|
+
return c
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function render(s) {
|
|
245
|
+
$('p-name').textContent = s.product.name || 'product'
|
|
246
|
+
$('p-repo').textContent = s.product.repo || 'no GitHub repo yet'
|
|
247
|
+
$('demo').hidden = !s.demo
|
|
248
|
+
document.title = `${s.product.name} · Xtruct`
|
|
249
|
+
$('err').hidden = !s.prsError
|
|
250
|
+
$('err').textContent = s.prsError || ''
|
|
251
|
+
|
|
252
|
+
// KPIs
|
|
253
|
+
const cap = s.capacity, active = s.agentsActive
|
|
254
|
+
$('k-agents').innerHTML = ''
|
|
255
|
+
$('k-agents').append(document.createTextNode(active), el('small', '', `/ ${cap.total_agents} budget`))
|
|
256
|
+
const m = $('k-meter'); m.textContent = ''
|
|
257
|
+
for (let i = 0; i < Math.max(cap.total_agents, active); i++) m.append(el('i', i < active ? (i < cap.total_agents ? 'on' : 'over') : ''))
|
|
258
|
+
const by = st => s.prs.filter(p => p.stage === st)
|
|
259
|
+
const review = by('review'), approved = by('approved')
|
|
260
|
+
$('k-review').textContent = review.length
|
|
261
|
+
$('k-review-sub').textContent = approved.length ? `${approved.length} approved, queued to merge` : 'nothing queued to merge'
|
|
262
|
+
const day = by('merged').filter(p => p.mergedAt && Date.now() - new Date(p.mergedAt) < 864e5)
|
|
263
|
+
$('k-merged').textContent = day.length
|
|
264
|
+
$('k-merged-sub').textContent = `${by('merged').length} merged in total`
|
|
265
|
+
const failing = s.prs.filter(p => p.ci === 'failing' && p.stage !== 'merged' && p.stage !== 'closed')
|
|
266
|
+
const running = s.prs.filter(p => p.ci === 'running' && p.stage !== 'merged')
|
|
267
|
+
$('k-ci').textContent = failing.length ? failing.length + ' failing' : 'green'
|
|
268
|
+
$('k-ci').style.color = failing.length ? 'var(--rose)' : 'var(--green)'
|
|
269
|
+
$('k-ci-sub').textContent = running.length ? `${running.length} running` : 'no checks running'
|
|
270
|
+
|
|
271
|
+
// Board
|
|
272
|
+
for (const col of document.querySelectorAll('.col')) {
|
|
273
|
+
const st = col.dataset.stage, list = col.querySelector('.list')
|
|
274
|
+
let items = by(st)
|
|
275
|
+
if (st === 'merged') items = items.sort((a, b) => new Date(b.mergedAt) - new Date(a.mergedAt))
|
|
276
|
+
col.querySelector('[data-n]').textContent = items.length
|
|
277
|
+
list.textContent = ''
|
|
278
|
+
const shown = st === 'merged' ? items.slice(0, 8) : items
|
|
279
|
+
if (!shown.length) list.append(el('div', 'empty', { building: 'No agents building', review: 'Nothing to review', approved: 'Nothing waiting to merge', merged: 'Nothing merged yet' }[st]))
|
|
280
|
+
shown.forEach(p => list.append(card(p)))
|
|
281
|
+
if (items.length > shown.length) list.append(el('div', 'note', `+ ${items.length - shown.length} earlier`))
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Epics
|
|
285
|
+
const ep = $('epics'); ep.textContent = ''
|
|
286
|
+
$('bugs').textContent = s.bugs ? `${s.bugs} open bug${s.bugs > 1 ? 's' : ''}` : ''
|
|
287
|
+
if (!s.epics.length) ep.append(el('p', 'note', s.epicsNote || 'No epics yet.'))
|
|
288
|
+
for (const e of s.epics) {
|
|
289
|
+
const box = el(e.url && e.url !== '#' ? 'a' : 'div', 'epic')
|
|
290
|
+
if (box.tagName === 'A') { box.href = e.url; box.target = '_blank'; box.rel = 'noopener' }
|
|
291
|
+
const row = el('div', 'row'); row.append(el('b', '', e.name), el('span', '', e.total ? `${e.done}/${e.total}` : (e.status || '')))
|
|
292
|
+
const bar = el('div', 'bar' + (e.total && e.done === e.total ? ' done' : '')); const fill = el('i'); fill.style.width = (e.total ? Math.round(100 * e.done / e.total) : 0) + '%'; bar.append(fill)
|
|
293
|
+
box.append(row, bar); ep.append(box)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Capacity
|
|
297
|
+
$('cap-limit').textContent = 'limited by ' + cap.limited_by.replace('_', ' ')
|
|
298
|
+
const sl = $('slots'); sl.textContent = ''
|
|
299
|
+
for (let i = 0; i < cap.total_agents; i++) sl.append(el('i', i < active ? 'on' : ''))
|
|
300
|
+
sl.setAttribute('aria-label', `${active} of ${cap.total_agents} agent slots in use`)
|
|
301
|
+
const f = $('cap-facts'); f.textContent = ''
|
|
302
|
+
for (const [k, v] of [['machine', `${cap.cpus} cores · ${cap.ram_gb} GB · load ${cap.load_1m}`], ['split', `${cap.epics_at_once} epics × ${cap.agents_per_epic} agents`], ['ceiling', `${cap.per_workflow_ceiling} per workflow`]]) f.append(el('dt', '', k), el('dd', '', v))
|
|
303
|
+
|
|
304
|
+
// Activity
|
|
305
|
+
const log = $('log'); log.textContent = ''
|
|
306
|
+
if (!s.activity.length) log.append(el('li', 'note', 'No commits on main yet.'))
|
|
307
|
+
for (const a of s.activity.slice(0, 8)) { const li = el('li'); li.append(el('span', 'sha', a.sha), el('span', 's', a.subject), el('span', 'w', a.when)); log.append(li) }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function tick() {
|
|
311
|
+
try {
|
|
312
|
+
const res = await fetch('/api/state', { cache: 'no-store' })
|
|
313
|
+
const s = await res.json()
|
|
314
|
+
if (s.error) throw new Error(s.error)
|
|
315
|
+
render(s); last = Date.now(); $('pulse').classList.remove('stale')
|
|
316
|
+
} catch (e) {
|
|
317
|
+
$('pulse').classList.add('stale')
|
|
318
|
+
$('err').hidden = false; $('err').textContent = 'Lost contact with the dashboard server: ' + e.message
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
setInterval(() => { if (last) $('ago').textContent = 'updated ' + rel(new Date(last).toISOString()) }, 1000)
|
|
322
|
+
tick(); setInterval(tick, 5000)
|
|
323
|
+
})()
|
|
324
|
+
</script>
|
|
325
|
+
</body>
|
|
326
|
+
</html>
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// `xtruct dashboard`: a local, read-only view of the company at work.
|
|
3
|
+
// Reads git worktrees (agents building), GitHub PRs (the review pipeline), ClickUp epics
|
|
4
|
+
// (when CLICKUP_TOKEN is set), capacity and recent merges. Listens on 127.0.0.1 only.
|
|
5
|
+
|
|
6
|
+
const http = require('node:http')
|
|
7
|
+
const fs = require('node:fs')
|
|
8
|
+
const path = require('node:path')
|
|
9
|
+
const { execFile, spawn } = require('node:child_process')
|
|
10
|
+
const ui = require('./ui')
|
|
11
|
+
|
|
12
|
+
const PAGE = path.join(__dirname, 'dashboard.html')
|
|
13
|
+
const CACHE_MS = 4000
|
|
14
|
+
const DONE = new Set(['merged', 'done', 'complete', 'closed', 'in review'])
|
|
15
|
+
|
|
16
|
+
const sh = (cmd, args, cwd) => new Promise(resolve =>
|
|
17
|
+
execFile(cmd, args, { cwd, timeout: 20000, maxBuffer: 16 * 1024 * 1024 }, (err, stdout) => resolve(err ? null : stdout)))
|
|
18
|
+
|
|
19
|
+
async function worktrees(root) {
|
|
20
|
+
const out = await sh('git', ['worktree', 'list', '--porcelain'], root)
|
|
21
|
+
if (!out) return []
|
|
22
|
+
return out.trim().split('\n\n').slice(1).map(block => {
|
|
23
|
+
const get = k => (block.match(new RegExp(`^${k} (.*)$`, 'm')) || [])[1]
|
|
24
|
+
return { path: get('worktree'), branch: (get('branch') || '').replace('refs/heads/', '') || null }
|
|
25
|
+
}).filter(w => w.path)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function ciState(checks) {
|
|
29
|
+
if (!checks || !checks.length) return 'none'
|
|
30
|
+
if (checks.some(c => ['FAILURE', 'ERROR', 'CANCELLED', 'TIMED_OUT', 'ACTION_REQUIRED'].includes(c.conclusion || c.state))) return 'failing'
|
|
31
|
+
if (checks.some(c => (c.status && c.status !== 'COMPLETED') || c.state === 'PENDING')) return 'running'
|
|
32
|
+
return 'passing'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function pulls(root) {
|
|
36
|
+
const fields = 'number,title,state,isDraft,labels,headRefName,url,additions,deletions,createdAt,updatedAt,mergedAt,statusCheckRollup'
|
|
37
|
+
const out = await sh('gh', ['pr', 'list', '--state', 'all', '--limit', '100', '--json', fields], root)
|
|
38
|
+
if (out == null) return { error: 'No GitHub PRs yet. Run scripts/bootstrap-github.sh, and check gh auth status.', items: [] }
|
|
39
|
+
const items = JSON.parse(out).map(p => {
|
|
40
|
+
const labels = p.labels.map(l => l.name)
|
|
41
|
+
const reviewed = labels.includes('review:passed'), led = labels.includes('lead:approved')
|
|
42
|
+
const stage = p.state === 'MERGED' ? 'merged'
|
|
43
|
+
: p.state === 'CLOSED' ? 'closed'
|
|
44
|
+
: p.isDraft ? 'building'
|
|
45
|
+
: reviewed && led ? 'approved' : 'review'
|
|
46
|
+
return {
|
|
47
|
+
number: p.number, title: p.title, url: p.url, branch: p.headRefName, stage,
|
|
48
|
+
reviewed, led, large: labels.includes('large-ok'), founder: labels.includes('founder-approved'),
|
|
49
|
+
ci: ciState(p.statusCheckRollup), additions: p.additions, deletions: p.deletions,
|
|
50
|
+
updatedAt: p.updatedAt, mergedAt: p.mergedAt,
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
return { items }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function epics(cfg) {
|
|
57
|
+
const token = process.env.CLICKUP_TOKEN, list = cfg.clickup?.epics_list_id
|
|
58
|
+
if (!list) return { note: 'The ClickUp board appears after plan-product runs.', items: [] }
|
|
59
|
+
if (!token) return { note: 'Set CLICKUP_TOKEN (a ClickUp personal token) to show epic progress here.', items: [] }
|
|
60
|
+
try {
|
|
61
|
+
const tasks = []
|
|
62
|
+
for (let page = 0; page < 5; page++) {
|
|
63
|
+
const res = await fetch(`https://api.clickup.com/api/v2/list/${list}/task?subtasks=true&include_closed=true&page=${page}`,
|
|
64
|
+
{ headers: { Authorization: token }, signal: AbortSignal.timeout(15000) })
|
|
65
|
+
if (!res.ok) return { note: `ClickUp answered ${res.status}. Check CLICKUP_TOKEN.`, items: [] }
|
|
66
|
+
const body = await res.json()
|
|
67
|
+
tasks.push(...body.tasks)
|
|
68
|
+
if (body.last_page !== false) break
|
|
69
|
+
}
|
|
70
|
+
const kids = id => tasks.filter(t => t.parent === id)
|
|
71
|
+
const items = tasks.filter(t => !t.parent).map(t => {
|
|
72
|
+
const sub = kids(t.id)
|
|
73
|
+
return {
|
|
74
|
+
id: t.id, name: t.name, url: t.url, status: t.status?.status, color: t.status?.color,
|
|
75
|
+
total: sub.length, done: sub.filter(s => DONE.has((s.status?.status || '').toLowerCase())).length,
|
|
76
|
+
bug: (t.tags || []).some(tag => tag.name === 'bug'),
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
return { items: items.filter(e => !e.bug), bugs: items.filter(e => e.bug).length }
|
|
80
|
+
} catch (e) {
|
|
81
|
+
return { note: `Could not reach ClickUp: ${e.message}`, items: [] }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function activity(root) {
|
|
86
|
+
const ref = (await sh('git', ['rev-parse', '--verify', '-q', 'origin/main'], root)) ? 'origin/main' : 'HEAD'
|
|
87
|
+
const out = await sh('git', ['log', '-15', '--format=%h%x09%ar%x09%s', ref], root)
|
|
88
|
+
return (out || '').trim().split('\n').filter(Boolean).map(l => {
|
|
89
|
+
const [sha, when, subject] = l.split('\t')
|
|
90
|
+
return { sha, when, subject }
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function collect(root, capacity, version) {
|
|
95
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(root, 'foundry.config.json'), 'utf8'))
|
|
96
|
+
const [trees, prs, board, log] = await Promise.all([worktrees(root), pulls(root), epics(cfg), activity(root)])
|
|
97
|
+
const prBranches = new Set(prs.items.filter(p => p.stage !== 'merged' && p.stage !== 'closed').map(p => p.branch))
|
|
98
|
+
// A worktree on a task branch with no open PR yet is a junior still building.
|
|
99
|
+
const building = trees.filter(w => w.branch && !prBranches.has(w.branch)).map(w => ({
|
|
100
|
+
number: null, title: w.branch.replace(/^t\//, '').replace(/^[A-Za-z0-9]+-/, '').replace(/-/g, ' ') || w.branch,
|
|
101
|
+
branch: w.branch, stage: 'building', ci: 'none', worktree: true,
|
|
102
|
+
}))
|
|
103
|
+
return {
|
|
104
|
+
version, demo: false, generatedAt: new Date().toISOString(),
|
|
105
|
+
product: { name: cfg.product, repo: cfg.github?.repo || null },
|
|
106
|
+
capacity: capacity(), agentsActive: trees.length,
|
|
107
|
+
prs: [...building, ...prs.items], prsError: prs.error || null,
|
|
108
|
+
epics: board.items, epicsNote: board.note || null, bugs: board.bugs || 0,
|
|
109
|
+
activity: log,
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function demoState(version) {
|
|
114
|
+
const ago = m => new Date(Date.now() - m * 60000).toISOString()
|
|
115
|
+
const pr = (number, title, stage, extra = {}) => ({
|
|
116
|
+
number, title, url: '#', branch: `t/CU-${86000 + number}-${title.toLowerCase().replace(/[^a-z]+/g, '-').slice(0, 24)}`,
|
|
117
|
+
stage, reviewed: stage === 'approved' || stage === 'merged', led: stage === 'approved' || stage === 'merged',
|
|
118
|
+
ci: stage === 'merged' || stage === 'approved' ? 'passing' : 'running',
|
|
119
|
+
additions: 40 + number * 7, deletions: number * 2, updatedAt: ago(number), mergedAt: stage === 'merged' ? ago(number * 9) : null, ...extra,
|
|
120
|
+
})
|
|
121
|
+
return {
|
|
122
|
+
version, demo: true, generatedAt: new Date().toISOString(),
|
|
123
|
+
product: { name: 'acme-bookings', repo: 'acme/acme-bookings' },
|
|
124
|
+
capacity: { cpus: 18, ram_gb: 48, load_1m: 2.1, total_agents: 25, limited_by: 'cpu', per_workflow_ceiling: 16, epics_at_once: 3, agents_per_epic: 8 },
|
|
125
|
+
agentsActive: 11,
|
|
126
|
+
prs: [
|
|
127
|
+
{ number: null, title: 'calendar sync worker', branch: 't/CU-86031-calendar-sync-worker', stage: 'building', ci: 'none', worktree: true },
|
|
128
|
+
{ number: null, title: 'refund webhook handler', branch: 't/CU-86034-refund-webhook-handler', stage: 'building', ci: 'none', worktree: true },
|
|
129
|
+
pr(48, 'Booking form validation and error states', 'building', { isDraft: true }),
|
|
130
|
+
pr(47, 'Availability API: weekly slots endpoint', 'review', { reviewed: true }),
|
|
131
|
+
pr(46, 'Stripe checkout session for deposits', 'review', { ci: 'failing' }),
|
|
132
|
+
pr(45, 'Staff schedule grid component', 'review', { led: true }),
|
|
133
|
+
pr(44, 'Email confirmations via Resend', 'approved'),
|
|
134
|
+
pr(43, 'Customer bookings list with filters', 'approved', { large: true }),
|
|
135
|
+
pr(41, 'Seed data for services and staff', 'merged'),
|
|
136
|
+
pr(40, 'Auth: magic link sign-in', 'merged'),
|
|
137
|
+
pr(39, 'DB schema: bookings, services, staff', 'merged'),
|
|
138
|
+
pr(38, 'Playwright harness and smoke test', 'merged'),
|
|
139
|
+
],
|
|
140
|
+
prsError: null,
|
|
141
|
+
epics: [
|
|
142
|
+
{ id: 'e1', name: 'E1 · Foundation', status: 'done', color: '#34D399', total: 9, done: 9, url: '#' },
|
|
143
|
+
{ id: 'e2', name: 'E2 · Booking flow', status: 'in progress', color: '#22D3EE', total: 12, done: 7, url: '#' },
|
|
144
|
+
{ id: 'e3', name: 'E3 · Payments', status: 'in progress', color: '#22D3EE', total: 8, done: 3, url: '#' },
|
|
145
|
+
{ id: 'e4', name: 'E4 · Staff scheduling', status: 'in progress', color: '#22D3EE', total: 10, done: 2, url: '#' },
|
|
146
|
+
{ id: 'e5', name: 'E5 · Hardening', status: 'to do', color: '#94A3B8', total: 0, done: 0, url: '#' },
|
|
147
|
+
],
|
|
148
|
+
epicsNote: null, bugs: 2,
|
|
149
|
+
activity: [
|
|
150
|
+
{ sha: 'a41c9e2', when: '9 minutes ago', subject: 'feat: seed data for services and staff (CU-86041)' },
|
|
151
|
+
{ sha: '7d02b1f', when: '38 minutes ago', subject: 'feat: magic link sign-in (CU-86040)' },
|
|
152
|
+
{ sha: 'c9e7710', when: '1 hour ago', subject: 'feat: bookings, services and staff schema (CU-86039)' },
|
|
153
|
+
{ sha: '19ab3d4', when: '2 hours ago', subject: 'test: Playwright harness and smoke test (CU-86038)' },
|
|
154
|
+
{ sha: '0f5e8c1', when: '3 hours ago', subject: 'chore: Makefile targets and CI (CU-86037)' },
|
|
155
|
+
],
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function openBrowser(url) {
|
|
160
|
+
const [cmd, args] = process.platform === 'darwin' ? ['open', [url]]
|
|
161
|
+
: process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]]
|
|
162
|
+
: ['xdg-open', [url]]
|
|
163
|
+
try { spawn(cmd, args, { stdio: 'ignore', detached: true }).unref() } catch { /* the URL is printed anyway */ }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function start({ root, port, open, capacity, version, demo }) {
|
|
167
|
+
if (!root && !demo) {
|
|
168
|
+
console.error(`\n ${ui.badge(ui.C.rose, 'error')} not inside an xtruct product. Run it in a product, or try ${ui.cmd('xtruct dashboard --demo')}\n`)
|
|
169
|
+
process.exit(1)
|
|
170
|
+
}
|
|
171
|
+
let cached = null, cachedAt = 0, pending = null
|
|
172
|
+
const state = async () => {
|
|
173
|
+
if (demo) return demoState(version)
|
|
174
|
+
if (cached && Date.now() - cachedAt < CACHE_MS) return cached
|
|
175
|
+
pending ??= collect(root, capacity, version).then(s => { cached = s; cachedAt = Date.now(); return s }).finally(() => { pending = null })
|
|
176
|
+
return pending
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const server = http.createServer(async (req, res) => {
|
|
180
|
+
try {
|
|
181
|
+
if (req.url === '/' || req.url.startsWith('/?')) {
|
|
182
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' })
|
|
183
|
+
return res.end(fs.readFileSync(PAGE))
|
|
184
|
+
}
|
|
185
|
+
if (req.url === '/api/state') {
|
|
186
|
+
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' })
|
|
187
|
+
return res.end(JSON.stringify(await state()))
|
|
188
|
+
}
|
|
189
|
+
res.writeHead(404).end()
|
|
190
|
+
} catch (e) {
|
|
191
|
+
res.writeHead(500, { 'content-type': 'application/json' }).end(JSON.stringify({ error: e.message }))
|
|
192
|
+
}
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
let tries = 0
|
|
196
|
+
server.on('error', e => {
|
|
197
|
+
if (e.code === 'EADDRINUSE' && tries++ < 10) return server.listen(++port, '127.0.0.1')
|
|
198
|
+
console.error(`\n ${ui.badge(ui.C.rose, 'error')} ${e.message}\n`)
|
|
199
|
+
process.exit(1)
|
|
200
|
+
})
|
|
201
|
+
server.listen(port, '127.0.0.1', () => {
|
|
202
|
+
const url = `http://127.0.0.1:${server.address().port}`
|
|
203
|
+
console.log(ui.banner(version, demo ? 'Dashboard · demo data' : 'Dashboard'))
|
|
204
|
+
console.log(` ${ui.sym.ok} Live at ${ui.paint(ui.C.cyan, url)} ${ui.dim('Ctrl+C to stop')}`)
|
|
205
|
+
if (!demo && !process.env.CLICKUP_TOKEN) console.log(` ${ui.sym.warn} ${ui.dim('Set CLICKUP_TOKEN to include epic progress from ClickUp')}`)
|
|
206
|
+
console.log()
|
|
207
|
+
if (open) openBrowser(url)
|
|
208
|
+
})
|
|
209
|
+
return server
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
module.exports = { start, collect, demoState, ciState }
|
xtruct/_cli/lib/paths.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// Where Xtruct keeps your config, journal and work items: ~/.config/xtruct.
|
|
3
|
+
// Older installs used ~/.config/xtruct-foundry; it is moved over on first use.
|
|
4
|
+
|
|
5
|
+
const fs = require('node:fs')
|
|
6
|
+
const path = require('node:path')
|
|
7
|
+
const os = require('node:os')
|
|
8
|
+
|
|
9
|
+
const BASE = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config')
|
|
10
|
+
const CONFIG_DIR = path.join(BASE, 'xtruct')
|
|
11
|
+
const LEGACY_DIR = path.join(BASE, 'xtruct-foundry')
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
if (!fs.existsSync(CONFIG_DIR) && fs.existsSync(LEGACY_DIR)) fs.renameSync(LEGACY_DIR, CONFIG_DIR)
|
|
15
|
+
} catch {}
|
|
16
|
+
|
|
17
|
+
module.exports = { CONFIG_DIR }
|