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.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +314 -0
  3. package/bin/nightytidy.js +3 -0
  4. package/package.json +55 -0
  5. package/src/checks.js +367 -0
  6. package/src/claude.js +655 -0
  7. package/src/cli.js +1012 -0
  8. package/src/consolidation.js +81 -0
  9. package/src/dashboard-html.js +496 -0
  10. package/src/dashboard-standalone.js +167 -0
  11. package/src/dashboard-tui.js +208 -0
  12. package/src/dashboard.js +427 -0
  13. package/src/env.js +100 -0
  14. package/src/executor.js +550 -0
  15. package/src/git.js +348 -0
  16. package/src/lock.js +186 -0
  17. package/src/logger.js +111 -0
  18. package/src/notifications.js +33 -0
  19. package/src/orchestrator.js +919 -0
  20. package/src/prompts/loader.js +55 -0
  21. package/src/prompts/manifest.json +138 -0
  22. package/src/prompts/specials/changelog.md +28 -0
  23. package/src/prompts/specials/consolidation.md +61 -0
  24. package/src/prompts/specials/doc-update.md +1 -0
  25. package/src/prompts/specials/report.md +95 -0
  26. package/src/prompts/steps/01-documentation.md +173 -0
  27. package/src/prompts/steps/02-test-coverage.md +181 -0
  28. package/src/prompts/steps/03-test-hardening.md +181 -0
  29. package/src/prompts/steps/04-test-architecture.md +130 -0
  30. package/src/prompts/steps/05-test-consolidation.md +165 -0
  31. package/src/prompts/steps/06-test-quality.md +211 -0
  32. package/src/prompts/steps/07-api-design.md +165 -0
  33. package/src/prompts/steps/08-security-sweep.md +207 -0
  34. package/src/prompts/steps/09-dependency-health.md +217 -0
  35. package/src/prompts/steps/10-codebase-cleanup.md +189 -0
  36. package/src/prompts/steps/11-crosscutting-concerns.md +196 -0
  37. package/src/prompts/steps/12-file-decomposition.md +263 -0
  38. package/src/prompts/steps/13-code-elegance.md +329 -0
  39. package/src/prompts/steps/14-architectural-complexity.md +297 -0
  40. package/src/prompts/steps/15-type-safety.md +192 -0
  41. package/src/prompts/steps/16-logging-error-message.md +173 -0
  42. package/src/prompts/steps/17-data-integrity.md +139 -0
  43. package/src/prompts/steps/18-performance.md +183 -0
  44. package/src/prompts/steps/19-cost-resource-optimization.md +136 -0
  45. package/src/prompts/steps/20-error-recovery.md +145 -0
  46. package/src/prompts/steps/21-race-condition-audit.md +178 -0
  47. package/src/prompts/steps/22-bug-hunt.md +229 -0
  48. package/src/prompts/steps/23-frontend-quality.md +210 -0
  49. package/src/prompts/steps/24-uiux-audit.md +284 -0
  50. package/src/prompts/steps/25-state-management.md +170 -0
  51. package/src/prompts/steps/26-perceived-performance.md +190 -0
  52. package/src/prompts/steps/27-devops.md +165 -0
  53. package/src/prompts/steps/28-scheduled-job-chron-jobs.md +141 -0
  54. package/src/prompts/steps/29-observability.md +152 -0
  55. package/src/prompts/steps/30-backup-check.md +155 -0
  56. package/src/prompts/steps/31-product-polish-ux-friction.md +122 -0
  57. package/src/prompts/steps/32-feature-discovery-opportunity.md +128 -0
  58. package/src/prompts/steps/33-strategic-opportunities.md +217 -0
  59. package/src/report.js +540 -0
  60. package/src/setup.js +133 -0
  61. 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
+ }