dx-devkit 0.0.0-stage → 1.1.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.
Files changed (172) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/LICENSE +21 -0
  3. package/README.md +475 -2
  4. package/bin/dx-mcp.js +2 -0
  5. package/dist/command.d.ts +9 -0
  6. package/dist/command.d.ts.map +1 -0
  7. package/dist/command.js +2 -0
  8. package/dist/command.js.map +1 -0
  9. package/dist/commands/ai.d.ts +7 -0
  10. package/dist/commands/ai.d.ts.map +1 -0
  11. package/dist/commands/ai.js +148 -0
  12. package/dist/commands/ai.js.map +1 -0
  13. package/dist/commands/clean.d.ts +3 -0
  14. package/dist/commands/clean.d.ts.map +1 -0
  15. package/dist/commands/clean.js +72 -0
  16. package/dist/commands/clean.js.map +1 -0
  17. package/dist/commands/deps.d.ts +3 -0
  18. package/dist/commands/deps.d.ts.map +1 -0
  19. package/dist/commands/deps.js +98 -0
  20. package/dist/commands/deps.js.map +1 -0
  21. package/dist/commands/go.d.ts +3 -0
  22. package/dist/commands/go.d.ts.map +1 -0
  23. package/dist/commands/go.js +202 -0
  24. package/dist/commands/go.js.map +1 -0
  25. package/dist/commands/log.d.ts +3 -0
  26. package/dist/commands/log.d.ts.map +1 -0
  27. package/dist/commands/log.js +51 -0
  28. package/dist/commands/log.js.map +1 -0
  29. package/dist/commands/new.d.ts +3 -0
  30. package/dist/commands/new.d.ts.map +1 -0
  31. package/dist/commands/new.js +117 -0
  32. package/dist/commands/new.js.map +1 -0
  33. package/dist/commands/port.d.ts +4 -0
  34. package/dist/commands/port.d.ts.map +1 -0
  35. package/dist/commands/port.js +106 -0
  36. package/dist/commands/port.js.map +1 -0
  37. package/dist/commands/projects.d.ts +5 -0
  38. package/dist/commands/projects.d.ts.map +1 -0
  39. package/dist/commands/projects.js +46 -0
  40. package/dist/commands/projects.js.map +1 -0
  41. package/dist/commands/stack.d.ts +34 -0
  42. package/dist/commands/stack.d.ts.map +1 -0
  43. package/dist/commands/stack.js +140 -0
  44. package/dist/commands/stack.js.map +1 -0
  45. package/dist/commands/summary.d.ts +3 -0
  46. package/dist/commands/summary.d.ts.map +1 -0
  47. package/dist/commands/summary.js +88 -0
  48. package/dist/commands/summary.js.map +1 -0
  49. package/dist/commands/system.d.ts +5 -0
  50. package/dist/commands/system.d.ts.map +1 -0
  51. package/dist/commands/system.js +104 -0
  52. package/dist/commands/system.js.map +1 -0
  53. package/dist/commands/ui.d.ts +3 -0
  54. package/dist/commands/ui.d.ts.map +1 -0
  55. package/dist/commands/ui.js +96 -0
  56. package/dist/commands/ui.js.map +1 -0
  57. package/dist/index.d.ts +3 -0
  58. package/dist/index.d.ts.map +1 -0
  59. package/dist/index.js +103 -0
  60. package/dist/index.js.map +1 -0
  61. package/dist/ui.d.ts +27 -0
  62. package/dist/ui.d.ts.map +1 -0
  63. package/dist/ui.js +104 -0
  64. package/dist/ui.js.map +1 -0
  65. package/node_modules/@dx/core/dist/config.d.ts +46 -0
  66. package/node_modules/@dx/core/dist/config.d.ts.map +1 -0
  67. package/node_modules/@dx/core/dist/config.js +131 -0
  68. package/node_modules/@dx/core/dist/config.js.map +1 -0
  69. package/node_modules/@dx/core/dist/fuzzy.d.ts +15 -0
  70. package/node_modules/@dx/core/dist/fuzzy.d.ts.map +1 -0
  71. package/node_modules/@dx/core/dist/fuzzy.js +63 -0
  72. package/node_modules/@dx/core/dist/fuzzy.js.map +1 -0
  73. package/node_modules/@dx/core/dist/index.d.ts +17 -0
  74. package/node_modules/@dx/core/dist/index.d.ts.map +1 -0
  75. package/node_modules/@dx/core/dist/index.js +19 -0
  76. package/node_modules/@dx/core/dist/index.js.map +1 -0
  77. package/node_modules/@dx/core/dist/runs.d.ts +19 -0
  78. package/node_modules/@dx/core/dist/runs.d.ts.map +1 -0
  79. package/node_modules/@dx/core/dist/runs.js +41 -0
  80. package/node_modules/@dx/core/dist/runs.js.map +1 -0
  81. package/node_modules/@dx/core/dist/tools/aiSetup.d.ts +58 -0
  82. package/node_modules/@dx/core/dist/tools/aiSetup.d.ts.map +1 -0
  83. package/node_modules/@dx/core/dist/tools/aiSetup.js +243 -0
  84. package/node_modules/@dx/core/dist/tools/aiSetup.js.map +1 -0
  85. package/node_modules/@dx/core/dist/tools/cleanup.d.ts +61 -0
  86. package/node_modules/@dx/core/dist/tools/cleanup.d.ts.map +1 -0
  87. package/node_modules/@dx/core/dist/tools/cleanup.js +171 -0
  88. package/node_modules/@dx/core/dist/tools/cleanup.js.map +1 -0
  89. package/node_modules/@dx/core/dist/tools/deps.d.ts +57 -0
  90. package/node_modules/@dx/core/dist/tools/deps.d.ts.map +1 -0
  91. package/node_modules/@dx/core/dist/tools/deps.js +151 -0
  92. package/node_modules/@dx/core/dist/tools/deps.js.map +1 -0
  93. package/node_modules/@dx/core/dist/tools/devErrors.d.ts +74 -0
  94. package/node_modules/@dx/core/dist/tools/devErrors.d.ts.map +1 -0
  95. package/node_modules/@dx/core/dist/tools/devErrors.js +261 -0
  96. package/node_modules/@dx/core/dist/tools/devErrors.js.map +1 -0
  97. package/node_modules/@dx/core/dist/tools/doctor.d.ts +12 -0
  98. package/node_modules/@dx/core/dist/tools/doctor.d.ts.map +1 -0
  99. package/node_modules/@dx/core/dist/tools/doctor.js +176 -0
  100. package/node_modules/@dx/core/dist/tools/doctor.js.map +1 -0
  101. package/node_modules/@dx/core/dist/tools/github.d.ts +31 -0
  102. package/node_modules/@dx/core/dist/tools/github.d.ts.map +1 -0
  103. package/node_modules/@dx/core/dist/tools/github.js +80 -0
  104. package/node_modules/@dx/core/dist/tools/github.js.map +1 -0
  105. package/node_modules/@dx/core/dist/tools/journal.d.ts +23 -0
  106. package/node_modules/@dx/core/dist/tools/journal.d.ts.map +1 -0
  107. package/node_modules/@dx/core/dist/tools/journal.js +80 -0
  108. package/node_modules/@dx/core/dist/tools/journal.js.map +1 -0
  109. package/node_modules/@dx/core/dist/tools/launch.d.ts +24 -0
  110. package/node_modules/@dx/core/dist/tools/launch.d.ts.map +1 -0
  111. package/node_modules/@dx/core/dist/tools/launch.js +137 -0
  112. package/node_modules/@dx/core/dist/tools/launch.js.map +1 -0
  113. package/node_modules/@dx/core/dist/tools/ports.d.ts +42 -0
  114. package/node_modules/@dx/core/dist/tools/ports.d.ts.map +1 -0
  115. package/node_modules/@dx/core/dist/tools/ports.js +220 -0
  116. package/node_modules/@dx/core/dist/tools/ports.js.map +1 -0
  117. package/node_modules/@dx/core/dist/tools/projects.d.ts +58 -0
  118. package/node_modules/@dx/core/dist/tools/projects.d.ts.map +1 -0
  119. package/node_modules/@dx/core/dist/tools/projects.js +208 -0
  120. package/node_modules/@dx/core/dist/tools/projects.js.map +1 -0
  121. package/node_modules/@dx/core/dist/tools/scaffold.d.ts +40 -0
  122. package/node_modules/@dx/core/dist/tools/scaffold.d.ts.map +1 -0
  123. package/node_modules/@dx/core/dist/tools/scaffold.js +188 -0
  124. package/node_modules/@dx/core/dist/tools/scaffold.js.map +1 -0
  125. package/node_modules/@dx/core/dist/tools/stacks.d.ts +119 -0
  126. package/node_modules/@dx/core/dist/tools/stacks.d.ts.map +1 -0
  127. package/node_modules/@dx/core/dist/tools/stacks.js +386 -0
  128. package/node_modules/@dx/core/dist/tools/stacks.js.map +1 -0
  129. package/node_modules/@dx/core/dist/tools/summary.d.ts +87 -0
  130. package/node_modules/@dx/core/dist/tools/summary.d.ts.map +1 -0
  131. package/node_modules/@dx/core/dist/tools/summary.js +331 -0
  132. package/node_modules/@dx/core/dist/tools/summary.js.map +1 -0
  133. package/node_modules/@dx/core/package.json +20 -0
  134. package/node_modules/@dx/mcp/dist/index.d.ts +3 -0
  135. package/node_modules/@dx/mcp/dist/index.d.ts.map +1 -0
  136. package/node_modules/@dx/mcp/dist/index.js +540 -0
  137. package/node_modules/@dx/mcp/dist/index.js.map +1 -0
  138. package/node_modules/@dx/mcp/package.json +8 -0
  139. package/node_modules/@dx/web/dist/api.d.ts +13 -0
  140. package/node_modules/@dx/web/dist/api.d.ts.map +1 -0
  141. package/node_modules/@dx/web/dist/api.js +351 -0
  142. package/node_modules/@dx/web/dist/api.js.map +1 -0
  143. package/node_modules/@dx/web/dist/background.d.ts +2 -0
  144. package/node_modules/@dx/web/dist/background.d.ts.map +1 -0
  145. package/node_modules/@dx/web/dist/background.js +24 -0
  146. package/node_modules/@dx/web/dist/background.js.map +1 -0
  147. package/node_modules/@dx/web/dist/check.d.ts +39 -0
  148. package/node_modules/@dx/web/dist/check.d.ts.map +1 -0
  149. package/node_modules/@dx/web/dist/check.js +97 -0
  150. package/node_modules/@dx/web/dist/check.js.map +1 -0
  151. package/node_modules/@dx/web/dist/dev.d.ts +109 -0
  152. package/node_modules/@dx/web/dist/dev.d.ts.map +1 -0
  153. package/node_modules/@dx/web/dist/dev.js +645 -0
  154. package/node_modules/@dx/web/dist/dev.js.map +1 -0
  155. package/node_modules/@dx/web/dist/link.d.ts +45 -0
  156. package/node_modules/@dx/web/dist/link.d.ts.map +1 -0
  157. package/node_modules/@dx/web/dist/link.js +150 -0
  158. package/node_modules/@dx/web/dist/link.js.map +1 -0
  159. package/node_modules/@dx/web/dist/proxy.d.ts +21 -0
  160. package/node_modules/@dx/web/dist/proxy.d.ts.map +1 -0
  161. package/node_modules/@dx/web/dist/proxy.js +140 -0
  162. package/node_modules/@dx/web/dist/proxy.js.map +1 -0
  163. package/node_modules/@dx/web/dist/server.d.ts +33 -0
  164. package/node_modules/@dx/web/dist/server.d.ts.map +1 -0
  165. package/node_modules/@dx/web/dist/server.js +184 -0
  166. package/node_modules/@dx/web/dist/server.js.map +1 -0
  167. package/node_modules/@dx/web/package.json +26 -0
  168. package/node_modules/@dx/web/public/app.js +1553 -0
  169. package/node_modules/@dx/web/public/icon.svg +1 -0
  170. package/node_modules/@dx/web/public/index.html +47 -0
  171. package/node_modules/@dx/web/public/styles.css +1124 -0
  172. package/package.json +52 -4
