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
package/src/checks.js ADDED
@@ -0,0 +1,367 @@
1
+ /**
2
+ * @fileoverview Pre-run validation checks for NightyTidy.
3
+ *
4
+ * Error contract: Throws with user-friendly messages on validation failure.
5
+ * All thrown errors are intended to be caught by cli.js and displayed to the user.
6
+ *
7
+ * @module checks
8
+ */
9
+
10
+ import { spawn } from 'child_process';
11
+ import { platform } from 'os';
12
+ import { info, debug, warn } from './logger.js';
13
+ import { cleanEnv } from './env.js';
14
+
15
+ /** @typedef {import('simple-git').SimpleGit} SimpleGit */
16
+
17
+ /**
18
+ * @typedef {Object} CommandResult
19
+ * @property {number|null} code - Exit code
20
+ * @property {string} stdout - Standard output
21
+ * @property {string} stderr - Standard error
22
+ */
23
+
24
+ /** Timeout for Claude authentication check (ms) */
25
+ const AUTH_TIMEOUT_MS = 30000;
26
+
27
+ /** Critical disk space threshold (MB) - throws error below this */
28
+ const CRITICAL_DISK_MB = 100;
29
+
30
+ /** Low disk space threshold (MB) - warns below this */
31
+ const LOW_DISK_MB = 1024;
32
+
33
+ /**
34
+ * Run a shell command with optional timeout.
35
+ *
36
+ * @param {string} cmd - Command to run
37
+ * @param {string[]} args - Command arguments
38
+ * @param {Object} [options] - Options
39
+ * @param {number} [options.timeoutMs] - Timeout in milliseconds
40
+ * @returns {Promise<CommandResult>} Command result
41
+ */
42
+ function runCommand(cmd, args, { timeoutMs, ...spawnOptions } = {}) {
43
+ return new Promise((resolve, reject) => {
44
+ const child = spawn(cmd, args, {
45
+ shell: platform() === 'win32',
46
+ ...spawnOptions,
47
+ });
48
+
49
+ let stdout = '';
50
+ let stderr = '';
51
+ let timer;
52
+
53
+ if (timeoutMs) {
54
+ timer = setTimeout(() => {
55
+ child.kill();
56
+ reject(new Error('timeout'));
57
+ }, timeoutMs);
58
+ }
59
+
60
+ child.stdout?.on('data', (chunk) => { stdout += chunk.toString(); });
61
+ child.stderr?.on('data', (chunk) => { stderr += chunk.toString(); });
62
+
63
+ child.on('error', (err) => { if (timer) clearTimeout(timer); reject(err); });
64
+ child.on('close', (code) => {
65
+ if (timer) clearTimeout(timer);
66
+ resolve({ code, stdout, stderr });
67
+ });
68
+ });
69
+ }
70
+
71
+ /**
72
+ * Verify git is installed and available on PATH.
73
+ * @throws {Error} If git is not installed
74
+ */
75
+ async function checkGitInstalled() {
76
+ try {
77
+ const result = await runCommand('git', ['--version']);
78
+ if (result.code !== 0) throw new Error();
79
+ info('Pre-check: git installed \u2713');
80
+ } catch {
81
+ throw new Error(
82
+ 'Git is not installed or not on your PATH.\n' +
83
+ 'Install it from https://git-scm.com and try again.'
84
+ );
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Verify the project directory is a git repository.
90
+ * @param {SimpleGit} git - Git instance
91
+ * @throws {Error} If not a git repository
92
+ */
93
+ async function checkGitRepo(git) {
94
+ const isRepo = await git.checkIsRepo();
95
+ if (!isRepo) {
96
+ throw new Error(
97
+ "This folder isn't a git project. Navigate to your project folder and try again.\n" +
98
+ 'If you need to set one up, run: git init'
99
+ );
100
+ }
101
+ info('Pre-check: git repository \u2713');
102
+ }
103
+
104
+ /**
105
+ * Verify the repository has at least one commit.
106
+ * @param {SimpleGit} git - Git instance
107
+ * @throws {Error} If no commits exist
108
+ */
109
+ async function checkHasCommits(git) {
110
+ try {
111
+ const log = await git.log({ maxCount: 1 });
112
+ if (!log.latest) throw new Error('no commits');
113
+ } catch {
114
+ throw new Error(
115
+ "Your project has no commits yet. NightyTidy needs at least one commit to create a safety tag.\n" +
116
+ 'Make an initial commit and try again: git add -A && git commit -m "Initial commit"'
117
+ );
118
+ }
119
+ info('Pre-check: has commits \u2713');
120
+ }
121
+
122
+ /**
123
+ * Verify Claude Code CLI is installed.
124
+ * @throws {Error} If Claude Code is not installed
125
+ */
126
+ async function checkClaudeInstalled() {
127
+ try {
128
+ const result = await runCommand('claude', ['--version'], { env: cleanEnv() });
129
+ if (result.code !== 0) throw new Error();
130
+ info('Pre-check: Claude Code installed \u2713');
131
+ } catch {
132
+ throw new Error(
133
+ 'Claude Code not detected.\n' +
134
+ 'Install it from https://docs.anthropic.com/en/docs/claude-code and sign in before running NightyTidy.'
135
+ );
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Run Claude Code interactively for sign-in with terminal access.
141
+ * @returns {Promise<void>} Resolves on successful auth
142
+ */
143
+ function runInteractiveAuth() {
144
+ return new Promise((resolve, reject) => {
145
+ const child = spawn('claude', ['-p', 'Say OK'], {
146
+ stdio: 'inherit',
147
+ shell: platform() === 'win32',
148
+ env: cleanEnv(),
149
+ });
150
+ child.on('error', reject);
151
+ child.on('close', (code) => {
152
+ if (code === 0) resolve();
153
+ else reject(new Error(`claude exited with code ${code}`));
154
+ });
155
+ });
156
+ }
157
+
158
+ /**
159
+ * Verify Claude Code is authenticated. Falls back to interactive sign-in if needed.
160
+ * @throws {Error} If authentication fails or times out
161
+ */
162
+ async function checkClaudeAuthenticated() {
163
+ // First try silently (captured output) — fast path for already-authenticated
164
+ try {
165
+ const result = await runCommand('claude', ['-p', 'Say OK'], {
166
+ timeoutMs: AUTH_TIMEOUT_MS,
167
+ stdio: ['ignore', 'pipe', 'pipe'],
168
+ env: cleanEnv(),
169
+ });
170
+ if (result.code !== 0 || !result.stdout.trim()) {
171
+ throw new Error('auth-failed');
172
+ }
173
+ info('Pre-check: Claude Code authenticated \u2713');
174
+ return;
175
+ } catch (err) {
176
+ if (err.message === 'timeout') {
177
+ throw new Error(
178
+ "Claude Code didn't respond within 30 seconds. It may be experiencing an outage.\n" +
179
+ 'Check https://status.anthropic.com and try again later.'
180
+ );
181
+ }
182
+ // Fall through to interactive sign-in attempt
183
+ }
184
+
185
+ // Silent check failed — launch Claude with terminal access for sign-in
186
+ info('Claude Code needs to sign in. Launching sign-in now...');
187
+ try {
188
+ await runInteractiveAuth();
189
+ info('Pre-check: Claude Code authenticated \u2713');
190
+ } catch {
191
+ throw new Error(
192
+ "Claude Code sign-in did not complete successfully.\n" +
193
+ 'If this keeps happening, check https://status.anthropic.com for outages.'
194
+ );
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Get free disk space on Windows.
200
+ * @param {string} projectDir - Project directory (to determine drive)
201
+ * @returns {Promise<number|null>} Free bytes or null if unavailable
202
+ */
203
+ async function getFreeBytesWindows(projectDir) {
204
+ const driveLetter = projectDir.charAt(0).toUpperCase();
205
+ // Try PowerShell first (wmic is deprecated on newer Windows)
206
+ const psResult = await runCommand('powershell', [
207
+ '-NoProfile', '-Command',
208
+ `(Get-PSDrive ${driveLetter}).Free`,
209
+ ]);
210
+ const psMatch = psResult.stdout.trim().match(/^(\d+)$/);
211
+ if (psResult.code === 0 && psMatch) {
212
+ return parseInt(psMatch[1], 10);
213
+ }
214
+ // Fallback to wmic for older Windows
215
+ const result = await runCommand('wmic', [
216
+ 'logicaldisk', 'where', `DeviceID='${driveLetter}:'`, 'get', 'FreeSpace',
217
+ ]);
218
+ const match = result.stdout.match(/(\d+)/);
219
+ return match ? parseInt(match[1], 10) : null;
220
+ }
221
+
222
+ /**
223
+ * Get free disk space on Unix-like systems.
224
+ * @param {string} projectDir - Project directory
225
+ * @returns {Promise<number|null>} Free bytes or null if unavailable
226
+ */
227
+ async function getFreeBytesUnix(projectDir) {
228
+ const result = await runCommand('df', ['-k', projectDir]);
229
+ const lines = result.stdout.trim().split('\n');
230
+ if (lines.length >= 2) {
231
+ const parts = lines[1].split(/\s+/);
232
+ if (parts.length >= 4) return parseInt(parts[3], 10) * 1024;
233
+ }
234
+ return null;
235
+ }
236
+
237
+ /**
238
+ * Get free disk space for the project directory.
239
+ * @param {string} projectDir - Project directory
240
+ * @returns {Promise<number|null>} Free bytes or null if unavailable
241
+ */
242
+ async function getFreeBytes(projectDir) {
243
+ return platform() === 'win32'
244
+ ? getFreeBytesWindows(projectDir)
245
+ : getFreeBytesUnix(projectDir);
246
+ }
247
+
248
+ /**
249
+ * Check available disk space. Throws on critical low, warns on low.
250
+ * @param {string} projectDir - Project directory
251
+ * @throws {Error} If disk space is critically low
252
+ */
253
+ async function checkDiskSpace(projectDir) {
254
+ let freeBytes = null;
255
+
256
+ try {
257
+ freeBytes = await getFreeBytes(projectDir);
258
+ } catch (err) {
259
+ debug(`Disk space check failed (${err.code || err.message}) — skipping`);
260
+ info('Pre-check: disk space (skipped) \u2713');
261
+ return;
262
+ }
263
+
264
+ if (freeBytes === null) {
265
+ debug('Could not parse disk space — skipping');
266
+ info('Pre-check: disk space (skipped) \u2713');
267
+ return;
268
+ }
269
+
270
+ const freeMB = Math.round(freeBytes / (1024 * 1024));
271
+ const freeGB = (freeBytes / (1024 * 1024 * 1024)).toFixed(1);
272
+
273
+ if (freeMB < CRITICAL_DISK_MB) {
274
+ throw new Error(
275
+ `Very low disk space (${freeMB} MB free). NightyTidy needs room for git operations.\n` +
276
+ 'Free up some space and try again.'
277
+ );
278
+ }
279
+
280
+ if (freeMB < LOW_DISK_MB) {
281
+ warn(`Low disk space (${freeMB} MB free). NightyTidy may fail if your project generates large diffs. Continuing anyway...`);
282
+ }
283
+
284
+ info(`Pre-check: disk space OK (${freeGB} GB free) \u2713`);
285
+ }
286
+
287
+ /**
288
+ * Check for uncommitted changes and warn if present.
289
+ * Non-critical check that never throws.
290
+ * @param {SimpleGit} git - Git instance
291
+ */
292
+ async function checkCleanWorkingTree(git) {
293
+ try {
294
+ const status = await git.status();
295
+ const dirtyCount = status.modified.length + status.not_added.length +
296
+ status.deleted.length + status.renamed.length + status.staged.length;
297
+ if (dirtyCount > 0) {
298
+ warn(
299
+ `You have ${dirtyCount} uncommitted change(s). NightyTidy will carry these to the run branch. ` +
300
+ 'If you undo the run later with git reset --hard, uncommitted changes will be lost. ' +
301
+ 'Consider committing or stashing your work first.'
302
+ );
303
+ } else {
304
+ info('Pre-check: clean working tree \u2713');
305
+ }
306
+ } catch (err) {
307
+ // Non-critical — skip silently
308
+ debug(`Working tree check failed (${err.message}) — skipping`);
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Check for existing NightyTidy branches from previous runs.
314
+ * Non-critical informational check that never throws.
315
+ * @param {SimpleGit} git - Git instance
316
+ */
317
+ async function checkExistingBranches(git) {
318
+ try {
319
+ const branches = await git.branch();
320
+ const nightyBranches = branches.all.filter(b => b.startsWith('nightytidy/run-'));
321
+ if (nightyBranches.length > 0) {
322
+ info(`Note: Found ${nightyBranches.length} existing NightyTidy branch(es) from previous run(s). These won't affect this run.`);
323
+ }
324
+ } catch (err) {
325
+ debug(`Branch check failed (${err.message}) — skipping`);
326
+ }
327
+ info('Pre-check: no branch conflicts \u2713');
328
+ }
329
+
330
+ /**
331
+ * Run all pre-flight validation checks.
332
+ *
333
+ * Checks are run in an optimized order:
334
+ * 1. Git installed (must pass before any git operations)
335
+ * 2. Parallel checks: git chain, Claude chain, disk space
336
+ *
337
+ * @param {string} projectDir - Project directory
338
+ * @param {SimpleGit} git - Git instance
339
+ * @throws {Error} If any critical check fails
340
+ */
341
+ export async function runPreChecks(projectDir, git) {
342
+ // Phase 1: git-installed must pass before any git operations
343
+ await checkGitInstalled();
344
+
345
+ // Phase 2: Run independent check groups in parallel for faster perceived startup.
346
+ // - Git chain: repo -> commits -> branches (sequential, each depends on the prior)
347
+ // - Claude chain: installed -> authenticated (sequential dependency)
348
+ // - Disk space: fully independent
349
+ const gitChain = async () => {
350
+ await checkGitRepo(git);
351
+ await checkHasCommits(git);
352
+ await checkCleanWorkingTree(git);
353
+ await checkExistingBranches(git);
354
+ };
355
+ const claudeChain = async () => {
356
+ await checkClaudeInstalled();
357
+ await checkClaudeAuthenticated();
358
+ };
359
+
360
+ await Promise.all([
361
+ gitChain(),
362
+ claudeChain(),
363
+ checkDiskSpace(projectDir),
364
+ ]);
365
+
366
+ info('All pre-run checks passed');
367
+ }