hlidskjalf 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -0
- package/dist/index.js +493 -0
- package/package.json +41 -0
package/README.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# hlidskjalf
|
|
2
|
+
|
|
3
|
+
A terminal dashboard for monitoring Turborepo dev processes, built with [Ink](https://npm.im/ink).
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
Add it to your root `package.json`:
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
{
|
|
11
|
+
"scripts": {
|
|
12
|
+
"dev": "hlidskjalf"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Then run it:
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
pnpm dev
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Options
|
|
24
|
+
|
|
25
|
+
| Option | Description |
|
|
26
|
+
| --- | --- |
|
|
27
|
+
| `--filter=<name>` | Only include matching workspaces. Can be passed multiple times. Append `...` to include transitive dependencies (e.g. `--filter=web...`). |
|
|
28
|
+
| `--order=<mode>` | `alphabetical` (default) or `run` (dependency order). |
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.tsx
|
|
4
|
+
import { render } from "ink";
|
|
5
|
+
|
|
6
|
+
// src/app.tsx
|
|
7
|
+
import { useApp, useInput } from "ink";
|
|
8
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
9
|
+
|
|
10
|
+
// src/processes.ts
|
|
11
|
+
import { spawn } from "child_process";
|
|
12
|
+
import { EventEmitter } from "events";
|
|
13
|
+
|
|
14
|
+
// src/parser.ts
|
|
15
|
+
var DTS = /\bDTS\b/;
|
|
16
|
+
var matchers = [
|
|
17
|
+
{ pattern: /running on (https?:\/\/\S+)/, status: "ready" },
|
|
18
|
+
{ pattern: /listening on (https?:\/\/\S+)/, status: "ready" },
|
|
19
|
+
{ pattern: /started.*(https?:\/\/localhost:\d+)/, status: "ready" },
|
|
20
|
+
{ pattern: /\bVITE\b.*\bready in\b/i, status: "ready" },
|
|
21
|
+
{ pattern: /\bLocal:\s+(https?:\/\/\S+)/, status: "ready" },
|
|
22
|
+
// ⚡ may include U+FE0F variation selector
|
|
23
|
+
{ pattern: /⚡\uFE0F?\s*Build success/, status: "watching" },
|
|
24
|
+
{ pattern: /Build start/, status: "building" },
|
|
25
|
+
{ pattern: /Watching for changes/, status: "watching" },
|
|
26
|
+
{ pattern: /[Ee]rror[\s:]/, status: "error" },
|
|
27
|
+
{ pattern: /process exit/, status: "error" }
|
|
28
|
+
];
|
|
29
|
+
function parseLine(line) {
|
|
30
|
+
if (DTS.test(line)) return {};
|
|
31
|
+
for (const { pattern, status } of matchers) {
|
|
32
|
+
const match = line.match(pattern);
|
|
33
|
+
if (match) return { status, url: match[1] };
|
|
34
|
+
}
|
|
35
|
+
return {};
|
|
36
|
+
}
|
|
37
|
+
function stripAnsi(text) {
|
|
38
|
+
return text.replace(/\x1b(?:\[[0-9;]*[A-Za-z]|\].*?(?:\x07|\x1b\\))/g, "");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// src/processes.ts
|
|
42
|
+
var MAX_LOGS = 500;
|
|
43
|
+
var ERROR_RECOVERY_MS = 5e3;
|
|
44
|
+
var MAX_CRASH_RETRIES = 3;
|
|
45
|
+
var ProcessRunner = class extends EventEmitter {
|
|
46
|
+
children = /* @__PURE__ */ new Map();
|
|
47
|
+
state = /* @__PURE__ */ new Map();
|
|
48
|
+
errorTimers = /* @__PURE__ */ new Map();
|
|
49
|
+
lastGoodStatus = /* @__PURE__ */ new Map();
|
|
50
|
+
crashRetries = /* @__PURE__ */ new Map();
|
|
51
|
+
pendingRebuilds = /* @__PURE__ */ new Set();
|
|
52
|
+
root;
|
|
53
|
+
stopping = false;
|
|
54
|
+
constructor(root) {
|
|
55
|
+
super();
|
|
56
|
+
this.root = root;
|
|
57
|
+
}
|
|
58
|
+
get(name) {
|
|
59
|
+
return this.state.get(name);
|
|
60
|
+
}
|
|
61
|
+
async start(workspaces) {
|
|
62
|
+
const packages = workspaces.filter((w) => w.kind === "package");
|
|
63
|
+
const apps = workspaces.filter((w) => w.kind === "app");
|
|
64
|
+
for (const workspace of workspaces) {
|
|
65
|
+
this.state.set(workspace.name, { workspace, status: "pending", logs: [] });
|
|
66
|
+
}
|
|
67
|
+
for (const workspace of packages) {
|
|
68
|
+
this.spawn(workspace);
|
|
69
|
+
}
|
|
70
|
+
if (packages.length > 0) {
|
|
71
|
+
await this.waitForPackages(packages.map((p) => p.name));
|
|
72
|
+
}
|
|
73
|
+
for (const workspace of apps) {
|
|
74
|
+
this.spawn(workspace);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
async shutdown() {
|
|
78
|
+
this.stopping = true;
|
|
79
|
+
for (const timer of this.errorTimers.values()) clearTimeout(timer);
|
|
80
|
+
this.errorTimers.clear();
|
|
81
|
+
for (const child of this.pendingRebuilds) child.kill("SIGTERM");
|
|
82
|
+
const waiting = [];
|
|
83
|
+
for (const [, child] of this.children) {
|
|
84
|
+
if (child.exitCode !== null || child.signalCode !== null) continue;
|
|
85
|
+
waiting.push(
|
|
86
|
+
new Promise((resolve) => {
|
|
87
|
+
const escalate = setTimeout(() => {
|
|
88
|
+
if (child.exitCode === null) child.kill("SIGKILL");
|
|
89
|
+
}, 5e3);
|
|
90
|
+
child.on("close", () => {
|
|
91
|
+
clearTimeout(escalate);
|
|
92
|
+
resolve();
|
|
93
|
+
});
|
|
94
|
+
child.kill("SIGTERM");
|
|
95
|
+
})
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
await Promise.all(waiting);
|
|
99
|
+
}
|
|
100
|
+
waitForPackages(names) {
|
|
101
|
+
const remaining = new Set(names);
|
|
102
|
+
return new Promise((resolve) => {
|
|
103
|
+
const check = () => {
|
|
104
|
+
for (const name of [...remaining]) {
|
|
105
|
+
const s = this.state.get(name)?.status;
|
|
106
|
+
if (s === "watching" || s === "error" || s === "stopped") remaining.delete(name);
|
|
107
|
+
}
|
|
108
|
+
if (remaining.size === 0) {
|
|
109
|
+
this.off("change", check);
|
|
110
|
+
resolve();
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
this.on("change", check);
|
|
114
|
+
check();
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
spawn(workspace) {
|
|
118
|
+
const child = spawn("pnpm", ["--filter", workspace.name, "run", "dev"], {
|
|
119
|
+
cwd: this.root,
|
|
120
|
+
stdio: "pipe",
|
|
121
|
+
env: { ...process.env, FORCE_COLOR: "1" }
|
|
122
|
+
});
|
|
123
|
+
this.children.set(workspace.name, child);
|
|
124
|
+
this.setStatus(workspace.name, "building");
|
|
125
|
+
let buffer = "";
|
|
126
|
+
const onData = (data) => {
|
|
127
|
+
buffer += data.toString();
|
|
128
|
+
const lines = buffer.split("\n");
|
|
129
|
+
buffer = lines.pop();
|
|
130
|
+
for (const raw of lines) {
|
|
131
|
+
const line = raw.trimEnd();
|
|
132
|
+
if (line) this.handleLine(workspace.name, line);
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
child.stdout?.on("data", onData);
|
|
136
|
+
child.stderr?.on("data", onData);
|
|
137
|
+
child.on("close", (code, signal) => {
|
|
138
|
+
if (buffer.trim()) this.handleLine(workspace.name, buffer.trimEnd());
|
|
139
|
+
buffer = "";
|
|
140
|
+
if (this.stopping) return;
|
|
141
|
+
if (signal === "SIGABRT") {
|
|
142
|
+
this.handleCrash(workspace);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
this.setStatus(workspace.name, code === 0 ? "stopped" : "error");
|
|
146
|
+
});
|
|
147
|
+
child.on("error", () => this.setStatus(workspace.name, "error"));
|
|
148
|
+
}
|
|
149
|
+
handleLine(name, line) {
|
|
150
|
+
const proc = this.state.get(name);
|
|
151
|
+
if (!proc) return;
|
|
152
|
+
proc.logs.push(line);
|
|
153
|
+
if (proc.logs.length > MAX_LOGS) proc.logs.splice(0, proc.logs.length - MAX_LOGS);
|
|
154
|
+
const { status, url } = parseLine(stripAnsi(line));
|
|
155
|
+
if (status) {
|
|
156
|
+
if (status === "error") {
|
|
157
|
+
this.scheduleErrorRecovery(name);
|
|
158
|
+
} else {
|
|
159
|
+
this.lastGoodStatus.set(name, status);
|
|
160
|
+
this.clearErrorTimer(name);
|
|
161
|
+
}
|
|
162
|
+
proc.status = status;
|
|
163
|
+
}
|
|
164
|
+
if (url) proc.url = url;
|
|
165
|
+
this.emit("change");
|
|
166
|
+
}
|
|
167
|
+
handleCrash(workspace) {
|
|
168
|
+
const retries = (this.crashRetries.get(workspace.name) ?? 0) + 1;
|
|
169
|
+
this.crashRetries.set(workspace.name, retries);
|
|
170
|
+
const proc = this.state.get(workspace.name);
|
|
171
|
+
if (retries <= MAX_CRASH_RETRIES) {
|
|
172
|
+
if (proc) {
|
|
173
|
+
proc.logs.push(
|
|
174
|
+
`[hlidskjalf] fsevents crash detected (attempt ${retries}/${MAX_CRASH_RETRIES}) \u2014 rebuilding...`
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
this.emit("change");
|
|
178
|
+
this.rebuildFsevents().then(() => {
|
|
179
|
+
if (!this.stopping) this.spawn(workspace);
|
|
180
|
+
}).catch(() => this.setStatus(workspace.name, "error"));
|
|
181
|
+
} else {
|
|
182
|
+
if (proc) {
|
|
183
|
+
proc.logs.push(
|
|
184
|
+
`[hlidskjalf] fsevents still crashing after ${MAX_CRASH_RETRIES} attempts \u2014 giving up.`
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
this.setStatus(workspace.name, "error");
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
rebuildFsevents() {
|
|
191
|
+
return new Promise((resolve) => {
|
|
192
|
+
const child = spawn("pnpm", ["rebuild", "fsevents"], {
|
|
193
|
+
cwd: this.root,
|
|
194
|
+
stdio: "pipe"
|
|
195
|
+
});
|
|
196
|
+
this.pendingRebuilds.add(child);
|
|
197
|
+
const done = () => {
|
|
198
|
+
this.pendingRebuilds.delete(child);
|
|
199
|
+
resolve();
|
|
200
|
+
};
|
|
201
|
+
child.on("close", done);
|
|
202
|
+
child.on("error", done);
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
scheduleErrorRecovery(name) {
|
|
206
|
+
this.clearErrorTimer(name);
|
|
207
|
+
const timer = setTimeout(() => {
|
|
208
|
+
this.errorTimers.delete(name);
|
|
209
|
+
const proc = this.state.get(name);
|
|
210
|
+
if (proc?.status === "error") {
|
|
211
|
+
this.setStatus(name, this.lastGoodStatus.get(name) ?? "ready");
|
|
212
|
+
}
|
|
213
|
+
}, ERROR_RECOVERY_MS);
|
|
214
|
+
timer.unref();
|
|
215
|
+
this.errorTimers.set(name, timer);
|
|
216
|
+
}
|
|
217
|
+
clearErrorTimer(name) {
|
|
218
|
+
const timer = this.errorTimers.get(name);
|
|
219
|
+
if (timer) {
|
|
220
|
+
clearTimeout(timer);
|
|
221
|
+
this.errorTimers.delete(name);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
setStatus(name, status) {
|
|
225
|
+
const proc = this.state.get(name);
|
|
226
|
+
if (!proc) return;
|
|
227
|
+
proc.status = status;
|
|
228
|
+
this.emit("change");
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
function createRunner(root) {
|
|
232
|
+
return new ProcessRunner(root);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// src/views/dashboard.tsx
|
|
236
|
+
import { Box, Text, useStdout } from "ink";
|
|
237
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
238
|
+
var kindLabel = {
|
|
239
|
+
package: "pkg",
|
|
240
|
+
app: "app"
|
|
241
|
+
};
|
|
242
|
+
var statusDisplay = {
|
|
243
|
+
pending: { color: "gray", label: "pending" },
|
|
244
|
+
building: { color: "yellow", label: "building" },
|
|
245
|
+
watching: { color: "green", label: "watching" },
|
|
246
|
+
ready: { color: "green", label: "ready" },
|
|
247
|
+
error: { color: "red", label: "error" },
|
|
248
|
+
stopped: { color: "gray", label: "stopped" }
|
|
249
|
+
};
|
|
250
|
+
var HINTS = "\u2191/\u2193 j/k select q quit";
|
|
251
|
+
function Dashboard({ processes, selectedIndex }) {
|
|
252
|
+
const { stdout } = useStdout();
|
|
253
|
+
const cols = stdout?.columns ?? 80;
|
|
254
|
+
const rows = stdout?.rows ?? 24;
|
|
255
|
+
const allReady = processes.length > 0 && processes.every((p) => p.status === "ready" || p.status === "watching");
|
|
256
|
+
const nameWidth = Math.max(14, ...processes.map((p) => p.workspace.name.length + 2));
|
|
257
|
+
const logHeight = Math.max(3, rows - processes.length - 5);
|
|
258
|
+
const safeIndex = Math.min(selectedIndex, Math.max(0, processes.length - 1));
|
|
259
|
+
const selected = processes[safeIndex];
|
|
260
|
+
const logLines = selected?.logs.slice(-logHeight) ?? [];
|
|
261
|
+
const showHints = cols >= 10 + HINTS.length + 4;
|
|
262
|
+
return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
|
|
263
|
+
/* @__PURE__ */ jsxs(Box, { children: [
|
|
264
|
+
/* @__PURE__ */ jsxs(Box, { flexGrow: 1, children: [
|
|
265
|
+
/* @__PURE__ */ jsx(Text, { color: allReady ? "yellow" : "gray", children: allReady ? "\u{1F3D4}" : "\u25E6" }),
|
|
266
|
+
/* @__PURE__ */ jsx(Text, { bold: true, children: allReady ? " Hlidskjalf" : " Hlidskjalf" })
|
|
267
|
+
] }),
|
|
268
|
+
showHints && /* @__PURE__ */ jsx(Text, { dimColor: true, children: HINTS })
|
|
269
|
+
] }),
|
|
270
|
+
/* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u2500".repeat(cols) }),
|
|
271
|
+
/* @__PURE__ */ jsxs(Box, { marginLeft: 1, children: [
|
|
272
|
+
/* @__PURE__ */ jsx(Box, { width: nameWidth, children: /* @__PURE__ */ jsx(Text, { dimColor: true, bold: true, children: "Name" }) }),
|
|
273
|
+
/* @__PURE__ */ jsx(Box, { width: 6, children: /* @__PURE__ */ jsx(Text, { dimColor: true, bold: true, children: "Kind" }) }),
|
|
274
|
+
/* @__PURE__ */ jsx(Box, { width: 14, children: /* @__PURE__ */ jsx(Text, { dimColor: true, bold: true, children: "Status" }) }),
|
|
275
|
+
/* @__PURE__ */ jsx(Text, { dimColor: true, bold: true, children: "URL" })
|
|
276
|
+
] }),
|
|
277
|
+
processes.map((proc, i) => {
|
|
278
|
+
const isSelected = i === safeIndex;
|
|
279
|
+
const { color, label } = statusDisplay[proc.status];
|
|
280
|
+
return /* @__PURE__ */ jsxs(Box, { children: [
|
|
281
|
+
/* @__PURE__ */ jsx(Text, { color: isSelected ? "cyan" : void 0, children: isSelected ? "\u25B8" : " " }),
|
|
282
|
+
/* @__PURE__ */ jsx(Box, { width: nameWidth, children: /* @__PURE__ */ jsx(Text, { color: isSelected ? "cyan" : void 0, bold: isSelected, wrap: "truncate", children: proc.workspace.name }) }),
|
|
283
|
+
/* @__PURE__ */ jsx(Box, { width: 6, children: /* @__PURE__ */ jsx(Text, { dimColor: true, children: kindLabel[proc.workspace.kind] }) }),
|
|
284
|
+
/* @__PURE__ */ jsx(Box, { width: 14, children: /* @__PURE__ */ jsxs(Text, { color, children: [
|
|
285
|
+
"\u25CF ",
|
|
286
|
+
label
|
|
287
|
+
] }) }),
|
|
288
|
+
/* @__PURE__ */ jsx(Text, { dimColor: true, children: proc.url ?? "" })
|
|
289
|
+
] }, proc.workspace.name);
|
|
290
|
+
}),
|
|
291
|
+
/* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u2500".repeat(cols) }),
|
|
292
|
+
selected && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
|
|
293
|
+
/* @__PURE__ */ jsx(Box, { marginLeft: 1, children: /* @__PURE__ */ jsxs(Text, { bold: true, children: [
|
|
294
|
+
"Logs: ",
|
|
295
|
+
selected.workspace.name
|
|
296
|
+
] }) }),
|
|
297
|
+
logLines.map((line, i) => (
|
|
298
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: log lines have no stable identity
|
|
299
|
+
/* @__PURE__ */ jsxs(Text, { wrap: "truncate", children: [
|
|
300
|
+
" ",
|
|
301
|
+
line
|
|
302
|
+
] }, i)
|
|
303
|
+
)),
|
|
304
|
+
Array.from({ length: logHeight - logLines.length }, (_, i) => (
|
|
305
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: fill lines have no stable identity
|
|
306
|
+
/* @__PURE__ */ jsx(Text, { children: " " }, `fill-${i}`)
|
|
307
|
+
))
|
|
308
|
+
] })
|
|
309
|
+
] });
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// src/views/loading.tsx
|
|
313
|
+
import { Box as Box2, Text as Text2, useStdout as useStdout2 } from "ink";
|
|
314
|
+
import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
315
|
+
function Loading() {
|
|
316
|
+
const { stdout } = useStdout2();
|
|
317
|
+
const cols = stdout?.columns ?? 80;
|
|
318
|
+
return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", children: [
|
|
319
|
+
/* @__PURE__ */ jsxs2(Box2, { children: [
|
|
320
|
+
/* @__PURE__ */ jsx2(Text2, { color: "gray", children: "\u25E6 " }),
|
|
321
|
+
/* @__PURE__ */ jsx2(Text2, { bold: true, children: "Hlidskjalf" })
|
|
322
|
+
] }),
|
|
323
|
+
/* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "\u2500".repeat(cols) }),
|
|
324
|
+
/* @__PURE__ */ jsx2(Box2, { marginTop: 1, marginLeft: 1, children: /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "Discovering workspaces..." }) })
|
|
325
|
+
] });
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// src/workspaces.ts
|
|
329
|
+
import { existsSync, readdirSync, readFileSync } from "fs";
|
|
330
|
+
import { join } from "path";
|
|
331
|
+
function readJson(path) {
|
|
332
|
+
try {
|
|
333
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
334
|
+
} catch {
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
function workspaceDeps(pkg) {
|
|
339
|
+
return Object.entries(pkg.dependencies ?? {}).filter(([, v]) => v.startsWith("workspace:")).map(([name]) => name);
|
|
340
|
+
}
|
|
341
|
+
var kindOrder = { package: 0, app: 1 };
|
|
342
|
+
function discover(root) {
|
|
343
|
+
const results = [];
|
|
344
|
+
for (const dir of ["packages", "apps"]) {
|
|
345
|
+
const base = join(root, dir);
|
|
346
|
+
const kind = dir === "apps" ? "app" : "package";
|
|
347
|
+
if (!existsSync(base)) continue;
|
|
348
|
+
for (const entry of readdirSync(base, { withFileTypes: true })) {
|
|
349
|
+
if (!entry.isDirectory()) continue;
|
|
350
|
+
const pkg = readJson(join(base, entry.name, "package.json"));
|
|
351
|
+
if (!pkg?.name) continue;
|
|
352
|
+
if (pkg.name === "hlidskjalf") continue;
|
|
353
|
+
if (!pkg.scripts?.dev) continue;
|
|
354
|
+
results.push({
|
|
355
|
+
name: pkg.name,
|
|
356
|
+
kind,
|
|
357
|
+
deps: workspaceDeps(pkg)
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return results;
|
|
362
|
+
}
|
|
363
|
+
function sortByDeps(workspaces) {
|
|
364
|
+
const names = new Set(workspaces.map((w) => w.name));
|
|
365
|
+
return [...workspaces].sort((a, b) => {
|
|
366
|
+
if (a.kind !== b.kind) return kindOrder[a.kind] - kindOrder[b.kind];
|
|
367
|
+
const aDeps = a.deps.filter((d) => names.has(d)).length;
|
|
368
|
+
const bDeps = b.deps.filter((d) => names.has(d)).length;
|
|
369
|
+
return aDeps - bDeps;
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
function sortByName(workspaces) {
|
|
373
|
+
return [...workspaces].sort((a, b) => {
|
|
374
|
+
if (a.kind !== b.kind) return kindOrder[a.kind] - kindOrder[b.kind];
|
|
375
|
+
return a.name.localeCompare(b.name);
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
function filterWorkspaces(workspaces, patterns) {
|
|
379
|
+
const byName = new Map(workspaces.map((w) => [w.name, w]));
|
|
380
|
+
const matches = /* @__PURE__ */ new Set();
|
|
381
|
+
for (const pattern of patterns) {
|
|
382
|
+
const transitive = pattern.endsWith("...");
|
|
383
|
+
const name = transitive ? pattern.slice(0, -3) : pattern;
|
|
384
|
+
if (byName.has(name)) matches.add(name);
|
|
385
|
+
if (transitive) collectDeps(name, byName, matches);
|
|
386
|
+
}
|
|
387
|
+
return workspaces.filter((w) => matches.has(w.name));
|
|
388
|
+
}
|
|
389
|
+
function collectDeps(name, byName, collected) {
|
|
390
|
+
const workspace = byName.get(name);
|
|
391
|
+
if (!workspace) return;
|
|
392
|
+
for (const dep of workspace.deps) {
|
|
393
|
+
if (byName.has(dep) && !collected.has(dep)) {
|
|
394
|
+
collected.add(dep);
|
|
395
|
+
collectDeps(dep, byName, collected);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// src/app.tsx
|
|
401
|
+
import { jsx as jsx3 } from "react/jsx-runtime";
|
|
402
|
+
function App({ options }) {
|
|
403
|
+
const { exit } = useApp();
|
|
404
|
+
const [loading, setLoading] = useState(true);
|
|
405
|
+
const [processes, setProcesses] = useState([]);
|
|
406
|
+
const [cursor, setCursor] = useState(0);
|
|
407
|
+
const runnerRef = useRef(null);
|
|
408
|
+
const stoppingRef = useRef(false);
|
|
409
|
+
const stop = useCallback(() => {
|
|
410
|
+
if (stoppingRef.current) return;
|
|
411
|
+
stoppingRef.current = true;
|
|
412
|
+
const runner = runnerRef.current;
|
|
413
|
+
if (runner) {
|
|
414
|
+
void runner.shutdown().finally(() => exit());
|
|
415
|
+
} else {
|
|
416
|
+
exit();
|
|
417
|
+
}
|
|
418
|
+
}, [exit]);
|
|
419
|
+
useEffect(() => {
|
|
420
|
+
const run = async () => {
|
|
421
|
+
let workspaces = discover(options.root);
|
|
422
|
+
if (options.filter) {
|
|
423
|
+
workspaces = filterWorkspaces(workspaces, options.filter);
|
|
424
|
+
}
|
|
425
|
+
if (workspaces.length === 0) {
|
|
426
|
+
console.error("No matching workspaces found.");
|
|
427
|
+
exit();
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
const startOrder = sortByDeps(workspaces);
|
|
431
|
+
const sorted = options.order === "run" ? startOrder : sortByName(workspaces);
|
|
432
|
+
const displayOrder = sorted.map((w) => w.name);
|
|
433
|
+
const runner = createRunner(options.root);
|
|
434
|
+
runnerRef.current = runner;
|
|
435
|
+
setProcesses(sorted.map((w) => ({ workspace: w, status: "pending", logs: [] })));
|
|
436
|
+
runner.on("change", () => {
|
|
437
|
+
setProcesses(
|
|
438
|
+
displayOrder.flatMap((name) => {
|
|
439
|
+
const p = runner.get(name);
|
|
440
|
+
return p ? [p] : [];
|
|
441
|
+
})
|
|
442
|
+
);
|
|
443
|
+
});
|
|
444
|
+
setLoading(false);
|
|
445
|
+
await runner.start(startOrder);
|
|
446
|
+
};
|
|
447
|
+
run().catch((err) => {
|
|
448
|
+
console.error("Fatal:", err);
|
|
449
|
+
exit();
|
|
450
|
+
});
|
|
451
|
+
process.on("SIGTERM", stop);
|
|
452
|
+
return () => {
|
|
453
|
+
process.off("SIGTERM", stop);
|
|
454
|
+
};
|
|
455
|
+
}, [exit, options.filter, options.order, options.root, stop]);
|
|
456
|
+
useInput((input, key) => {
|
|
457
|
+
if (loading) return;
|
|
458
|
+
if (input === "q" || key.ctrl && input === "c") {
|
|
459
|
+
stop();
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
if (key.upArrow || input === "k") {
|
|
463
|
+
setCursor((i) => Math.max(0, i - 1));
|
|
464
|
+
} else if (key.downArrow || input === "j") {
|
|
465
|
+
setCursor((i) => Math.min(processes.length - 1, i + 1));
|
|
466
|
+
}
|
|
467
|
+
});
|
|
468
|
+
if (loading) return /* @__PURE__ */ jsx3(Loading, {});
|
|
469
|
+
return /* @__PURE__ */ jsx3(Dashboard, { processes, selectedIndex: cursor });
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// src/index.tsx
|
|
473
|
+
import { jsx as jsx4 } from "react/jsx-runtime";
|
|
474
|
+
function parseArgs(argv) {
|
|
475
|
+
const root = process.cwd();
|
|
476
|
+
const filter = [];
|
|
477
|
+
let order = "alphabetical";
|
|
478
|
+
for (const arg of argv) {
|
|
479
|
+
if (arg.startsWith("--filter=")) {
|
|
480
|
+
const value = arg.slice("--filter=".length).replace(/^\{(.+)\}$/, "$1");
|
|
481
|
+
filter.push(value);
|
|
482
|
+
} else if (arg.startsWith("--order=")) {
|
|
483
|
+
const value = arg.slice("--order=".length);
|
|
484
|
+
if (value === "run" || value === "alphabetical") order = value;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
return { root, order, filter: filter.length > 0 ? filter : void 0 };
|
|
488
|
+
}
|
|
489
|
+
var { waitUntilExit } = render(/* @__PURE__ */ jsx4(App, { options: parseArgs(process.argv.slice(2)) }), {
|
|
490
|
+
exitOnCtrlC: false
|
|
491
|
+
});
|
|
492
|
+
await waitUntilExit();
|
|
493
|
+
process.exit(0);
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hlidskjalf",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Terminal UI for monitoring Turborepo workspaces",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"turborepo",
|
|
9
|
+
"monorepo",
|
|
10
|
+
"terminal",
|
|
11
|
+
"tui",
|
|
12
|
+
"dev",
|
|
13
|
+
"pnpm"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=22"
|
|
17
|
+
},
|
|
18
|
+
"bin": {
|
|
19
|
+
"hlidskjalf": "dist/index.js"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsup",
|
|
26
|
+
"dev": "tsup --watch",
|
|
27
|
+
"lint": "biome check --config-path=../.. .",
|
|
28
|
+
"lint:fix": "biome check --config-path=../.. --write .",
|
|
29
|
+
"format": "biome format --config-path=../.. --write ."
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"ink": "^5.2.1",
|
|
33
|
+
"react": "^18.3.1"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/node": "^22.15.33",
|
|
37
|
+
"@types/react": "^18.3.23",
|
|
38
|
+
"tsup": "^8.5.1",
|
|
39
|
+
"typescript": "^5.9.3"
|
|
40
|
+
}
|
|
41
|
+
}
|