@@ -0,0 +1,1553 @@
1
+ // dx dashboard. Plain JavaScript, no framework. Everything from the projects (names, commit
2
+ // messages, dev server output) is put on the page as text, never as HTML.
3
+
4
+ const TOKEN = document.querySelector('meta[name="dx-token"]')?.content ?? "";
5
+ const main = document.getElementById("main");
6
+ const nav = document.getElementById("nav");
7
+
8
+ // ------------------------------------------------------------------ helpers
9
+
10
+ async function api(path, { method = "GET", body } = {}) {
11
+ let res;
12
+ try {
13
+ res = await fetch(path, {
14
+ method,
15
+ headers: { "X-DX-Token": TOKEN, ...(body ? { "Content-Type": "application/json" } : {}) },
16
+ body: body ? JSON.stringify(body) : undefined,
17
+ });
18
+ } catch {
19
+ throw new Error("Can't reach dx. Is `dx ui` still running in your terminal?");
20
+ }
21
+ const data = await res.json().catch(() => null);
22
+ if (!res.ok) throw new Error(data?.error ?? `Request failed (${res.status})`);
23
+ return data;
24
+ }
25
+
26
+ /** h("button", { class: "btn", onclick }, "Text", child) */
27
+ function h(tag, props, ...children) {
28
+ const el = document.createElement(tag);
29
+ for (const [key, value] of Object.entries(props ?? {})) {
30
+ if (value === undefined || value === null || value === false) continue;
31
+ if (key.startsWith("on") && typeof value === "function") el.addEventListener(key.slice(2), value);
32
+ else if (key === "class") el.className = value;
33
+ else if (key === "dataset") Object.assign(el.dataset, value);
34
+ else if (key === "style") Object.assign(el.style, value);
35
+ else if (key in el && typeof value !== "string") el[key] = value;
36
+ else el.setAttribute(key, value === true ? "" : String(value));
37
+ }
38
+ append(el, ...children);
39
+ return el;
40
+ }
41
+
42
+ function append(el, ...children) {
43
+ for (const child of children.flat(Infinity)) {
44
+ if (child === undefined || child === null || child === false) continue;
45
+ el.append(child instanceof Node ? child : document.createTextNode(String(child)));
46
+ }
47
+ return el;
48
+ }
49
+
50
+ const ICONS = {
51
+ projects: "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z",
52
+ new: "M12 5v14M5 12h14",
53
+ ports: "M7 3v5M17 3v5M5 8h14v3a7 7 0 0 1-14 0zM12 18v3",
54
+ journal: "M5 4h11a3 3 0 0 1 3 3v13H8a3 3 0 0 1-3-3zM5 17a3 3 0 0 1 3-3h11M9 8h6",
55
+ summary: "M4 19V9M10 19V5M16 19v-7M22 19H2",
56
+ cleanup: "M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v6M14 11v6",
57
+ deps: "M12 3l8 3v6c0 4.5-3.4 8.3-8 9-4.6-.7-8-4.5-8-9V6zM9 12l2 2 4-4",
58
+ health: "M3 12h4l3-8 4 16 3-8h4",
59
+ play: "M7 4l13 8-13 8z",
60
+ stop: "M6 6h12v12H6z",
61
+ external: "M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5",
62
+ code: "M9 8l-4 4 4 4M15 8l4 4-4 4",
63
+ terminal: "M4 6l6 6-6 6M12 18h8",
64
+ folder: "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z",
65
+ refresh: "M20 11a8 8 0 0 0-14.9-4M4 4v4h4M4 13a8 8 0 0 0 14.9 4M20 20v-4h-4",
66
+ copy: "M9 9h11v11H9zM5 15H4V4h11v1",
67
+ save: "M5 4h11l3 3v13H5zM8 4v5h7M8 20v-6h8v6",
68
+ more: "M5 12h.01M12 12h.01M19 12h.01",
69
+ log: "M4 6h16M4 12h16M4 18h10",
70
+ template: "M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM16 14v6M13 17h6",
71
+ info: "M12 8h.01M11 12h1v5h1M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18z",
72
+ warn: "M12 9v4M12 17h.01M10.3 4l-8 14a2 2 0 0 0 1.7 3h16a2 2 0 0 0 1.7-3l-8-14a2 2 0 0 0-3.4 0z",
73
+ x: "M6 6l12 12M18 6L6 18",
74
+ };
75
+
76
+ function icon(name) {
77
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
78
+ svg.setAttribute("viewBox", "0 0 24 24");
79
+ svg.setAttribute("class", "icon");
80
+ svg.setAttribute("aria-hidden", "true");
81
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
82
+ path.setAttribute("d", ICONS[name] ?? ICONS.info);
83
+ if (name === "more") path.setAttribute("stroke-width", "3.5"); // dots need a thicker pen to be seen
84
+ svg.append(path);
85
+ return svg;
86
+ }
87
+
88
+ function btn(label, onclick, { kind = "", iconName, small = false, disabled = false, title, type = "button", name } = {}) {
89
+ return h(
90
+ "button",
91
+ { type, class: `btn ${kind ? `btn-${kind}` : ""} ${small ? "btn-sm" : ""}`.trim(), onclick, disabled, title, "aria-label": name },
92
+ iconName ? icon(iconName) : null,
93
+ label,
94
+ );
95
+ }
96
+
97
+ /** Runs an action from a button: disables it, shows "…" text while busy, reports errors. */
98
+ async function busy(button, busyLabel, fn) {
99
+ const original = [...button.childNodes];
100
+ button.disabled = true;
101
+ button.replaceChildren(h("span", { class: "spinner", "aria-hidden": "true" }), busyLabel);
102
+ try {
103
+ return await fn();
104
+ } catch (err) {
105
+ toast(err.message, "error");
106
+ return undefined;
107
+ } finally {
108
+ if (button.isConnected) {
109
+ button.disabled = false;
110
+ button.replaceChildren(...original);
111
+ }
112
+ }
113
+ }
114
+
115
+ function toast(message, kind = "ok") {
116
+ const el = h("div", { class: `toast ${kind === "error" ? "toast-error" : ""}` }, h("div", null, message));
117
+ document.getElementById("toasts").append(el);
118
+ setTimeout(() => el.remove(), kind === "error" ? 9000 : 4500);
119
+ }
120
+
121
+ function confirmDialog({ title, text, confirm = "Confirm", danger = true }) {
122
+ const dialog = document.getElementById("confirm");
123
+ document.getElementById("confirm-title").textContent = title;
124
+ document.getElementById("confirm-text").replaceChildren(...(Array.isArray(text) ? text : [text]).map((t) => (t instanceof Node ? t : h("p", null, t))));
125
+ const ok = document.getElementById("confirm-ok");
126
+ ok.textContent = confirm;
127
+ ok.className = `btn ${danger ? "btn-danger" : "btn-primary"}`;
128
+ dialog.returnValue = "";
129
+ dialog.showModal();
130
+ return new Promise((resolve) => dialog.addEventListener("close", () => resolve(dialog.returnValue === "ok"), { once: true }));
131
+ }
132
+
133
+ function stateView(title, text, ...actions) {
134
+ return h("div", { class: "state" }, h("p", { class: "state-title" }, title), text ? h("p", null, text) : null, actions.length ? h("div", { class: "toolbar" }, actions) : null);
135
+ }
136
+
137
+ function loadingView(text) {
138
+ return h("div", { class: "state", role: "status" }, h("span", { class: "spinner", "aria-hidden": "true" }), h("p", null, text));
139
+ }
140
+
141
+ function errorView(err, retry) {
142
+ return h(
143
+ "div",
144
+ { class: "note note-danger", role: "alert" },
145
+ icon("warn"),
146
+ h("div", null, h("p", null, err.message), retry ? h("p", null, btn("Try again", retry, { small: true, iconName: "refresh" })) : null),
147
+ );
148
+ }
149
+
150
+ function pageHead(title, sub, ...actions) {
151
+ return h(
152
+ "div",
153
+ { class: "page-head" },
154
+ h("div", null, h("h1", { class: "page-title" }, title), sub ? h("p", { class: "page-sub" }, sub) : null),
155
+ actions.length ? h("div", { class: "toolbar" }, actions) : null,
156
+ );
157
+ }
158
+
159
+ function segmented(label, options, value, onchange) {
160
+ const group = h("div", { class: "segmented", role: "group", "aria-label": label });
161
+ for (const [v, text] of options) {
162
+ group.append(
163
+ h(
164
+ "button",
165
+ {
166
+ type: "button",
167
+ "aria-pressed": String(v === value),
168
+ onclick: () => {
169
+ for (const b of group.children) b.setAttribute("aria-pressed", "false");
170
+ group.querySelector(`[data-v="${v}"]`)?.setAttribute("aria-pressed", "true");
171
+ onchange(v);
172
+ },
173
+ dataset: { v: String(v) },
174
+ },
175
+ text,
176
+ ),
177
+ );
178
+ }
179
+ return group;
180
+ }
181
+
182
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
183
+ const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
184
+
185
+ function dayLabel(date) {
186
+ const [y, m, d] = date.split("-").map(Number);
187
+ const at = new Date(y, m - 1, d);
188
+ const today = new Date();
189
+ const yesterday = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1);
190
+ if (at.toDateString() === today.toDateString()) return "Today";
191
+ if (at.toDateString() === yesterday.toDateString()) return "Yesterday";
192
+ return `${WEEKDAYS[at.getDay()]} ${d} ${MONTHS[m - 1]}`;
193
+ }
194
+
195
+ function relativeTime(ms) {
196
+ if (!ms) return "—";
197
+ const s = Math.round((Date.now() - ms) / 1000);
198
+ if (s < 60) return "just now";
199
+ const m = Math.round(s / 60);
200
+ if (m < 60) return `${m}m ago`;
201
+ const hr = Math.round(m / 60);
202
+ if (hr < 24) return `${hr}h ago`;
203
+ const d = Math.round(hr / 24);
204
+ if (d < 30) return `${d}d ago`;
205
+ const mo = Math.round(d / 30);
206
+ return mo < 12 ? `${mo}mo ago` : `${Math.round(mo / 12)}y ago`;
207
+ }
208
+
209
+ function formatBytes(bytes) {
210
+ const units = ["B", "KB", "MB", "GB", "TB"];
211
+ let n = bytes;
212
+ let i = 0;
213
+ while (n >= 1024 && i < units.length - 1) {
214
+ n /= 1024;
215
+ i++;
216
+ }
217
+ return `${i === 0 ? n : n.toFixed(n >= 100 ? 0 : 1)} ${units[i]}`;
218
+ }
219
+
220
+ function plural(n, one, many = `${one}s`) {
221
+ return `${n} ${n === 1 ? one : many}`;
222
+ }
223
+
224
+ const ROLE_LABELS = { data: "database", backend: "back end", frontend: "front end" };
225
+
226
+ const STACK_SOURCES = {
227
+ devrc: ".devrc.json",
228
+ procfile: "the Procfile",
229
+ runner: "the monorepo's dev script",
230
+ scripts: "the dev:* scripts",
231
+ workspaces: "the workspaces",
232
+ compose: "docker-compose",
233
+ };
234
+
235
+ const KIND_LABELS = { typescript: "TypeScript", build: "Build", runtime: "Runtime", python: "Python", go: "Go", exit: "Crashed", error: "Error" };
236
+
237
+ /** A file path as seen from its project: "C:/work/shop/src/App.tsx" → "src/App.tsx". */
238
+ function inProject(file, dir) {
239
+ const f = file.replace(/\\/g, "/").replace(/^\.\//, "");
240
+ const d = dir.replace(/\\/g, "/").replace(/\/$/, "");
241
+ return f.toLowerCase().startsWith(`${d.toLowerCase()}/`) ? f.slice(d.length + 1) : tildify(file);
242
+ }
243
+
244
+ function tildify(p) {
245
+ return p.replace(/^[A-Za-z]:\\Users\\[^\\]+/, "~").replace(/^\/(Users|home)\/[^/]+/, "~");
246
+ }
247
+
248
+ function pref(key, fallback) {
249
+ try {
250
+ return localStorage.getItem(`dx.${key}`) ?? fallback;
251
+ } catch {
252
+ return fallback;
253
+ }
254
+ }
255
+
256
+ function setPref(key, value) {
257
+ try {
258
+ localStorage.setItem(`dx.${key}`, String(value));
259
+ } catch {
260
+ // private window: preferences just aren't remembered
261
+ }
262
+ }
263
+
264
+ // ------------------------------------------------------------------ router
265
+
266
+ const pages = [
267
+ { id: "projects", label: "Projects", icon: "projects", render: projectsPage },
268
+ { id: "new", label: "New project", icon: "new", render: newProjectPage },
269
+ { id: "ports", label: "Ports", icon: "ports", render: portsPage },
270
+ { id: "journal", label: "Journal", icon: "journal", render: journalPage },
271
+ { id: "summary", label: "Summary", icon: "summary", render: summaryPage },
272
+ { id: "cleanup", label: "Cleanup", icon: "cleanup", render: cleanupPage },
273
+ { id: "deps", label: "Dependencies", icon: "deps", render: depsPage },
274
+ { id: "health", label: "Health & settings", icon: "health", render: healthPage },
275
+ ];
276
+
277
+ let leaveCurrent = () => {};
278
+
279
+ function renderNav(active) {
280
+ nav.replaceChildren(
281
+ ...pages.map((p) =>
282
+ h(
283
+ "a",
284
+ { href: `#/${p.id}`, "aria-current": p.id === active ? "page" : undefined, dataset: { page: p.id } },
285
+ icon(p.icon),
286
+ h("span", { class: "nav-label" }, p.label),
287
+ p.id === "projects" ? h("span", { class: "count", id: "running-count", hidden: true }) : null,
288
+ ),
289
+ ),
290
+ );
291
+ }
292
+
293
+ function route() {
294
+ const id = location.hash.replace(/^#\/?/, "").split("?")[0] || "projects";
295
+ const page = pages.find((p) => p.id === id) ?? pages[0];
296
+ leaveCurrent();
297
+ leaveCurrent = () => {};
298
+ renderNav(page.id);
299
+ document.title = `${page.label} · dx`;
300
+ const container = h("div", { class: "page" });
301
+ main.replaceChildren(container);
302
+ const cleanup = page.render(container);
303
+ if (typeof cleanup === "function") leaveCurrent = cleanup;
304
+ main.focus({ preventScroll: true });
305
+ window.scrollTo(0, 0);
306
+ void updateRunningCount();
307
+ }
308
+
309
+ async function updateRunningCount() {
310
+ try {
311
+ const servers = await api("/api/dev");
312
+ const running = servers.filter((s) => s.status === "starting" || s.status === "ready").length;
313
+ const el = document.getElementById("running-count");
314
+ if (el) {
315
+ el.hidden = running === 0;
316
+ el.textContent = String(running);
317
+ el.title = `${plural(running, "dev server")} running`;
318
+ }
319
+ } catch {
320
+ // shown on the page itself
321
+ }
322
+ }
323
+
324
+ // ------------------------------------------------------------------ Projects
325
+
326
+ function projectsPage(root) {
327
+ let projects = [];
328
+ let query = "";
329
+ const openLogs = new Set(JSON.parse(pref("openLogs", "[]")));
330
+ const openErrors = new Set(JSON.parse(pref("openErrors", "[]")));
331
+ const logState = new Map(); // dir -> { after, el }
332
+ const errorPanels = new Map(); // dir -> { el, dir, sig }
333
+ let stopped = false;
334
+
335
+ const search = h("input", {
336
+ class: "input",
337
+ type: "search",
338
+ placeholder: "Search projects",
339
+ "aria-label": "Search projects",
340
+ oninput: (e) => {
341
+ query = e.target.value.trim().toLowerCase();
342
+ renderList();
343
+ },
344
+ });
345
+ const devSection = h("section", { class: "section", "aria-labelledby": "dev-title", hidden: true });
346
+ const listSection = h("section", { class: "section", "aria-label": "All projects" });
347
+ const listHolder = h("div", { class: "card" }, loadingView("Finding your projects…"));
348
+ append(listSection, listHolder);
349
+
350
+ append(
351
+ root,
352
+ pageHead(
353
+ "Projects",
354
+ "Open a project, run its dev server here, or open it in the browser.",
355
+ search,
356
+ h("a", { class: "btn", href: "#/new" }, icon("new"), "New project"),
357
+ btn("Refresh", () => load(), { iconName: "refresh", kind: "ghost" }),
358
+ ),
359
+ devSection,
360
+ listSection,
361
+ );
362
+
363
+ async function load() {
364
+ try {
365
+ projects = await api("/api/projects");
366
+ renderList();
367
+ renderDev();
368
+ } catch (err) {
369
+ listHolder.replaceChildren(errorView(err, load));
370
+ }
371
+ }
372
+
373
+ function renderList() {
374
+ const list = query ? projects.filter((p) => p.name.toLowerCase().includes(query) || p.dir.toLowerCase().includes(query)) : projects;
375
+ if (projects.length === 0) {
376
+ listHolder.replaceChildren(
377
+ stateView("No projects found", "dx looks for folders with a package.json, go.mod, .git and similar in your projects folders.", h("a", { class: "btn btn-primary", href: "#/health" }, "Add a projects folder")),
378
+ );
379
+ return;
380
+ }
381
+ if (list.length === 0) {
382
+ listHolder.replaceChildren(stateView(`No project matches "${query}"`, null, btn("Clear search", () => ((search.value = ""), (query = ""), renderList()))));
383
+ return;
384
+ }
385
+ listHolder.replaceChildren(h("div", { class: "project-list" }, list.map(projectRow)));
386
+ }
387
+
388
+ function projectRow(p) {
389
+ const running = p.dev && (p.dev.status === "starting" || p.dev.status === "ready");
390
+ const kind = p.packageManager ? `${p.kind} · ${p.packageManager}` : p.kind;
391
+ return h(
392
+ "div",
393
+ { class: "project" },
394
+ h(
395
+ "div",
396
+ null,
397
+ h(
398
+ "div",
399
+ { class: "project-name" },
400
+ p.name,
401
+ h("span", { class: "badge" }, kind),
402
+ p.stack ? h("span", { class: "badge badge-accent", title: `Found from ${STACK_SOURCES[p.stack.source] ?? "the project"}` }, `Stack: ${p.stack.services.map((s) => s.name).join(", ")}`) : null,
403
+ running ? statusBadge(p.dev.status) : null,
404
+ ),
405
+ h(
406
+ "div",
407
+ { class: "project-meta" },
408
+ h("span", null, `Active ${relativeTime(p.lastActive)}`),
409
+ p.stack ? h("span", null, `${plural(p.stack.services.length, "service")}, started in order`) : p.devCommand ? h("span", { class: "mono" }, p.devCommand) : h("span", null, "No dev command"),
410
+ h("span", { class: "mono" }, tildify(p.dir)),
411
+ ),
412
+ ),
413
+ h(
414
+ "div",
415
+ { class: "project-actions" },
416
+ running
417
+ ? btn("Stop", (e) => stopDev(e.currentTarget, p.dir), { iconName: "stop", small: true, name: `Stop ${p.name}` })
418
+ : p.devCommand || p.stack
419
+ ? btn("Run", (e) => startDev(e.currentTarget, p.dir), {
420
+ kind: "primary",
421
+ iconName: "play",
422
+ small: true,
423
+ title: p.stack ? `Run its ${p.stack.services.length} services here` : `Run ${p.devCommand} here`,
424
+ name: `Run ${p.name}`,
425
+ })
426
+ : null,
427
+ btn("Open in editor", (e) => open(e.currentTarget, p, "editor"), { iconName: "code", small: true, name: `Open ${p.name} in editor` }),
428
+ moreMenu(p),
429
+ ),
430
+ );
431
+ }
432
+
433
+ function moreMenu(p) {
434
+ const details = h("details", { class: "menu" });
435
+ const close = () => details.removeAttribute("open");
436
+ const item = (label, iconName, fn, disabled = false) =>
437
+ h("button", { type: "button", disabled, onclick: async () => (close(), fn()) }, icon(iconName), label);
438
+ append(
439
+ details,
440
+ h("summary", { class: "btn btn-sm", "aria-label": `More actions for ${p.name}` }, icon("more"), "More"),
441
+ h(
442
+ "div",
443
+ { class: "menu-list" },
444
+ item("Open in browser", "external", () => open(null, p, "browser", p.dev?.localUrl ?? p.dev?.url ?? p.url), !(p.dev?.localUrl ?? p.dev?.url ?? p.url)),
445
+ item("Run in a terminal window", "terminal", () => open(null, p, "terminal"), !p.devCommand),
446
+ item("Show in folder", "folder", () => open(null, p, "folder")),
447
+ item("Save as template…", "template", () => saveTemplate(p)),
448
+ ),
449
+ );
450
+ return details;
451
+ }
452
+
453
+ async function open(button, p, action, url) {
454
+ const run = async () => toast((await api("/api/projects/open", { method: "POST", body: { dir: p.dir, action, url } })).message);
455
+ if (button) await busy(button, "Opening…", run);
456
+ else await run().catch((err) => toast(err.message, "error"));
457
+ }
458
+
459
+ async function saveTemplate(p) {
460
+ const name = prompt(`Template name for ${p.name} (lowercase, e.g. vite-app):`, p.name.toLowerCase().replace(/[^a-z0-9._-]+/g, "-"));
461
+ if (!name) return;
462
+ try {
463
+ const r = await api("/api/templates/save", { method: "POST", body: { dir: p.dir, name } });
464
+ toast(`Saved template ${r.name} (${plural(r.files, "file")}). Use it on the New project page.`);
465
+ } catch (err) {
466
+ toast(err.message, "error");
467
+ }
468
+ }
469
+
470
+ async function startDev(button, dir) {
471
+ await busy(button, "Starting…", async () => {
472
+ await api("/api/dev/start", { method: "POST", body: { dir } });
473
+ openLogs.add(dir.toLowerCase());
474
+ setPref("openLogs", JSON.stringify([...openLogs]));
475
+ await load();
476
+ void updateRunningCount();
477
+ });
478
+ }
479
+
480
+ async function stopDev(button, dir) {
481
+ await busy(button, "Stopping…", async () => {
482
+ await api("/api/dev/stop", { method: "POST", body: { dir } });
483
+ await new Promise((r) => setTimeout(r, 600));
484
+ await load();
485
+ void updateRunningCount();
486
+ });
487
+ }
488
+
489
+ function statusBadge(status) {
490
+ const map = {
491
+ starting: ["badge-warn", "Starting", true],
492
+ ready: ["badge-ok", "Running", false],
493
+ stopped: ["", "Stopped", false],
494
+ failed: ["badge-danger", "Failed", false],
495
+ };
496
+ const [cls, text, pulse] = map[status] ?? ["", status, false];
497
+ return h("span", { class: `badge ${cls}` }, h("span", { class: `dot ${pulse ? "dot-pulse" : ""}` }), text);
498
+ }
499
+
500
+ function renderDev() {
501
+ const servers = projects.filter((p) => p.dev).map((p) => p.dev);
502
+ devSection.hidden = servers.length === 0;
503
+ if (!servers.length) return;
504
+ devSection.replaceChildren(
505
+ h("h2", { class: "section-title", id: "dev-title" }, "Dev servers"),
506
+ h("div", { class: "dev-list" }, servers.map(devCard)),
507
+ );
508
+ }
509
+
510
+ function devCard(s) {
511
+ const key = s.dir.toLowerCase();
512
+ const live = s.status === "starting" || s.status === "ready";
513
+ const logEl = h("pre", { class: "log", tabindex: "0", "aria-label": `Output of ${s.project}` });
514
+ const showing = openLogs.has(key);
515
+ const showingErrors = openErrors.has(key);
516
+ const errorsEl = h("div", { class: "dev-errors", "aria-live": "polite", "aria-label": `Errors in ${s.project}` });
517
+ const toggle = (set, prefKey) => {
518
+ if (set.has(key)) set.delete(key);
519
+ else set.add(key);
520
+ setPref(prefKey, JSON.stringify([...set]));
521
+ renderDev();
522
+ };
523
+ const card = h(
524
+ "article",
525
+ { class: "card dev" },
526
+ h(
527
+ "div",
528
+ { class: "dev-head" },
529
+ h(
530
+ "div",
531
+ { class: "dev-title" },
532
+ h("strong", null, s.project),
533
+ statusBadge(s.status),
534
+ s.startedBy === "ai" ? h("span", { class: "badge badge-accent", title: "Started by an AI agent through dx's MCP tools" }, "Started by AI") : null,
535
+ s.errorCount ? h("span", { class: "badge badge-danger" }, plural(s.errorCount, "error")) : null,
536
+ addressLink(s) ?? h("span", { class: "dim mono" }, s.command),
537
+ ),
538
+ h(
539
+ "div",
540
+ { class: "toolbar" },
541
+ (s.localUrl ?? s.url) && s.status === "ready"
542
+ ? btn("Open in browser", (e) => open(e.currentTarget, { dir: s.dir }, "browser", s.localUrl ?? s.url), { iconName: "external", small: true, kind: "primary" })
543
+ : null,
544
+ s.errorCount || showingErrors
545
+ ? btn(showingErrors ? "Hide errors" : "Show errors", () => toggle(openErrors, "openErrors"), { iconName: "warn", small: true })
546
+ : null,
547
+ btn(showing ? "Hide output" : "Show output", () => toggle(openLogs, "openLogs"), { iconName: "log", small: true }),
548
+ live
549
+ ? btn("Stop", (e) => stopDev(e.currentTarget, s.dir), { iconName: "stop", small: true })
550
+ : [
551
+ btn("Run again", (e) => startDev(e.currentTarget, s.dir), { iconName: "play", small: true }),
552
+ btn("Dismiss", async () => {
553
+ await api("/api/dev/clear", { method: "POST", body: { dir: s.dir } }).catch(() => null);
554
+ logState.delete(key);
555
+ load();
556
+ }, { iconName: "x", small: true, kind: "ghost" }),
557
+ ],
558
+ ),
559
+ ),
560
+ s.services ? h("ul", { class: "stack-services", "aria-label": `Services of ${s.project}` }, s.services.map(serviceRow)) : null,
561
+ s.note ? h("p", { class: "stack-note" }, icon("warn"), s.note) : null,
562
+ showingErrors ? errorsEl : null,
563
+ showing && s.services ? outputFilter(key, s.services) : null,
564
+ showing ? logEl : null,
565
+ );
566
+ if (showing) {
567
+ const state = logState.get(key) ?? { after: 0, lines: [] };
568
+ logState.set(key, state);
569
+ state.el = logEl;
570
+ state.stack = !!s.services;
571
+ renderLines(state, state.lines, true);
572
+ }
573
+ if (showingErrors) {
574
+ errorPanels.set(key, { el: errorsEl, dir: s.dir });
575
+ void refreshErrors(key);
576
+ }
577
+ return card;
578
+ }
579
+
580
+ /** The address to show: the local domain when there is one, the dev server's own otherwise. */
581
+ function addressLink(s) {
582
+ const href = s.localUrl ?? s.url;
583
+ if (!href) return null;
584
+ return h("a", { href, target: "_blank", rel: "noreferrer", class: "mono", title: s.localUrl && s.url ? `Also at ${s.url}` : undefined }, href.replace(/^http:\/\//, ""));
585
+ }
586
+
587
+ function serviceRow(svc) {
588
+ return h(
589
+ "li",
590
+ null,
591
+ statusBadge(svc.status),
592
+ h("strong", null, svc.service),
593
+ svc.role && svc.role !== "other" ? h("span", { class: "dim" }, ROLE_LABELS[svc.role] ?? svc.role) : null,
594
+ svc.errorCount ? h("span", { class: "badge badge-danger" }, plural(svc.errorCount, "error")) : null,
595
+ addressLink(svc) ?? h("span", { class: "dim mono stack-cmd" }, svc.command),
596
+ );
597
+ }
598
+
599
+ /** All | api | web: which service's output the log shows. */
600
+ function outputFilter(key, services) {
601
+ const state = logState.get(key) ?? { after: 0, lines: [] };
602
+ logState.set(key, state);
603
+ const options = [["", "All"], ...services.map((svc) => [svc.service, svc.service])];
604
+ return h(
605
+ "div",
606
+ { class: "log-filter" },
607
+ segmented("Show output from", options, state.filter ?? "", (v) => {
608
+ state.filter = v || undefined;
609
+ renderLines(state, state.lines, true);
610
+ }),
611
+ );
612
+ }
613
+
614
+ async function refreshErrors(key) {
615
+ const panel = errorPanels.get(key);
616
+ if (!panel?.el.isConnected) return;
617
+ let result;
618
+ try {
619
+ [result] = await api(`/api/dev/errors?dir=${encodeURIComponent(panel.dir)}`);
620
+ } catch {
621
+ return;
622
+ }
623
+ const errors = result?.errors ?? [];
624
+ const sig = errors.map((e) => `${e.id}:${e.count}`).join(",");
625
+ if (panel.sig === sig) return;
626
+ panel.sig = sig;
627
+ panel.el.replaceChildren(
628
+ errors.length
629
+ ? h("ul", { class: "dev-error-list" }, errors.map((e) => errorItem(e, panel.dir)))
630
+ : h("p", { class: "dev-errors-empty" }, icon("info"), "No errors. They clear when the dev server rebuilds cleanly."),
631
+ );
632
+ }
633
+
634
+ function errorItem(e, dir) {
635
+ const where = e.file ? `${inProject(e.file, dir)}${e.line ? `:${e.line}` : ""}${e.column ? `:${e.column}` : ""}` : null;
636
+ const detail = e.lines.slice(1, 8).join("\n");
637
+ return h(
638
+ "li",
639
+ { class: "dev-error" },
640
+ h(
641
+ "div",
642
+ { class: "dev-error-head" },
643
+ h("span", { class: "badge badge-danger" }, KIND_LABELS[e.kind] ?? "Error"),
644
+ e.service ? h("span", { class: "badge" }, e.service) : null,
645
+ h("strong", { class: "dev-error-message" }, e.message),
646
+ e.count > 1 ? h("span", { class: "dim" }, `×${e.count}`) : null,
647
+ ),
648
+ where
649
+ ? btn(where, (ev) =>
650
+ busy(ev.currentTarget, "Opening…", async () => toast((await api("/api/open-file", { method: "POST", body: { dir, file: e.file, line: e.line, column: e.column } })).message)),
651
+ { small: true, kind: "ghost", iconName: "code", title: "Open in your editor at this line", name: `Open ${where} in editor` },
652
+ )
653
+ : null,
654
+ detail ? h("pre", { class: "log log-snippet" }, detail) : null,
655
+ );
656
+ }
657
+
658
+ function renderLines(state, lines, replace) {
659
+ const el = state.el;
660
+ if (!el) return;
661
+ const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
662
+ if (replace) el.replaceChildren();
663
+ for (const l of lines) {
664
+ if (state.filter && l.service !== state.filter) continue;
665
+ el.append(
666
+ h(
667
+ "span",
668
+ { class: l.stream === "err" ? "err" : l.stream === "dx" ? "dx" : "" },
669
+ state.stack && l.service && !state.filter ? h("span", { class: "svc" }, `${l.service} │ `) : null,
670
+ l.text + "\n",
671
+ ),
672
+ );
673
+ }
674
+ if (!el.childNodes.length) el.append(h("span", { class: "empty" }, "No output yet."));
675
+ if (replace || nearBottom) el.scrollTop = el.scrollHeight;
676
+ }
677
+
678
+ async function pollLogs() {
679
+ if (stopped) return;
680
+ let changed = false;
681
+ for (const [key, state] of logState) {
682
+ if (!openLogs.has(key) || !state.el?.isConnected) continue;
683
+ const p = projects.find((x) => x.dir.toLowerCase() === key);
684
+ if (!p) continue;
685
+ try {
686
+ const { lines, server } = await api(`/api/dev/logs?dir=${encodeURIComponent(p.dir)}&after=${state.after}`);
687
+ if (lines.length) {
688
+ if (state.el.querySelector(".empty")) state.el.replaceChildren();
689
+ state.lines.push(...lines);
690
+ if (state.lines.length > 2000) state.lines.splice(0, state.lines.length - 2000);
691
+ state.after = lines[lines.length - 1].n;
692
+ renderLines(state, lines, false);
693
+ }
694
+ if (server && (server.status !== p.dev?.status || server.errorCount !== p.dev?.errorCount)) changed = true;
695
+ } catch {
696
+ // dashboard stopped; the next load shows the error
697
+ }
698
+ }
699
+ for (const key of errorPanels.keys()) {
700
+ if (openErrors.has(key)) await refreshErrors(key);
701
+ else errorPanels.delete(key);
702
+ }
703
+ if (changed) await load();
704
+ if (!stopped) timer = setTimeout(pollLogs, 1000);
705
+ }
706
+
707
+ const abort = new AbortController();
708
+ // One listener closes any open ⋯ menu when you click elsewhere or press Escape.
709
+ document.addEventListener("click", (e) => {
710
+ for (const m of root.querySelectorAll("details.menu[open]")) if (!m.contains(e.target)) m.removeAttribute("open");
711
+ }, { signal: abort.signal });
712
+ document.addEventListener("keydown", (e) => {
713
+ if (e.key === "Escape") for (const m of root.querySelectorAll("details.menu[open]")) m.removeAttribute("open");
714
+ }, { signal: abort.signal });
715
+ let timer = setTimeout(pollLogs, 1000);
716
+ // Also picks up dev servers an AI agent starts while the page is open.
717
+ const statusTimer = setInterval(async () => {
718
+ const servers = await api("/api/dev").catch(() => null);
719
+ const differs = (s) => {
720
+ const dev = projects.find((p) => p.dir === s.dir)?.dev;
721
+ return dev?.status !== s.status || dev?.errorCount !== s.errorCount;
722
+ };
723
+ if (servers && (servers.some(differs) || servers.length !== projects.filter((p) => p.dev).length)) load();
724
+ }, 3000);
725
+ load();
726
+ return () => {
727
+ stopped = true;
728
+ clearTimeout(timer);
729
+ clearInterval(statusTimer);
730
+ abort.abort();
731
+ };
732
+ }
733
+
734
+ // ------------------------------------------------------------------ New project
735
+
736
+ function newProjectPage(root) {
737
+ const holder = h("div", null, loadingView("Loading templates and projects…"));
738
+ append(root, pageHead("New project", "Start from one of your templates or a copy of an existing project."), holder);
739
+
740
+ async function load() {
741
+ let data;
742
+ try {
743
+ data = await api("/api/templates");
744
+ } catch (err) {
745
+ holder.replaceChildren(errorView(err, load));
746
+ return;
747
+ }
748
+ const source = h(
749
+ "select",
750
+ { id: "new-source", required: true },
751
+ h("option", { value: "" }, "Choose…"),
752
+ data.templates.length ? h("optgroup", { label: "Your templates" }, data.templates.map((t) => h("option", { value: `template:${t}` }, t))) : null,
753
+ h("optgroup", { label: "Copy a project" }, data.projects.map((p) => h("option", { value: `project:${p.dir}` }, p.name))),
754
+ );
755
+ const name = h("input", { class: "input", id: "new-name", required: true, placeholder: "my-app", pattern: "[a-z0-9][a-z0-9._\\-]*", autocomplete: "off", spellcheck: "false" });
756
+ const where = h("select", { id: "new-in" }, data.roots.map((r) => h("option", { value: r }, tildify(r))));
757
+ const git = h("input", { type: "checkbox", checked: true });
758
+ const install = h("input", { type: "checkbox", checked: true });
759
+ const openEditor = h("input", { type: "checkbox", checked: true });
760
+ const submit = btn("Create project", null, { kind: "primary", type: "submit", iconName: "new" });
761
+ const result = h("div", { "aria-live": "polite" });
762
+
763
+ const form = h(
764
+ "form",
765
+ {
766
+ class: "card card-pad section",
767
+ novalidate: true,
768
+ onsubmit: async (e) => {
769
+ e.preventDefault();
770
+ if (!source.value) return toast("Choose what to start from.", "error"), source.focus();
771
+ if (!/^[a-z0-9][a-z0-9._-]*$/.test(name.value)) return toast("Use lowercase letters, digits, - _ or . for the name, e.g. my-app.", "error"), name.focus();
772
+ const r = await busy(submit, install.checked ? "Creating and installing…" : "Creating…", () =>
773
+ api("/api/new", { method: "POST", body: { source: source.value, name: name.value, in: where.value, git: git.checked, install: install.checked, open: openEditor.checked } }),
774
+ );
775
+ if (!r) return;
776
+ toast(`Created ${r.name}`);
777
+ result.replaceChildren(
778
+ h(
779
+ "div",
780
+ { class: "note" },
781
+ icon("info"),
782
+ h(
783
+ "div",
784
+ { class: "section" },
785
+ h("p", null, h("strong", null, r.name), " is ready in ", h("span", { class: "mono" }, tildify(r.dir))),
786
+ h("ul", { class: "bullets" }, r.steps.map((s) => h("li", null, s))),
787
+ h(
788
+ "div",
789
+ { class: "toolbar" },
790
+ btn("Run its dev server", async (e) => {
791
+ await busy(e.currentTarget, "Starting…", async () => {
792
+ await api("/api/dev/start", { method: "POST", body: { dir: r.dir } });
793
+ location.hash = "#/projects";
794
+ });
795
+ }, { kind: "primary", iconName: "play", small: true }),
796
+ h("a", { class: "btn btn-sm", href: "#/projects" }, "Go to projects"),
797
+ ),
798
+ ),
799
+ ),
800
+ );
801
+ form.reset();
802
+ git.checked = install.checked = openEditor.checked = true;
803
+ },
804
+ },
805
+ h(
806
+ "div",
807
+ { class: "form-grid" },
808
+ h("label", { class: "field", for: "new-source" }, h("span", { class: "field-label" }, "Start from"), source, h("span", { class: "field-hint" }, "A project is copied without its .git, node_modules, build output and .env files.")),
809
+ h("label", { class: "field", for: "new-name" }, h("span", { class: "field-label" }, "Name"), name, h("span", { class: "field-hint" }, "Lowercase letters, digits, - _ or .")),
810
+ h("label", { class: "field", for: "new-in" }, h("span", { class: "field-label" }, "Create it in"), where),
811
+ ),
812
+ h(
813
+ "div",
814
+ { class: "toolbar" },
815
+ h("label", { class: "check" }, git, "Create a git repository"),
816
+ h("label", { class: "check" }, install, "Install packages"),
817
+ h("label", { class: "check" }, openEditor, "Open in editor"),
818
+ ),
819
+ h("div", { class: "form-actions" }, submit),
820
+ );
821
+
822
+ holder.replaceChildren(
823
+ h("div", { class: "section" }, form, result),
824
+ h(
825
+ "section",
826
+ { class: "section", "aria-labelledby": "tpl-title" },
827
+ h("h2", { class: "section-title", id: "tpl-title" }, "Your templates"),
828
+ data.templates.length
829
+ ? h("div", { class: "chips" }, data.templates.map((t) => h("span", { class: "chip" }, t)))
830
+ : h("p", { class: "dim" }, "No templates yet. On the Projects page, open a project's ⋯ menu and choose Save as template."),
831
+ ),
832
+ );
833
+ }
834
+ load();
835
+ }
836
+
837
+ // ------------------------------------------------------------------ Ports
838
+
839
+ function portsPage(root) {
840
+ let showAll = false;
841
+ const holder = h("div", { class: "card" }, loadingView("Reading listening ports…"));
842
+ append(
843
+ root,
844
+ pageHead(
845
+ "Ports",
846
+ "What's listening on this computer, and what started it.",
847
+ h("label", { class: "check" }, h("input", { type: "checkbox", onchange: (e) => ((showAll = e.target.checked), load()) }), "Include system ports"),
848
+ btn("Refresh", () => load(), { iconName: "refresh" }),
849
+ ),
850
+ holder,
851
+ );
852
+
853
+ async function load() {
854
+ try {
855
+ const entries = await api(`/api/ports${showAll ? "?all=1" : ""}`);
856
+ if (!entries.length) {
857
+ holder.replaceChildren(stateView("Nothing is listening", showAll ? "No listening ports." : "No program is listening on ports 1024 and up."));
858
+ return;
859
+ }
860
+ holder.replaceChildren(
861
+ h(
862
+ "div",
863
+ { class: "table-wrap" },
864
+ h(
865
+ "table",
866
+ { class: "table" },
867
+ h("thead", null, h("tr", null, h("th", { scope: "col" }, "Port"), h("th", { scope: "col" }, "Process"), h("th", { scope: "col", class: "num" }, "PID"), h("th", { scope: "col" }, "Command"), h("th", { scope: "col" }, h("span", { class: "visually-hidden" }, "Actions")))),
868
+ h(
869
+ "tbody",
870
+ null,
871
+ entries.map((e) =>
872
+ h(
873
+ "tr",
874
+ null,
875
+ h("td", null, h("strong", { class: "mono" }, e.port), e.port < 1024 ? h("span", { class: "dim" }, " system") : null),
876
+ h("td", null, e.processName ?? "unknown"),
877
+ h("td", { class: "num mono" }, e.pid),
878
+ h("td", { class: "cmd", title: e.commandLine ?? "" }, e.commandLine ?? "—"),
879
+ h("td", { class: "actions" }, e.system ? h("span", { class: "dim" }, "System") : btn("Stop", (ev) => kill(ev.currentTarget, e), { kind: "danger-ghost", small: true, iconName: "stop" })),
880
+ ),
881
+ ),
882
+ ),
883
+ ),
884
+ ),
885
+ );
886
+ } catch (err) {
887
+ holder.replaceChildren(errorView(err, load));
888
+ }
889
+ }
890
+
891
+ async function kill(button, e) {
892
+ const ok = await confirmDialog({
893
+ title: `Stop ${e.processName ?? "the process"} on port ${e.port}?`,
894
+ text: [`PID ${e.pid} and its child processes will be stopped.`, e.commandLine ? h("p", { class: "mono" }, e.commandLine) : null].filter(Boolean),
895
+ confirm: "Stop it",
896
+ });
897
+ if (!ok) return;
898
+ await busy(button, "Stopping…", async () => {
899
+ const r = await api("/api/ports/kill", { method: "POST", body: { port: e.port } });
900
+ toast(r.message);
901
+ await load();
902
+ });
903
+ }
904
+ load();
905
+ }
906
+
907
+ // ------------------------------------------------------------------ Journal
908
+
909
+ function journalPage(root) {
910
+ let days = Number(pref("journalDays", "7"));
911
+ const holder = h("div", { class: "card" }, loadingView("Reading your journal…"));
912
+ const input = h("input", { class: "input input-wide", id: "journal-text", placeholder: "What did you do? e.g. fixed the seat map overflow", autocomplete: "off", maxlength: "500" });
913
+ const add = btn("Add", null, { kind: "primary", type: "submit" });
914
+ const form = h(
915
+ "form",
916
+ {
917
+ class: "card card-pad toolbar",
918
+ onsubmit: async (e) => {
919
+ e.preventDefault();
920
+ if (!input.value.trim()) return input.focus();
921
+ const r = await busy(add, "Adding…", () => api("/api/journal", { method: "POST", body: { text: input.value } }));
922
+ if (!r) return;
923
+ input.value = "";
924
+ toast(`Logged at ${r.time}`);
925
+ load();
926
+ input.focus();
927
+ },
928
+ },
929
+ h("label", { class: "visually-hidden", for: "journal-text" }, "Journal entry"),
930
+ input,
931
+ add,
932
+ );
933
+ append(
934
+ root,
935
+ pageHead(
936
+ "Journal",
937
+ "One line per thing you did. It feeds your weekly summary.",
938
+ segmented("Show", [[7, "7 days"], [30, "30 days"], [90, "90 days"]], days, (v) => ((days = v), setPref("journalDays", v), load())),
939
+ ),
940
+ form,
941
+ holder,
942
+ );
943
+
944
+ async function load() {
945
+ try {
946
+ const entries = await api(`/api/journal?days=${days}`);
947
+ if (!entries.length) {
948
+ holder.replaceChildren(stateView(`Nothing logged in the last ${days} days`, "Add what you did above, one line at a time."));
949
+ return;
950
+ }
951
+ const byDay = new Map();
952
+ for (const e of entries) (byDay.get(e.date) ?? byDay.set(e.date, []).get(e.date)).push(e);
953
+ holder.replaceChildren(
954
+ ...[...byDay.entries()].reverse().map(([date, list]) =>
955
+ h("section", { class: "day" }, h("h2", { class: "day-title" }, dayLabel(date)), h("ul", { class: "entries" }, [...list].reverse().map((e) => h("li", null, h("span", { class: "time" }, e.time), h("span", null, e.text))))),
956
+ ),
957
+ );
958
+ } catch (err) {
959
+ holder.replaceChildren(errorView(err, load));
960
+ }
961
+ }
962
+ load();
963
+ setTimeout(() => input.focus(), 0);
964
+ }
965
+
966
+ // ------------------------------------------------------------------ Summary
967
+
968
+ function summaryPage(root) {
969
+ let days = Number(pref("summaryDays", "7"));
970
+ let github = pref("summaryGithub", "1") === "1";
971
+ let everyone = false;
972
+ let markdown = "";
973
+ const holder = h("div", { class: "section" }, loadingView("Reading git history across your projects…"));
974
+ const copy = btn("Copy as markdown", async () => {
975
+ try {
976
+ await navigator.clipboard.writeText(markdown);
977
+ toast("Copied. Paste it into Slack, an email or your notes.");
978
+ } catch {
979
+ toast("Couldn't copy. Select the text and copy it yourself.", "error");
980
+ }
981
+ }, { iconName: "copy", disabled: true });
982
+ const save = btn("Save to file", async (e) => {
983
+ const r = await busy(e.currentTarget, "Saving…", () => api("/api/summary/save", { method: "POST", body: { days, github, allAuthors: everyone } }));
984
+ if (r) toast(`Saved to ${tildify(r.file)}`);
985
+ }, { iconName: "save", disabled: true });
986
+
987
+ append(
988
+ root,
989
+ pageHead("Summary", "Your commits, pull requests and journal, ready to send.", copy, save),
990
+ h(
991
+ "div",
992
+ { class: "toolbar" },
993
+ segmented("Period", [[1, "Today"], [7, "7 days"], [30, "30 days"]], days, (v) => ((days = v), setPref("summaryDays", v), load())),
994
+ h("label", { class: "check" }, h("input", { type: "checkbox", checked: github, onchange: (e) => ((github = e.target.checked), setPref("summaryGithub", github ? "1" : "0"), load()) }), "GitHub pull requests"),
995
+ h("label", { class: "check" }, h("input", { type: "checkbox", onchange: (e) => ((everyone = e.target.checked), load()) }), "Everyone's commits"),
996
+ ),
997
+ holder,
998
+ );
999
+
1000
+ async function load() {
1001
+ copy.disabled = save.disabled = true;
1002
+ holder.replaceChildren(loadingView("Reading git history across your projects…"));
1003
+ try {
1004
+ const r = await api(`/api/summary?days=${days}&github=${github ? 1 : 0}&allAuthors=${everyone ? 1 : 0}`);
1005
+ markdown = r.markdown;
1006
+ copy.disabled = save.disabled = false;
1007
+ holder.replaceChildren(...renderSummary(r.summary));
1008
+ } catch (err) {
1009
+ holder.replaceChildren(errorView(err, load));
1010
+ }
1011
+ }
1012
+
1013
+ function renderSummary(s) {
1014
+ const commits = s.projects.reduce((n, p) => n + p.commits.length, 0);
1015
+ const gh = s.github && "user" in s.github ? s.github : null;
1016
+ const out = [
1017
+ h(
1018
+ "div",
1019
+ { class: "stats" },
1020
+ stat(commits, commits === 1 ? "commit" : "commits", `in ${plural(s.projects.length, "project")}`),
1021
+ gh ? stat(gh.merged.length, gh.merged.length === 1 ? "pull request merged" : "pull requests merged", `${gh.reviewed.length} reviewed`) : null,
1022
+ stat(s.journal.length, s.journal.length === 1 ? "journal entry" : "journal entries"),
1023
+ stat(s.time.length, s.time.length === 1 ? "project touched" : "projects touched"),
1024
+ ),
1025
+ ];
1026
+ if (!commits && !s.journal.length && !(gh && gh.merged.length + gh.reviewed.length)) {
1027
+ out.push(h("div", { class: "card" }, stateView("Nothing to report yet", `No commits and no journal entries in the last ${plural(s.days, "day")}.`, h("a", { class: "btn", href: "#/journal" }, "Write in your journal"))));
1028
+ }
1029
+ if (s.journal.length) {
1030
+ const byDay = new Map();
1031
+ for (const e of s.journal) (byDay.get(e.date) ?? byDay.set(e.date, []).get(e.date)).push(e);
1032
+ out.push(
1033
+ section("Journal", h("div", { class: "card" }, [...byDay.entries()].map(([date, list]) => h("div", { class: "day" }, h("h3", { class: "day-title" }, dayLabel(date)), h("ul", { class: "bullets" }, list.map((e) => h("li", null, e.text))))))),
1034
+ );
1035
+ }
1036
+ if (gh && gh.merged.length + gh.reviewed.length) {
1037
+ const prList = (title, prs) =>
1038
+ prs.length
1039
+ ? h("div", { class: "day" }, h("h3", { class: "day-title" }, title), h("ul", { class: "bullets" }, prs.map((pr) => h("li", null, pr.title, " ", h("a", { href: pr.url, target: "_blank", rel: "noreferrer", class: "mono" }, `${pr.repo}#${pr.number}`)))))
1040
+ : null;
1041
+ out.push(section("Pull requests", h("div", { class: "card" }, prList("Merged", gh.merged), prList("Reviewed", gh.reviewed))));
1042
+ }
1043
+ if (s.projects.length) {
1044
+ out.push(
1045
+ section(
1046
+ "Commits",
1047
+ h(
1048
+ "div",
1049
+ { class: "card" },
1050
+ s.projects.map((p) => {
1051
+ const seen = new Map();
1052
+ for (const c of [...p.commits].reverse()) {
1053
+ const key = c.subject.toLowerCase();
1054
+ const first = seen.get(key);
1055
+ seen.set(key, first ? { c: first.c, n: first.n + 1 } : { c, n: 1 });
1056
+ }
1057
+ return h(
1058
+ "div",
1059
+ { class: "day" },
1060
+ h("h3", { class: "day-title" }, `${p.project} `, h("span", { class: "dim" }, `(${p.commits.length})`)),
1061
+ h("ul", { class: "bullets" }, [...seen.values()].map(({ c, n }) => h("li", null, c.subject || "(no message)", n > 1 ? h("span", { class: "dim" }, ` ×${n}`) : null, h("span", { class: "dim" }, ` · ${dayLabel(c.date)}`)))),
1062
+ );
1063
+ }),
1064
+ ),
1065
+ ),
1066
+ );
1067
+ }
1068
+ if (s.time.length) {
1069
+ const max = Math.max(...s.time.map((t) => t.days), 1);
1070
+ out.push(
1071
+ section(
1072
+ "Where the time went",
1073
+ h(
1074
+ "div",
1075
+ { class: "card card-pad section" },
1076
+ s.time.map((t) =>
1077
+ h(
1078
+ "div",
1079
+ { class: "bar-row" },
1080
+ h("strong", null, t.project),
1081
+ h(
1082
+ "div",
1083
+ { class: "bar-track" },
1084
+ h("div", { class: "bar", style: { width: `${Math.max(4, (t.days / max) * 100)}%` }, role: "img", "aria-label": plural(t.days, "day") }),
1085
+ h("span", { class: "bar-label" }, [plural(t.days, "day"), t.commits ? plural(t.commits, "commit") : null, t.opens ? `opened ${t.opens}×` : null, t.devMs >= 60000 ? `${Math.round(t.devMs / 60000)} min dev server` : null].filter(Boolean).join(" · ")),
1086
+ ),
1087
+ ),
1088
+ ),
1089
+ ),
1090
+ ),
1091
+ );
1092
+ }
1093
+ if (s.uncommitted.length) {
1094
+ out.push(
1095
+ section(
1096
+ "Not committed yet",
1097
+ h("div", { class: "note note-warn" }, icon("warn"), h("div", null, h("p", null, "These projects have changes you haven't committed. It's a reminder for you, and it's in the copied text too: delete that part before sending if you like."), h("ul", { class: "bullets" }, s.uncommitted.map((u) => h("li", null, h("strong", null, u.project), `: ${plural(u.files, "changed file")}`))))),
1098
+ ),
1099
+ );
1100
+ }
1101
+ const notes = [];
1102
+ if (s.skipped.length) notes.push(`Couldn't read ${s.skipped.map((x) => `${x.project} (${x.reason})`).join(", ")}.`);
1103
+ if (s.github && "error" in s.github) notes.push(`No pull requests: ${s.github.error}.`);
1104
+ else if (gh && !gh.authenticated) notes.push("Pull requests come from public repos only. Set GITHUB_TOKEN before running dx ui to include private ones.");
1105
+ if (notes.length) out.push(h("div", { class: "note" }, icon("info"), h("div", null, notes.map((n) => h("p", null, n)))));
1106
+ out.push(h("p", { class: "dim" }, `Commits by ${s.author}.`));
1107
+ return out;
1108
+ }
1109
+
1110
+ function stat(value, label, sub) {
1111
+ return h("div", { class: "card stat" }, h("div", { class: "stat-value" }, value), h("div", { class: "stat-label" }, label), sub ? h("div", { class: "stat-label" }, sub) : null);
1112
+ }
1113
+ function section(title, ...content) {
1114
+ return h("section", { class: "section" }, h("h2", { class: "section-title" }, title), content);
1115
+ }
1116
+ load();
1117
+ }
1118
+
1119
+ // ------------------------------------------------------------------ Cleanup
1120
+
1121
+ function cleanupPage(root) {
1122
+ let days = Number(pref("cleanupDays", "30"));
1123
+ let scan = null;
1124
+ const selected = new Set();
1125
+ const holder = h("div", { class: "section" }, loadingView("Measuring build folders in idle projects…"));
1126
+ append(
1127
+ root,
1128
+ pageHead(
1129
+ "Cleanup",
1130
+ "Free disk space: node_modules and build folders in projects you haven't touched lately.",
1131
+ h("label", { class: "field" }, h("span", { class: "visually-hidden" }, "Idle for"), h("select", { onchange: (e) => ((days = Number(e.target.value)), setPref("cleanupDays", days), load()) }, [30, 60, 90, 180, 365].map((d) => h("option", { value: d, selected: d === days }, `Idle for ${d}+ days`)))),
1132
+ btn("Scan again", () => load(), { iconName: "refresh" }),
1133
+ ),
1134
+ h("div", { class: "note" }, icon("info"), h("p", null, "Only folders an install or a build recreates are listed. In a git repo, only folders git ignores, so a committed build folder is never touched. Run npm install in a project to get its node_modules back.")),
1135
+ holder,
1136
+ );
1137
+
1138
+ async function load() {
1139
+ holder.replaceChildren(loadingView("Measuring build folders in idle projects…"));
1140
+ try {
1141
+ scan = await api(`/api/cleanup?days=${days}`);
1142
+ selected.clear();
1143
+ for (const i of scan.items) selected.add(i.dir);
1144
+ render();
1145
+ } catch (err) {
1146
+ holder.replaceChildren(errorView(err, load));
1147
+ }
1148
+ }
1149
+
1150
+ function render() {
1151
+ if (!scan.items.length) {
1152
+ holder.replaceChildren(h("div", { class: "card" }, stateView("Nothing to clean", scan.projectsScanned ? `${plural(scan.projectsScanned, "project")} idle for ${days}+ days, and none has build folders left.` : `No project has been idle for ${days}+ days.`)));
1153
+ return;
1154
+ }
1155
+ const chosen = scan.items.filter((i) => selected.has(i.dir));
1156
+ const chosenBytes = chosen.reduce((n, i) => n + i.bytes, 0);
1157
+ const all = h("input", {
1158
+ type: "checkbox",
1159
+ "aria-label": "Select all",
1160
+ checked: chosen.length === scan.items.length,
1161
+ indeterminate: chosen.length > 0 && chosen.length < scan.items.length,
1162
+ onchange: (e) => {
1163
+ selected.clear();
1164
+ if (e.target.checked) for (const i of scan.items) selected.add(i.dir);
1165
+ render();
1166
+ },
1167
+ });
1168
+ holder.replaceChildren(
1169
+ h(
1170
+ "div",
1171
+ { class: "stats" },
1172
+ h("div", { class: "card stat" }, h("div", { class: "stat-value" }, formatBytes(scan.totalBytes)), h("div", { class: "stat-label" }, "can be freed")),
1173
+ h("div", { class: "card stat" }, h("div", { class: "stat-value" }, scan.items.length), h("div", { class: "stat-label" }, `folders in ${plural(new Set(scan.items.map((i) => i.project)).size, "project")}`)),
1174
+ ),
1175
+ h(
1176
+ "div",
1177
+ { class: "card table-wrap" },
1178
+ h(
1179
+ "table",
1180
+ { class: "table" },
1181
+ h("thead", null, h("tr", null, h("th", { scope: "col" }, all), h("th", { scope: "col" }, "Project"), h("th", { scope: "col" }, "Folder"), h("th", { scope: "col", class: "num" }, "Size"), h("th", { scope: "col" }, "Idle"))),
1182
+ h(
1183
+ "tbody",
1184
+ null,
1185
+ scan.items.map((i) =>
1186
+ h(
1187
+ "tr",
1188
+ null,
1189
+ h("td", null, h("input", { type: "checkbox", "aria-label": `${i.project} ${i.relative}`, checked: selected.has(i.dir), onchange: (e) => (e.target.checked ? selected.add(i.dir) : selected.delete(i.dir), render()) })),
1190
+ h("td", null, h("strong", null, i.project)),
1191
+ h("td", { class: "mono" }, i.relative),
1192
+ h("td", { class: "num" }, formatBytes(i.bytes)),
1193
+ h("td", { class: "dim" }, relativeTime(i.lastActive)),
1194
+ ),
1195
+ ),
1196
+ ),
1197
+ ),
1198
+ ),
1199
+ h(
1200
+ "div",
1201
+ { class: "sticky-bar" },
1202
+ h("span", null, chosen.length ? `${plural(chosen.length, "folder")} selected · ${formatBytes(chosenBytes)}` : "Nothing selected"),
1203
+ btn(`Delete ${chosen.length ? formatBytes(chosenBytes) : ""}`.trim(), (e) => remove(e.currentTarget, chosen, chosenBytes), { kind: "danger", iconName: "cleanup", disabled: !chosen.length }),
1204
+ ),
1205
+ );
1206
+ }
1207
+
1208
+ async function remove(button, chosen, bytes) {
1209
+ const projects = [...new Set(chosen.map((i) => i.project))];
1210
+ const ok = await confirmDialog({
1211
+ title: `Delete ${plural(chosen.length, "folder")} (${formatBytes(bytes)})?`,
1212
+ text: [`From ${projects.join(", ")}.`, "They're recreated by npm install or a build. This can't be undone."],
1213
+ confirm: `Delete ${formatBytes(bytes)}`,
1214
+ });
1215
+ if (!ok) return;
1216
+ await busy(button, "Deleting…", async () => {
1217
+ const r = await api("/api/cleanup/delete", { method: "POST", body: { days, dirs: chosen.map((i) => i.dir) } });
1218
+ toast(`Freed ${formatBytes(r.freed)} (${plural(r.deleted, "folder")} deleted)`);
1219
+ for (const f of r.failed) toast(`${tildify(f.dir)}: ${f.message}`, "error");
1220
+ await load();
1221
+ });
1222
+ }
1223
+ load();
1224
+ }
1225
+
1226
+ // ------------------------------------------------------------------ Dependencies
1227
+
1228
+ function depsPage(root) {
1229
+ let active = pref("depsActive", "");
1230
+ const expanded = new Set();
1231
+ let timer;
1232
+ const runButton = btn("Run audit", () => run(), { kind: "primary", iconName: "deps" });
1233
+ const holder = h("div", { class: "section" });
1234
+ append(
1235
+ root,
1236
+ pageHead(
1237
+ "Dependencies",
1238
+ "Outdated packages and known vulnerabilities in every Node project. Nothing is changed.",
1239
+ h("label", { class: "field" }, h("span", { class: "visually-hidden" }, "Which projects"), h("select", { onchange: (e) => ((active = e.target.value), setPref("depsActive", active)) }, [["", "All projects"], ["30", "Active in the last 30 days"], ["90", "Active in the last 90 days"]].map(([v, t]) => h("option", { value: v, selected: v === active }, t)))),
1240
+ runButton,
1241
+ ),
1242
+ holder,
1243
+ );
1244
+
1245
+ async function run() {
1246
+ try {
1247
+ await api("/api/deps/run", { method: "POST", body: { activeDays: active } });
1248
+ poll();
1249
+ } catch (err) {
1250
+ toast(err.message, "error");
1251
+ }
1252
+ }
1253
+
1254
+ async function poll() {
1255
+ clearTimeout(timer);
1256
+ let job;
1257
+ try {
1258
+ job = await api("/api/deps");
1259
+ } catch (err) {
1260
+ holder.replaceChildren(errorView(err, poll));
1261
+ return;
1262
+ }
1263
+ runButton.disabled = job.running;
1264
+ if (job.running) {
1265
+ const pct = job.total ? Math.round((job.done / job.total) * 100) : 0;
1266
+ holder.replaceChildren(
1267
+ h(
1268
+ "div",
1269
+ { class: "card card-pad section", role: "status" },
1270
+ h("p", null, job.total ? `Checking ${Math.min(job.done + 1, job.total)} of ${job.total}: ` : "Finding your Node projects…", job.current ? h("strong", null, job.current) : null),
1271
+ h("div", { class: "progress", role: "progressbar", "aria-valuemin": "0", "aria-valuemax": "100", "aria-valuenow": String(pct) }, h("div", { style: { width: `${pct}%` } })),
1272
+ h("p", { class: "dim" }, "It asks the npm registry, so it takes a few seconds per project. You can leave this page; it keeps running."),
1273
+ ),
1274
+ );
1275
+ timer = setTimeout(poll, 1000);
1276
+ return;
1277
+ }
1278
+ if (job.error) {
1279
+ holder.replaceChildren(errorView(new Error(job.error), run));
1280
+ return;
1281
+ }
1282
+ if (!job.reports) {
1283
+ holder.replaceChildren(h("div", { class: "card" }, stateView("No audit yet", "Run it to check every Node project. It takes about 5 seconds per project.", btn("Run audit", () => run(), { kind: "primary", iconName: "deps" }))));
1284
+ return;
1285
+ }
1286
+ render(job);
1287
+ }
1288
+
1289
+ function render(job) {
1290
+ const reports = job.reports;
1291
+ const checked = reports.filter((r) => !r.skipped);
1292
+ const vulnerable = checked.filter((r) => (r.vulnerabilities?.total ?? 0) > 0);
1293
+ const critical = checked.reduce((n, r) => n + (r.vulnerabilities?.critical ?? 0), 0);
1294
+ const outdated = checked.reduce((n, r) => n + r.outdated.length, 0);
1295
+ holder.replaceChildren(
1296
+ h(
1297
+ "div",
1298
+ { class: "stats" },
1299
+ stat(vulnerable.length, vulnerable.length === 1 ? "project with vulnerabilities" : "projects with vulnerabilities", `of ${checked.length} checked`),
1300
+ stat(critical, critical === 1 ? "critical vulnerability" : "critical vulnerabilities"),
1301
+ stat(outdated, outdated === 1 ? "outdated package" : "outdated packages"),
1302
+ ),
1303
+ h(
1304
+ "div",
1305
+ { class: "card table-wrap" },
1306
+ h(
1307
+ "table",
1308
+ { class: "table" },
1309
+ h("thead", null, h("tr", null, h("th", { scope: "col" }, "Project"), h("th", { scope: "col" }, "Vulnerabilities"), h("th", { scope: "col", class: "num" }, "Outdated"), h("th", { scope: "col", class: "num" }, "Major"), h("th", { scope: "col" }, h("span", { class: "visually-hidden" }, "Details")))),
1310
+ h("tbody", null, reports.flatMap((r) => rows(r))),
1311
+ ),
1312
+ ),
1313
+ h("p", { class: "dim" }, `Checked ${relativeTime(Date.parse(job.finishedAt))}. Fix in a project with npm audit fix (or npm update), then run the audit again.`),
1314
+ );
1315
+ }
1316
+
1317
+ function rows(r) {
1318
+ const open = expanded.has(r.dir);
1319
+ const toggle = () => (open ? expanded.delete(r.dir) : expanded.add(r.dir), poll());
1320
+ const main = h(
1321
+ "tr",
1322
+ null,
1323
+ h("td", null, h("strong", null, r.project), h("div", { class: "dim mono" }, tildify(r.dir))),
1324
+ h("td", null, r.skipped ? h("span", { class: "dim" }, r.skipped) : vulnBadges(r.vulnerabilities)),
1325
+ h("td", { class: "num" }, r.skipped ? "—" : r.outdated.length),
1326
+ h("td", { class: "num" }, r.skipped ? "—" : r.outdated.filter((p) => p.major).length),
1327
+ h("td", { class: "actions" }, !r.skipped && (r.outdated.length || r.notes.length) ? btn(open ? "Hide" : "Details", toggle, { small: true, kind: "ghost", title: `Packages in ${r.project}` }) : null),
1328
+ );
1329
+ if (!open) return [main];
1330
+ return [
1331
+ main,
1332
+ h(
1333
+ "tr",
1334
+ { class: "row-detail" },
1335
+ h(
1336
+ "td",
1337
+ { colspan: "5" },
1338
+ h(
1339
+ "div",
1340
+ { class: "section" },
1341
+ r.notes.map((n) => h("p", { class: "dim" }, n)),
1342
+ r.outdated.length
1343
+ ? h(
1344
+ "table",
1345
+ { class: "table" },
1346
+ h("thead", null, h("tr", null, h("th", null, "Package"), h("th", null, "Installed"), h("th", null, "Wanted"), h("th", null, "Latest"))),
1347
+ h("tbody", null, r.outdated.map((p) => h("tr", null, h("td", { class: "mono" }, p.name), h("td", { class: "mono" }, p.current ?? "—"), h("td", { class: "mono" }, p.wanted ?? "—"), h("td", { class: "mono" }, p.latest, p.major ? h("span", { class: "badge badge-warn" }, " major") : null)))),
1348
+ )
1349
+ : null,
1350
+ h("div", { class: "toolbar" }, btn("Open in editor", (e) => busy(e.currentTarget, "Opening…", async () => toast((await api("/api/projects/open", { method: "POST", body: { dir: r.dir, action: "editor" } })).message)), { small: true, iconName: "code" })),
1351
+ ),
1352
+ ),
1353
+ ),
1354
+ ];
1355
+ }
1356
+
1357
+ function vulnBadges(v) {
1358
+ if (!v) return h("span", { class: "dim" }, "Couldn't check");
1359
+ const parts = [
1360
+ ["critical", "badge-danger"],
1361
+ ["high", "badge-danger"],
1362
+ ["moderate", "badge-warn"],
1363
+ ["low", ""],
1364
+ ].filter(([k]) => v[k] > 0);
1365
+ if (!parts.length) return h("span", { class: "badge badge-ok" }, "None");
1366
+ return h("div", { class: "chips" }, parts.map(([k, cls]) => h("span", { class: `badge ${cls}` }, `${v[k]} ${k}`)));
1367
+ }
1368
+
1369
+ function stat(value, label, sub) {
1370
+ return h("div", { class: "card stat" }, h("div", { class: "stat-value" }, value), h("div", { class: "stat-label" }, label), sub ? h("div", { class: "stat-label" }, sub) : null);
1371
+ }
1372
+
1373
+ poll();
1374
+ return () => clearTimeout(timer);
1375
+ }
1376
+
1377
+ // ------------------------------------------------------------------ Health & settings
1378
+
1379
+ function healthPage(root) {
1380
+ const holder = h("div", { class: "section" }, loadingView("Checking your setup…"));
1381
+ append(root, pageHead("Health & settings", "Checks that dx works on this computer, its settings, and recent activity.", btn("Check again", () => load(), { iconName: "refresh" })), holder);
1382
+
1383
+ async function load() {
1384
+ let data;
1385
+ try {
1386
+ data = await api("/api/health");
1387
+ } catch (err) {
1388
+ holder.replaceChildren(errorView(err, load));
1389
+ return;
1390
+ }
1391
+ const symbol = { ok: "✓", warn: "!", fail: "✗" };
1392
+ const checks = h(
1393
+ "ul",
1394
+ { class: "checks card" },
1395
+ data.checks.map((c) =>
1396
+ h(
1397
+ "li",
1398
+ null,
1399
+ h("span", { class: `status-${c.status}`, "aria-label": c.status }, symbol[c.status]),
1400
+ h("strong", null, c.name),
1401
+ h("span", { class: "dim", style: { overflowWrap: "anywhere" } }, c.detail, c.fix ? h("span", { class: "check-fix" }, "Fix: ", h("span", { class: "mono" }, c.fix)) : null),
1402
+ ),
1403
+ ),
1404
+ );
1405
+
1406
+ const aiTools = h("div", null, loadingView("Looking for AI tools…"));
1407
+ void loadAiTools(aiTools);
1408
+
1409
+ const config = data.config;
1410
+ const newRoot = h("input", { class: "input input-wide", placeholder: "C:\\Users\\you\\code", "aria-label": "Folder to add" });
1411
+ const editor = h("input", { class: "input", id: "cfg-editor", value: config.editor });
1412
+ const github = h("input", { class: "input", id: "cfg-github", value: config.githubUser ?? "", placeholder: "Guessed from your repos" });
1413
+ const depth = h("select", { id: "cfg-depth" }, [1, 2, 3].map((d) => h("option", { value: d, selected: d === config.scanDepth }, d === 1 ? "Only the folders inside" : `${d} levels deep`)));
1414
+ const save = btn("Save settings", null, { kind: "primary", type: "submit" });
1415
+
1416
+ const settings = h(
1417
+ "form",
1418
+ {
1419
+ class: "card card-pad section",
1420
+ onsubmit: async (e) => {
1421
+ e.preventDefault();
1422
+ const r = await busy(save, "Saving…", () => api("/api/config", { method: "POST", body: { editor: editor.value, githubUser: github.value, scanDepth: Number(depth.value) } }));
1423
+ if (r) toast("Settings saved");
1424
+ },
1425
+ },
1426
+ h("h2", { class: "section-title" }, "Settings"),
1427
+ h(
1428
+ "div",
1429
+ { class: "field" },
1430
+ h("span", { class: "field-label" }, "Projects folders"),
1431
+ h(
1432
+ "div",
1433
+ { class: "chips" },
1434
+ config.projectsRoots.map((r) =>
1435
+ h("span", { class: "chip" }, tildify(r), h("button", { type: "button", class: "btn btn-sm btn-ghost", "aria-label": `Remove ${r}`, title: "Remove", onclick: () => updateRoots({ removeRoot: r }) }, icon("x"))),
1436
+ ),
1437
+ ),
1438
+ h("div", { class: "toolbar" }, newRoot, btn("Add folder", () => newRoot.value.trim() && updateRoots({ addRoot: newRoot.value }), { iconName: "new" })),
1439
+ ),
1440
+ h(
1441
+ "div",
1442
+ { class: "form-grid" },
1443
+ h("label", { class: "field", for: "cfg-editor" }, h("span", { class: "field-label" }, "Editor command"), editor, h("span", { class: "field-hint" }, "code, cursor, or a full path in quotes")),
1444
+ h("label", { class: "field", for: "cfg-github" }, h("span", { class: "field-label" }, "GitHub username"), github, h("span", { class: "field-hint" }, "For pull requests in the summary")),
1445
+ h("label", { class: "field", for: "cfg-depth" }, h("span", { class: "field-label" }, "Look for projects"), depth),
1446
+ ),
1447
+ h("div", { class: "form-actions" }, save),
1448
+ );
1449
+
1450
+ const runs = data.runs.length
1451
+ ? h(
1452
+ "div",
1453
+ { class: "card table-wrap" },
1454
+ h(
1455
+ "table",
1456
+ { class: "table" },
1457
+ h("thead", null, h("tr", null, h("th", null, "When"), h("th", null, "Tool"), h("th", null, "From"), h("th", null, "Result"), h("th", null, "Detail"))),
1458
+ h(
1459
+ "tbody",
1460
+ null,
1461
+ data.runs.map((r) =>
1462
+ h("tr", null, h("td", { class: "dim" }, relativeTime(Date.parse(r.at))), h("td", { class: "mono" }, r.tool), h("td", { class: "dim" }, r.source ?? "cli"), h("td", null, r.ok ? h("span", { class: "badge badge-ok" }, "ok") : h("span", { class: "badge badge-danger" }, "failed")), h("td", { class: "cmd", title: r.detail ?? "" }, r.detail ?? "")),
1463
+ ),
1464
+ ),
1465
+ ),
1466
+ )
1467
+ : h("div", { class: "card" }, stateView("No activity yet", "Opening projects, killing ports and other actions show up here."));
1468
+
1469
+ holder.replaceChildren(
1470
+ h("section", { class: "section" }, h("h2", { class: "section-title" }, "Checks"), checks),
1471
+ h(
1472
+ "section",
1473
+ { class: "section" },
1474
+ h("h2", { class: "section-title" }, "AI tools"),
1475
+ h("p", { class: "page-sub" }, "Connected tools can run your apps, read their output and errors, and check pages through dx. dx connects new ones by itself."),
1476
+ aiTools,
1477
+ ),
1478
+ settings,
1479
+ h("section", { class: "section" }, h("h2", { class: "section-title" }, "Recent activity"), runs),
1480
+ );
1481
+ }
1482
+
1483
+ async function loadAiTools(holderEl) {
1484
+ let tools;
1485
+ try {
1486
+ tools = await api("/api/ai-tools");
1487
+ } catch (err) {
1488
+ holderEl.replaceChildren(errorView(err, () => loadAiTools(holderEl)));
1489
+ return;
1490
+ }
1491
+ const act = (button, path, ids, label) =>
1492
+ busy(button, label, async () => {
1493
+ const results = await api(path, { method: "POST", body: { ids } });
1494
+ for (const r of results) toast(`${r.name}: ${r.message}`, r.ok ? "ok" : "error");
1495
+ await loadAiTools(holderEl);
1496
+ });
1497
+ const state = (t) =>
1498
+ !t.installed && !t.connected
1499
+ ? ["", "–", "Not installed"]
1500
+ : t.connected && t.current
1501
+ ? ["status-ok", "✓", "Connected"]
1502
+ : t.connected
1503
+ ? ["status-warn", "!", "Points at an old copy of dx"]
1504
+ : ["status-warn", "!", "Not connected"];
1505
+ holderEl.replaceChildren(
1506
+ h(
1507
+ "ul",
1508
+ { class: "checks checks-actions card" },
1509
+ tools.map((t) => {
1510
+ const [cls, sym, text] = state(t);
1511
+ return h(
1512
+ "li",
1513
+ null,
1514
+ h("span", { class: cls, "aria-hidden": "true" }, sym),
1515
+ h("strong", null, t.name),
1516
+ h("span", { class: "dim" }, text, t.connected && t.current && t.note ? h("span", { class: "check-fix" }, t.note) : null),
1517
+ t.connected && t.current
1518
+ ? btn("Disconnect", (e) => act(e.currentTarget, "/api/ai-tools/disconnect", [t.id], "Disconnecting…"), { small: true, kind: "ghost", name: `Disconnect ${t.name}` })
1519
+ : t.installed || t.connected
1520
+ ? btn(t.connected ? "Update" : "Connect", (e) => act(e.currentTarget, "/api/ai-tools/connect", [t.id], "Connecting…"), { small: true, kind: "primary", name: `Connect ${t.name}` })
1521
+ : h("span"),
1522
+ );
1523
+ }),
1524
+ ),
1525
+ );
1526
+ }
1527
+
1528
+ async function updateRoots(body) {
1529
+ try {
1530
+ await api("/api/config", { method: "POST", body });
1531
+ toast(body.addRoot ? "Folder added" : "Folder removed");
1532
+ load();
1533
+ } catch (err) {
1534
+ toast(err.message, "error");
1535
+ }
1536
+ }
1537
+ load();
1538
+ }
1539
+
1540
+ // ------------------------------------------------------------------ start
1541
+
1542
+ const themeSelect = document.getElementById("theme");
1543
+ function applyTheme(value) {
1544
+ if (value === "auto") document.documentElement.removeAttribute("data-theme");
1545
+ else document.documentElement.setAttribute("data-theme", value);
1546
+ }
1547
+ themeSelect.value = pref("theme", "auto");
1548
+ applyTheme(themeSelect.value);
1549
+ themeSelect.addEventListener("change", () => (applyTheme(themeSelect.value), setPref("theme", themeSelect.value)));
1550
+
1551
+ window.addEventListener("hashchange", route);
1552
+ setInterval(updateRunningCount, 5000);
1553
+ route();