nightytidy 0.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.
- package/LICENSE +21 -0
- package/README.md +314 -0
- package/bin/nightytidy.js +3 -0
- package/package.json +55 -0
- package/src/checks.js +367 -0
- package/src/claude.js +655 -0
- package/src/cli.js +1012 -0
- package/src/consolidation.js +81 -0
- package/src/dashboard-html.js +496 -0
- package/src/dashboard-standalone.js +167 -0
- package/src/dashboard-tui.js +208 -0
- package/src/dashboard.js +427 -0
- package/src/env.js +100 -0
- package/src/executor.js +550 -0
- package/src/git.js +348 -0
- package/src/lock.js +186 -0
- package/src/logger.js +111 -0
- package/src/notifications.js +33 -0
- package/src/orchestrator.js +919 -0
- package/src/prompts/loader.js +55 -0
- package/src/prompts/manifest.json +138 -0
- package/src/prompts/specials/changelog.md +28 -0
- package/src/prompts/specials/consolidation.md +61 -0
- package/src/prompts/specials/doc-update.md +1 -0
- package/src/prompts/specials/report.md +95 -0
- package/src/prompts/steps/01-documentation.md +173 -0
- package/src/prompts/steps/02-test-coverage.md +181 -0
- package/src/prompts/steps/03-test-hardening.md +181 -0
- package/src/prompts/steps/04-test-architecture.md +130 -0
- package/src/prompts/steps/05-test-consolidation.md +165 -0
- package/src/prompts/steps/06-test-quality.md +211 -0
- package/src/prompts/steps/07-api-design.md +165 -0
- package/src/prompts/steps/08-security-sweep.md +207 -0
- package/src/prompts/steps/09-dependency-health.md +217 -0
- package/src/prompts/steps/10-codebase-cleanup.md +189 -0
- package/src/prompts/steps/11-crosscutting-concerns.md +196 -0
- package/src/prompts/steps/12-file-decomposition.md +263 -0
- package/src/prompts/steps/13-code-elegance.md +329 -0
- package/src/prompts/steps/14-architectural-complexity.md +297 -0
- package/src/prompts/steps/15-type-safety.md +192 -0
- package/src/prompts/steps/16-logging-error-message.md +173 -0
- package/src/prompts/steps/17-data-integrity.md +139 -0
- package/src/prompts/steps/18-performance.md +183 -0
- package/src/prompts/steps/19-cost-resource-optimization.md +136 -0
- package/src/prompts/steps/20-error-recovery.md +145 -0
- package/src/prompts/steps/21-race-condition-audit.md +178 -0
- package/src/prompts/steps/22-bug-hunt.md +229 -0
- package/src/prompts/steps/23-frontend-quality.md +210 -0
- package/src/prompts/steps/24-uiux-audit.md +284 -0
- package/src/prompts/steps/25-state-management.md +170 -0
- package/src/prompts/steps/26-perceived-performance.md +190 -0
- package/src/prompts/steps/27-devops.md +165 -0
- package/src/prompts/steps/28-scheduled-job-chron-jobs.md +141 -0
- package/src/prompts/steps/29-observability.md +152 -0
- package/src/prompts/steps/30-backup-check.md +155 -0
- package/src/prompts/steps/31-product-polish-ux-friction.md +122 -0
- package/src/prompts/steps/32-feature-discovery-opportunity.md +128 -0
- package/src/prompts/steps/33-strategic-opportunities.md +217 -0
- package/src/report.js +540 -0
- package/src/setup.js +133 -0
- package/src/sync.js +536 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// Standalone dashboard HTTP server for orchestrator mode.
|
|
4
|
+
// Spawned as a detached process by orchestrator.js during --init-run.
|
|
5
|
+
// Polls nightytidy-progress.json for state updates and serves the
|
|
6
|
+
// browser dashboard with SSE push. Killed by --finish-run via PID.
|
|
7
|
+
|
|
8
|
+
import { createServer } from 'http';
|
|
9
|
+
import { readFileSync, writeFileSync } from 'fs';
|
|
10
|
+
import { randomBytes } from 'crypto';
|
|
11
|
+
import { getHTML } from './dashboard-html.js';
|
|
12
|
+
|
|
13
|
+
const POLL_INTERVAL = 500;
|
|
14
|
+
const MAX_BODY_BYTES = 1024; // 1 KB — stop endpoint only needs a small JSON body
|
|
15
|
+
|
|
16
|
+
const projectDir = process.argv[2];
|
|
17
|
+
if (!projectDir) {
|
|
18
|
+
process.stderr.write('Usage: dashboard-standalone.js <projectDir>\n');
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const progressPath = `${projectDir}/nightytidy-progress.json`;
|
|
23
|
+
const urlFilePath = `${projectDir}/nightytidy-dashboard.url`;
|
|
24
|
+
const csrfToken = randomBytes(16).toString('hex');
|
|
25
|
+
|
|
26
|
+
let currentState = null;
|
|
27
|
+
let lastRawJson = '';
|
|
28
|
+
let lastOutputLength = 0;
|
|
29
|
+
let lastStepName = '';
|
|
30
|
+
const sseClients = new Set();
|
|
31
|
+
let pollIntervalId = null;
|
|
32
|
+
|
|
33
|
+
const SECURITY_HEADERS = {
|
|
34
|
+
'X-Content-Type-Options': 'nosniff',
|
|
35
|
+
'X-Frame-Options': 'DENY',
|
|
36
|
+
'Content-Security-Policy': "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function pollProgress() {
|
|
40
|
+
try {
|
|
41
|
+
const raw = readFileSync(progressPath, 'utf8');
|
|
42
|
+
|
|
43
|
+
// Only push if file content actually changed (avoids redundant JSON.parse + stringify)
|
|
44
|
+
if (raw === lastRawJson) return;
|
|
45
|
+
lastRawJson = raw;
|
|
46
|
+
|
|
47
|
+
const state = JSON.parse(raw);
|
|
48
|
+
currentState = state;
|
|
49
|
+
|
|
50
|
+
// Reset output tracking when step changes
|
|
51
|
+
if (state.currentStepName !== lastStepName) {
|
|
52
|
+
lastOutputLength = 0;
|
|
53
|
+
lastStepName = state.currentStepName || '';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Send only new output as SSE output event
|
|
57
|
+
if (state.currentStepOutput && state.currentStepOutput.length > lastOutputLength) {
|
|
58
|
+
const newChunk = state.currentStepOutput.slice(lastOutputLength);
|
|
59
|
+
lastOutputLength = state.currentStepOutput.length;
|
|
60
|
+
const outputPayload = `event: output\ndata: ${JSON.stringify(newChunk)}\n\n`;
|
|
61
|
+
for (const client of sseClients) {
|
|
62
|
+
try { client.write(outputPayload); } catch { sseClients.delete(client); }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const payload = `event: state\ndata: ${raw}\n\n`;
|
|
67
|
+
for (const client of sseClients) {
|
|
68
|
+
try { client.write(payload); } catch { sseClients.delete(client); }
|
|
69
|
+
}
|
|
70
|
+
} catch { /* file being written or invalid — skip this tick */ }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function handleRequest(req, res) {
|
|
74
|
+
if (req.method === 'GET' && req.url === '/') {
|
|
75
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', ...SECURITY_HEADERS });
|
|
76
|
+
res.end(getHTML(csrfToken));
|
|
77
|
+
} else if (req.method === 'GET' && req.url === '/events') {
|
|
78
|
+
res.writeHead(200, {
|
|
79
|
+
'Content-Type': 'text/event-stream',
|
|
80
|
+
'Cache-Control': 'no-cache',
|
|
81
|
+
'Connection': 'keep-alive',
|
|
82
|
+
});
|
|
83
|
+
if (currentState) {
|
|
84
|
+
res.write(`event: state\ndata: ${JSON.stringify(currentState)}\n\n`);
|
|
85
|
+
}
|
|
86
|
+
sseClients.add(res);
|
|
87
|
+
res.on('close', () => sseClients.delete(res));
|
|
88
|
+
} else if (req.method === 'POST' && req.url === '/stop') {
|
|
89
|
+
// CSRF-protected stop endpoint — no-op in orchestrator mode (abort is handled externally)
|
|
90
|
+
let body = '';
|
|
91
|
+
req.on('data', chunk => {
|
|
92
|
+
body += chunk;
|
|
93
|
+
if (body.length > MAX_BODY_BYTES) {
|
|
94
|
+
req.destroy();
|
|
95
|
+
res.writeHead(413, { 'Content-Type': 'application/json', ...SECURITY_HEADERS });
|
|
96
|
+
res.end(JSON.stringify({ error: 'Request body too large' }));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
req.on('end', () => {
|
|
101
|
+
try {
|
|
102
|
+
const parsed = JSON.parse(body || '{}');
|
|
103
|
+
if (parsed.token !== csrfToken) {
|
|
104
|
+
res.writeHead(403, { 'Content-Type': 'application/json', ...SECURITY_HEADERS });
|
|
105
|
+
res.end(JSON.stringify({ error: 'Invalid token' }));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
res.writeHead(403, { 'Content-Type': 'application/json', ...SECURITY_HEADERS });
|
|
110
|
+
res.end(JSON.stringify({ error: 'Invalid token' }));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
res.writeHead(200, { 'Content-Type': 'application/json', ...SECURITY_HEADERS });
|
|
114
|
+
res.end(JSON.stringify({ ok: true, message: 'Stop not supported in orchestrator mode' }));
|
|
115
|
+
});
|
|
116
|
+
} else {
|
|
117
|
+
res.writeHead(404, { 'Content-Type': 'text/plain', ...SECURITY_HEADERS });
|
|
118
|
+
res.end('Not found');
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const server = createServer(handleRequest);
|
|
123
|
+
|
|
124
|
+
// Prevent slow/malicious clients from holding connections indefinitely.
|
|
125
|
+
// SSE connections excluded by design (headers written immediately, stay open).
|
|
126
|
+
server.requestTimeout = 30_000; // 30s — max time for entire request
|
|
127
|
+
server.headersTimeout = 15_000; // 15s — max time to receive headers
|
|
128
|
+
|
|
129
|
+
server.listen(0, '127.0.0.1', () => {
|
|
130
|
+
const port = server.address().port;
|
|
131
|
+
const url = `http://localhost:${port}`;
|
|
132
|
+
|
|
133
|
+
try { writeFileSync(urlFilePath, url + '\n', 'utf8'); } catch { /* non-critical */ }
|
|
134
|
+
|
|
135
|
+
// Pre-populate currentState from progress file so SSE clients connecting
|
|
136
|
+
// before the first poll tick get immediate data instead of a blank dashboard.
|
|
137
|
+
try {
|
|
138
|
+
const raw = readFileSync(progressPath, 'utf8');
|
|
139
|
+
currentState = JSON.parse(raw);
|
|
140
|
+
lastRawJson = raw;
|
|
141
|
+
} catch { /* file may not exist yet — first poll will pick it up */ }
|
|
142
|
+
|
|
143
|
+
// Write port to stdout so the spawning process can capture it
|
|
144
|
+
process.stdout.write(JSON.stringify({ port, url, pid: process.pid }) + '\n');
|
|
145
|
+
|
|
146
|
+
pollIntervalId = setInterval(pollProgress, POLL_INTERVAL);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
server.on('error', (err) => {
|
|
150
|
+
process.stderr.write(`Dashboard server error: ${err.message}\n`);
|
|
151
|
+
process.exit(1);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Graceful shutdown with force-exit safety net.
|
|
155
|
+
// server.close() waits for all connections to drain, but SSE connections
|
|
156
|
+
// never close on their own. The 10s timeout guarantees termination even
|
|
157
|
+
// if client.end() fails silently or new connections arrive after cleanup.
|
|
158
|
+
const SHUTDOWN_FORCE_EXIT_MS = 10_000;
|
|
159
|
+
|
|
160
|
+
process.on('SIGTERM', () => {
|
|
161
|
+
if (pollIntervalId) clearInterval(pollIntervalId);
|
|
162
|
+
for (const client of sseClients) { try { client.end(); } catch { /* ignore */ } }
|
|
163
|
+
sseClients.clear();
|
|
164
|
+
const forceTimer = setTimeout(() => process.exit(0), SHUTDOWN_FORCE_EXIT_MS);
|
|
165
|
+
forceTimer.unref();
|
|
166
|
+
server.close(() => process.exit(0));
|
|
167
|
+
});
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// Standalone TUI progress display for NightyTidy.
|
|
4
|
+
// Spawned in a separate terminal window by dashboard.js.
|
|
5
|
+
// Reads nightytidy-progress.json and renders a live terminal UI.
|
|
6
|
+
//
|
|
7
|
+
// Usage: node dashboard-tui.js <path-to-progress.json>
|
|
8
|
+
|
|
9
|
+
import { readFileSync } from 'fs';
|
|
10
|
+
import chalk from 'chalk';
|
|
11
|
+
|
|
12
|
+
const POLL_INTERVAL = 1000;
|
|
13
|
+
const EXIT_DELAY = 5000;
|
|
14
|
+
const BAR_WIDTH = 30;
|
|
15
|
+
const MAX_VISIBLE_STEPS = 16;
|
|
16
|
+
const MAX_OUTPUT_LINES = 20;
|
|
17
|
+
|
|
18
|
+
// Module-level state
|
|
19
|
+
let progressFilePath = null;
|
|
20
|
+
let lastJson = '';
|
|
21
|
+
|
|
22
|
+
function readState() {
|
|
23
|
+
if (!progressFilePath) return null;
|
|
24
|
+
try {
|
|
25
|
+
const raw = readFileSync(progressFilePath, 'utf8');
|
|
26
|
+
if (raw === lastJson) return null; // no change
|
|
27
|
+
lastJson = raw;
|
|
28
|
+
return JSON.parse(raw);
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function formatMs(ms) {
|
|
35
|
+
if (!Number.isFinite(ms) || ms < 0) return '0s';
|
|
36
|
+
|
|
37
|
+
const s = Math.floor(ms / 1000);
|
|
38
|
+
if (s < 60) return `${s}s`;
|
|
39
|
+
const m = Math.floor(s / 60);
|
|
40
|
+
if (m < 60) return `${m}m ${String(s % 60).padStart(2, '0')}s`;
|
|
41
|
+
const h = Math.floor(m / 60);
|
|
42
|
+
return `${h}h ${String(m % 60).padStart(2, '0')}m`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function progressBar(done, total, hasActive = false) {
|
|
46
|
+
const effective = hasActive ? done + 0.5 : done;
|
|
47
|
+
const pct = total > 0 ? effective / total : 0;
|
|
48
|
+
const filled = Math.round(pct * BAR_WIDTH);
|
|
49
|
+
const empty = BAR_WIDTH - filled;
|
|
50
|
+
const bar = chalk.cyan('\u2588'.repeat(filled)) + chalk.dim('\u2591'.repeat(empty));
|
|
51
|
+
const label = `${done}/${total} (${Math.round(pct * 100)}%)`;
|
|
52
|
+
return `${bar} ${label}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const STATUS_COLORS = {
|
|
56
|
+
starting: chalk.blue,
|
|
57
|
+
running: chalk.blue,
|
|
58
|
+
finishing: chalk.cyan,
|
|
59
|
+
completed: chalk.green,
|
|
60
|
+
stopped: chalk.yellow,
|
|
61
|
+
error: chalk.red,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
function statusColor(status) {
|
|
65
|
+
const colorFn = STATUS_COLORS[status] || chalk.white;
|
|
66
|
+
return colorFn(status.charAt(0).toUpperCase() + status.slice(1));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function stepIcon(status) {
|
|
70
|
+
if (status === 'completed') return chalk.green('\u2713');
|
|
71
|
+
if (status === 'skipped') return chalk.dim('\u23ed');
|
|
72
|
+
if (status === 'failed') return chalk.red('\u2717');
|
|
73
|
+
if (status === 'running') return chalk.cyan('\u23f3');
|
|
74
|
+
return chalk.dim('\u25cb');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function render(state) {
|
|
78
|
+
const lines = [];
|
|
79
|
+
|
|
80
|
+
// Header
|
|
81
|
+
lines.push('');
|
|
82
|
+
lines.push(chalk.cyan.bold(' NightyTidy \u2014 Live Progress'));
|
|
83
|
+
lines.push('');
|
|
84
|
+
|
|
85
|
+
// Status + elapsed
|
|
86
|
+
const elapsed = state.startTime ? formatMs(Date.now() - state.startTime) : '0s';
|
|
87
|
+
lines.push(` Status: ${statusColor(state.status)} Elapsed: ${chalk.white(elapsed)}`);
|
|
88
|
+
lines.push('');
|
|
89
|
+
|
|
90
|
+
// Progress bar
|
|
91
|
+
const done = state.completedCount + state.failedCount;
|
|
92
|
+
const hasActive = state.status === 'running' && state.currentStepIndex >= 0;
|
|
93
|
+
lines.push(` ${progressBar(done, state.totalSteps, hasActive)}`);
|
|
94
|
+
lines.push('');
|
|
95
|
+
|
|
96
|
+
// Step list
|
|
97
|
+
const steps = state.steps || [];
|
|
98
|
+
const visible = steps.slice(0, MAX_VISIBLE_STEPS);
|
|
99
|
+
|
|
100
|
+
for (const step of visible) {
|
|
101
|
+
const icon = stepIcon(step.status);
|
|
102
|
+
const dur = step.duration ? chalk.dim(` ${formatMs(step.duration)}`) : '';
|
|
103
|
+
const running = step.status === 'running' ? chalk.dim(' \u2190 running') : '';
|
|
104
|
+
lines.push(` ${icon} ${step.number}. ${step.name}${dur}${running}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (steps.length > MAX_VISIBLE_STEPS) {
|
|
108
|
+
const remaining = steps.length - MAX_VISIBLE_STEPS;
|
|
109
|
+
lines.push(chalk.dim(` ... (${remaining} more)`));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
lines.push('');
|
|
113
|
+
|
|
114
|
+
// Claude output panel
|
|
115
|
+
if (state.currentStepOutput && state.status === 'running') {
|
|
116
|
+
lines.push(chalk.cyan.bold(' Claude Code Output'));
|
|
117
|
+
lines.push(chalk.dim(' ' + '\u2500'.repeat(50)));
|
|
118
|
+
|
|
119
|
+
const outputLines = state.currentStepOutput.split('\n');
|
|
120
|
+
const visible = outputLines.slice(-MAX_OUTPUT_LINES);
|
|
121
|
+
|
|
122
|
+
if (outputLines.length > MAX_OUTPUT_LINES) {
|
|
123
|
+
lines.push(chalk.dim(` ... (${outputLines.length - MAX_OUTPUT_LINES} lines above)`));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
for (const line of visible) {
|
|
127
|
+
lines.push(' ' + chalk.dim(line));
|
|
128
|
+
}
|
|
129
|
+
lines.push('');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Counts
|
|
133
|
+
const parts = [];
|
|
134
|
+
if (state.completedCount > 0) parts.push(chalk.green(`${state.completedCount} passed`));
|
|
135
|
+
if (state.failedCount > 0) parts.push(chalk.red(`${state.failedCount} failed`));
|
|
136
|
+
if (parts.length > 0) lines.push(` ${parts.join(', ')}`);
|
|
137
|
+
|
|
138
|
+
// Error message
|
|
139
|
+
if (state.error) {
|
|
140
|
+
lines.push('');
|
|
141
|
+
lines.push(chalk.red(` Error: ${state.error}`));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Footer
|
|
145
|
+
lines.push('');
|
|
146
|
+
if (['completed', 'stopped', 'error'].includes(state.status)) {
|
|
147
|
+
lines.push(chalk.dim(' Run finished. This window will close shortly.'));
|
|
148
|
+
} else {
|
|
149
|
+
lines.push(chalk.dim(' Press Ctrl+C to close this window.'));
|
|
150
|
+
lines.push(chalk.dim(' NightyTidy continues running in the background.'));
|
|
151
|
+
}
|
|
152
|
+
lines.push('');
|
|
153
|
+
|
|
154
|
+
// Clear screen and draw
|
|
155
|
+
process.stdout.write('\x1B[2J\x1B[H');
|
|
156
|
+
process.stdout.write(lines.join('\n'));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function startPolling(filePath) {
|
|
160
|
+
progressFilePath = filePath;
|
|
161
|
+
let lastState = null;
|
|
162
|
+
|
|
163
|
+
const interval = setInterval(() => {
|
|
164
|
+
try {
|
|
165
|
+
const newState = readState();
|
|
166
|
+
if (newState) lastState = newState;
|
|
167
|
+
|
|
168
|
+
if (lastState) {
|
|
169
|
+
render(lastState);
|
|
170
|
+
|
|
171
|
+
if (['completed', 'stopped', 'error'].includes(lastState.status)) {
|
|
172
|
+
clearInterval(interval);
|
|
173
|
+
setTimeout(() => process.exit(0), EXIT_DELAY);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
} catch {
|
|
177
|
+
// Render or read failed — retry on next tick; don't crash the window
|
|
178
|
+
}
|
|
179
|
+
}, POLL_INTERVAL);
|
|
180
|
+
|
|
181
|
+
// Initial render attempt
|
|
182
|
+
try {
|
|
183
|
+
const initial = readState();
|
|
184
|
+
if (initial) {
|
|
185
|
+
lastState = initial;
|
|
186
|
+
render(initial);
|
|
187
|
+
}
|
|
188
|
+
} catch {
|
|
189
|
+
// Will retry on next interval
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Only run as main entry point — safe to import without side effects
|
|
194
|
+
const isMain = process.argv[1]?.replace(/\\/g, '/').endsWith('dashboard-tui.js');
|
|
195
|
+
|
|
196
|
+
if (isMain) {
|
|
197
|
+
// Prevent uncaught errors from silently closing the window
|
|
198
|
+
process.on('uncaughtException', (err) => {
|
|
199
|
+
try { process.stderr.write(`[dashboard-tui] uncaught: ${err?.message || err}\n`); } catch { /* ignore */ }
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const path = process.argv[2];
|
|
203
|
+
if (!path) {
|
|
204
|
+
console.error('Usage: node dashboard-tui.js <progress-file-path>');
|
|
205
|
+
process.exit(1);
|
|
206
|
+
}
|
|
207
|
+
startPolling(path);
|
|
208
|
+
}
|