nightytidy 0.1.0 → 0.2.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/README.md CHANGED
@@ -309,6 +309,18 @@ GitHub Actions on every push/PR to master:
309
309
  | GUI Markdown | marked v17 (vendored UMD) |
310
310
  | Testing | Vitest v3 |
311
311
 
312
+ ## Web App (nightytidy.com)
313
+
314
+ NightyTidy has a web dashboard at [nightytidy.com](https://nightytidy.com) for remote monitoring, scheduling, and analytics. Start the local agent to connect:
315
+
316
+ ```bash
317
+ npx nightytidy agent
318
+ ```
319
+
320
+ The agent runs on your machine at `127.0.0.1:48372`. The web app connects to it via WebSocket — your code never leaves your machine. Firebase handles authentication and stores run history so you can check status from any browser.
321
+
322
+ **Source**: [nightytidy-web](https://github.com/dorianspitz23/nightytidy-web)
323
+
312
324
  ## License
313
325
 
314
326
  [MIT](LICENSE)
package/bin/nightytidy.js CHANGED
@@ -1,3 +1,3 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
  import { run } from '../src/cli.js';
3
3
  run();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nightytidy",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Automated overnight codebase improvement through Claude Code",
5
5
  "license": "MIT",
6
6
  "author": "Dorian Spitz",
@@ -44,9 +44,11 @@
44
44
  "@inquirer/checkbox": "^5.1.0",
45
45
  "chalk": "^5.6.2",
46
46
  "commander": "^14.0.3",
47
+ "node-cron": "^4.2.1",
47
48
  "node-notifier": "^10.0.1",
48
49
  "ora": "^9.3.0",
49
- "simple-git": "3.33.0"
50
+ "simple-git": "3.33.0",
51
+ "ws": "^8.19.0"
50
52
  },
51
53
  "devDependencies": {
52
54
  "@vitest/coverage-v8": "^3.2.4",
@@ -0,0 +1,138 @@
1
+ import { spawn } from 'node:child_process';
2
+ import path from 'node:path';
3
+ import { debug, error as logError } from '../logger.js';
4
+
5
+ export class CliBridge {
6
+ constructor(projectDir) {
7
+ this.projectDir = projectDir;
8
+ this.activeProcess = null;
9
+ }
10
+
11
+ async listSteps() {
12
+ return this._run(CliBridge.buildArgs({ list: true }));
13
+ }
14
+
15
+ async initRun(steps, timeout) {
16
+ return this._run(CliBridge.buildArgs({ initRun: true, steps, timeout }));
17
+ }
18
+
19
+ async runStep(stepNum, onOutput) {
20
+ return this._run(CliBridge.buildArgs({ runStep: stepNum }), onOutput);
21
+ }
22
+
23
+ async finishRun() {
24
+ return this._run(CliBridge.buildArgs({ finishRun: true }));
25
+ }
26
+
27
+ kill() {
28
+ if (this.activeProcess) {
29
+ const pid = this.activeProcess.pid;
30
+ debug(`Killing CLI process ${pid}`);
31
+ if (process.platform === 'win32') {
32
+ spawn('taskkill', ['/F', '/T', '/PID', String(pid)]);
33
+ } else {
34
+ this.activeProcess.kill('SIGTERM');
35
+ }
36
+ this.activeProcess = null;
37
+ }
38
+ }
39
+
40
+ static buildArgs(opts) {
41
+ const args = [];
42
+ if (opts.list) {
43
+ args.push('--list', '--json');
44
+ }
45
+ if (opts.initRun) {
46
+ args.push('--init-run');
47
+ args.push('--skip-dashboard');
48
+ args.push('--skip-sync');
49
+ if (opts.steps) args.push('--steps', opts.steps.join(','));
50
+ if (opts.timeout) args.push('--timeout', String(opts.timeout));
51
+ }
52
+ if (opts.runStep !== undefined) {
53
+ args.push('--run-step', String(opts.runStep));
54
+ }
55
+ if (opts.finishRun) {
56
+ args.push('--finish-run');
57
+ }
58
+ return args;
59
+ }
60
+
61
+ static parseOutput(stdout) {
62
+ const lines = stdout.trim().split('\n');
63
+ for (let i = lines.length - 1; i >= 0; i--) {
64
+ const line = lines[i].trim();
65
+ if (line.startsWith('{') || line.startsWith('[')) {
66
+ try {
67
+ return JSON.parse(line);
68
+ } catch { /* continue */ }
69
+ }
70
+ }
71
+ return null;
72
+ }
73
+
74
+ _run(args, onOutput) {
75
+ return new Promise((resolve, reject) => {
76
+ const binPath = path.resolve(import.meta.dirname, '../../bin/nightytidy.js');
77
+ const proc = spawn('node', [binPath, ...args], {
78
+ cwd: this.projectDir,
79
+ stdio: ['pipe', 'pipe', 'pipe'],
80
+ });
81
+ this.activeProcess = proc;
82
+
83
+ let stdout = '';
84
+ let stderr = '';
85
+
86
+ proc.stdout.on('data', (data) => {
87
+ const text = data.toString();
88
+ stdout += text;
89
+ // Filter the final JSON result line from streaming output — it's the
90
+ // orchestrator's return value, not Claude's output. The parsed result
91
+ // is extracted separately by parseOutput() after process close.
92
+ if (onOutput) {
93
+ const trimmed = text.trim();
94
+ if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
95
+ try {
96
+ const obj = JSON.parse(trimmed);
97
+ if ('success' in obj && ('step' in obj || 'error' in obj)) return;
98
+ } catch { /* not JSON, forward as normal output */ }
99
+ }
100
+ onOutput(text);
101
+ }
102
+ });
103
+
104
+ proc.stderr.on('data', (data) => {
105
+ const text = data.toString();
106
+ stderr += text;
107
+ // Filter Node.js ExperimentalWarning noise before forwarding to UI
108
+ if (onOutput && !text.includes('ExperimentalWarning') && !text.includes('--experimental-')) {
109
+ onOutput(text);
110
+ }
111
+ });
112
+
113
+ proc.on('close', (code) => {
114
+ this.activeProcess = null;
115
+ const parsed = CliBridge.parseOutput(stdout);
116
+ resolve({
117
+ success: code === 0,
118
+ exitCode: code,
119
+ stdout,
120
+ stderr,
121
+ parsed,
122
+ });
123
+ });
124
+
125
+ proc.on('error', (err) => {
126
+ this.activeProcess = null;
127
+ logError(`CLI process error: ${err.message}`);
128
+ resolve({
129
+ success: false,
130
+ exitCode: -1,
131
+ stdout,
132
+ stderr: err.message,
133
+ parsed: null,
134
+ });
135
+ });
136
+ });
137
+ }
138
+ }
@@ -0,0 +1,51 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import crypto from 'node:crypto';
5
+ import { debug } from '../logger.js';
6
+
7
+ export const CONFIG_VERSION = 1;
8
+ const CONFIG_FILE = 'config.json';
9
+
10
+ export function getConfigDir() {
11
+ return path.join(os.homedir(), '.nightytidy');
12
+ }
13
+
14
+ export function ensureConfigDir(configDir) {
15
+ if (!fs.existsSync(configDir)) {
16
+ fs.mkdirSync(configDir, { recursive: true });
17
+ debug(`Created config directory: ${configDir}`);
18
+ }
19
+ }
20
+
21
+ export function readConfig(configDir) {
22
+ const filePath = path.join(configDir, CONFIG_FILE);
23
+ try {
24
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
25
+ if (data.version === CONFIG_VERSION) return data;
26
+ debug(`Config version mismatch: ${data.version} → ${CONFIG_VERSION}, migrating`);
27
+ return migrateConfig(data);
28
+ } catch {
29
+ return createDefaultConfig();
30
+ }
31
+ }
32
+
33
+ export function writeConfig(configDir, config) {
34
+ ensureConfigDir(configDir);
35
+ const filePath = path.join(configDir, CONFIG_FILE);
36
+ fs.writeFileSync(filePath, JSON.stringify(config, null, 2));
37
+ }
38
+
39
+ function createDefaultConfig() {
40
+ return {
41
+ version: CONFIG_VERSION,
42
+ port: 48372,
43
+ token: crypto.randomBytes(24).toString('hex'),
44
+ machine: os.hostname(),
45
+ };
46
+ }
47
+
48
+ function migrateConfig(data) {
49
+ // Future migrations go here
50
+ return { ...createDefaultConfig(), ...data, version: CONFIG_VERSION };
51
+ }
@@ -0,0 +1,61 @@
1
+ import { debug, info, warn } from '../logger.js';
2
+
3
+ const REFRESH_BUFFER_MS = 15 * 60_000; // Request refresh 15 min before expiry
4
+
5
+ export class FirebaseAuth {
6
+ constructor(configDir) {
7
+ this.configDir = configDir;
8
+ this.token = null;
9
+ this.expiresAt = null;
10
+ this._refreshRequested = false;
11
+ }
12
+
13
+ isAuthenticated() {
14
+ return this.token !== null && this.expiresAt > Date.now();
15
+ }
16
+
17
+ getToken() {
18
+ if (!this.isAuthenticated()) return null;
19
+ return this.token;
20
+ }
21
+
22
+ setToken(token, expiresAt) {
23
+ this.token = token;
24
+ this.expiresAt = expiresAt;
25
+ this._refreshRequested = false;
26
+ debug('Firebase auth token updated');
27
+ }
28
+
29
+ getAuthHeader() {
30
+ const token = this.getToken();
31
+ if (!token) return {};
32
+ return { Authorization: `Bearer ${token}` };
33
+ }
34
+
35
+ /**
36
+ * Returns true if the token is within REFRESH_BUFFER_MS of expiry
37
+ * and a refresh has not already been requested.
38
+ */
39
+ needsRefresh() {
40
+ if (!this.token || !this.expiresAt) return false;
41
+ if (this._refreshRequested) return false;
42
+ return this.expiresAt - Date.now() < REFRESH_BUFFER_MS;
43
+ }
44
+
45
+ /**
46
+ * Mark that a refresh has been requested so we don't spam requests.
47
+ * Cleared when setToken() is called with a new token.
48
+ */
49
+ markRefreshRequested() {
50
+ this._refreshRequested = true;
51
+ debug('Firebase auth token refresh requested');
52
+ }
53
+
54
+ // Full OAuth flow will be implemented in integration phase
55
+ // For now, this is a placeholder that stores/retrieves tokens
56
+ async authenticate() {
57
+ info('Firebase authentication required — browser OAuth flow needed');
58
+ // TODO: Open browser to nightytidy.com/auth/agent, receive token via callback
59
+ return false;
60
+ }
61
+ }
@@ -0,0 +1,77 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { debug, warn } from '../logger.js';
4
+
5
+ const execFileAsync = promisify(execFile);
6
+
7
+ export class AgentGit {
8
+ constructor(projectDir) {
9
+ this.projectDir = projectDir;
10
+ }
11
+
12
+ async getDiffStat(baseBranch, runBranch) {
13
+ return this._exec('git', ['diff', '--stat', `${baseBranch}...${runBranch}`]);
14
+ }
15
+
16
+ async getDiff(baseBranch, runBranch) {
17
+ return this._exec('git', ['diff', `${baseBranch}...${runBranch}`]);
18
+ }
19
+
20
+ async countFilesChanged(baseBranch, runBranch) {
21
+ const output = await this._exec('git', ['diff', '--name-only', `${baseBranch}...${runBranch}`]);
22
+ return output.trim().split('\n').filter(Boolean).length;
23
+ }
24
+
25
+ async rollback(tag) {
26
+ // Safety: verify tag exists and is a nightytidy tag
27
+ const tagExists = (await this._exec('git', ['tag', '-l', tag])).trim();
28
+ if (!tagExists || !tag.startsWith('nightytidy-before-')) {
29
+ throw new Error(`Invalid rollback tag: ${tag}`);
30
+ }
31
+ debug(`Rolling back to tag: ${tag}`);
32
+ await this._exec('git', ['reset', '--hard', tag]);
33
+ }
34
+
35
+ async createPr(branch, title, body) {
36
+ try {
37
+ // Use execFile with args array to prevent command injection
38
+ const result = await this._exec('gh', [
39
+ 'pr', 'create', '--head', branch, '--title', title, '--body', body,
40
+ ]);
41
+ const url = result.trim().split('\n').pop();
42
+ return { success: true, url };
43
+ } catch (err) {
44
+ warn(`PR creation failed: ${err.message}`);
45
+ return { success: false, error: err.message };
46
+ }
47
+ }
48
+
49
+ async merge(runBranch, targetBranch) {
50
+ try {
51
+ await this._exec('git', ['checkout', targetBranch]);
52
+ await this._exec('git', ['merge', runBranch, '--no-edit']);
53
+ return { success: true };
54
+ } catch (err) {
55
+ // Abort failed merge
56
+ try { await this._exec('git', ['merge', '--abort']); } catch { /* ignore */ }
57
+ return { success: false, conflict: true, error: err.message };
58
+ }
59
+ }
60
+
61
+ async getReport(branch) {
62
+ // List files matching NIGHTYTIDY-REPORT*.md on the branch
63
+ const lsOutput = await this._exec('git', ['ls-tree', '--name-only', branch]);
64
+ const reportFile = lsOutput.trim().split('\n')
65
+ .filter(f => f.startsWith('NIGHTYTIDY-REPORT') && f.endsWith('.md'))
66
+ .sort()
67
+ .pop(); // Take the latest (highest numbered)
68
+ if (!reportFile) return null;
69
+ const content = await this._exec('git', ['show', `${branch}:${reportFile}`]);
70
+ return { filename: reportFile, content };
71
+ }
72
+
73
+ async _exec(cmd, args) {
74
+ const { stdout } = await execFileAsync(cmd, args, { cwd: this.projectDir });
75
+ return stdout;
76
+ }
77
+ }