diff --git a/package.json b/package.json index 64bfdbe..597184a 100644 --- a/package.json +++ b/package.json @@ -32,9 +32,9 @@ "main": "src/index.js", "scripts": { "dev": "node bin/autopilot.js", - "test": "node --test", + "test": "node --test --test-concurrency=1", "lint": "node -c bin/autopilot.js && node -c src/index.js", - "verify": "node bin/autopilot.js --help && node bin/autopilot.js doctor && node --test", + "verify": "node bin/autopilot.js --help && node bin/autopilot.js doctor && npm test", "prepublishOnly": "npm run verify", "release:patch": "npm run verify && npm version patch && git push --follow-tags && echo \"\nšŸš€ Release initiated! GitHub Action will publish to NPM.\"", "release:minor": "npm run verify && npm version minor && git push --follow-tags && echo \"\nšŸš€ Release initiated! GitHub Action will publish to NPM.\"", diff --git a/src/commands/dashboard.mjs b/src/commands/dashboard.mjs index 86f096b..68ee36f 100644 --- a/src/commands/dashboard.mjs +++ b/src/commands/dashboard.mjs @@ -19,7 +19,7 @@ const e = React.createElement; const Dashboard = () => { const { exit } = useApp(); const root = process.cwd(); - + const [status, setStatus] = useState('loading'); const [pid, setPid] = useState(null); const [lastCommit, setLastCommit] = useState(null); @@ -34,18 +34,18 @@ const Dashboard = () => { // 1. Check process status const currentPid = await getRunningPid(root); setPid(currentPid); - + // 2. Check Paused State const stateManager = new StateManager(root); if (stateManager.isPaused()) { - setStatus('paused'); - setPausedState(stateManager.getState()); + setStatus('paused'); + setPausedState(stateManager.getState()); } else if (currentPid) { - setStatus('running'); - setPausedState(null); + setStatus('running'); + setPausedState(null); } else { - setStatus('stopped'); - setPausedState(null); + setStatus('stopped'); + setPausedState(null); } // 3. Last Commit @@ -56,7 +56,7 @@ const Dashboard = () => { // 4. Pending Files const statusObj = await git.getPorcelainStatus(root); if (statusObj.ok) { - setPendingFiles(statusObj.files); + setPendingFiles(statusObj.files); } // 5. Today Stats (Simple count from history) @@ -126,10 +126,10 @@ const Dashboard = () => { e(Box, { flexDirection: "column", marginBottom: 1 }, e(Text, { underline: true }, `Pending Changes (${pendingFiles.length})`), e(Box, { flexDirection: "column" }, - pendingFiles.length === 0 ? + pendingFiles.length === 0 ? e(Text, { color: "gray" }, "No pending changes") : - pendingFiles.slice(0, 5).map((f) => - e(Text, { key: f.file, color: "yellow" }, ` ${f.status} ${f.file}`) + pendingFiles.slice(0, 5).map((f, idx) => + e(Text, { key: `${f.file}-${idx}`, color: "yellow" }, ` ${f.status} ${f.file}`) ) ), pendingFiles.length > 5 && e(Text, { color: "gray" }, ` ...and ${pendingFiles.length - 5} more`) @@ -143,5 +143,9 @@ const Dashboard = () => { }; export default function runDashboard() { + if (!process.stdin.isTTY && !process.env.AUTOPILOT_TEST_MODE) { + console.error('Error: Dashboard requires an interactive terminal (TTY).'); + process.exit(1); + } render(e(Dashboard)); } diff --git a/src/commands/doctor.js b/src/commands/doctor.js index e48673c..38bcc79 100644 --- a/src/commands/doctor.js +++ b/src/commands/doctor.js @@ -13,7 +13,7 @@ const git = require('../core/git'); const doctor = async () => { const repoPath = process.cwd(); let issues = 0; - + logger.section('Autopilot Doctor'); logger.info('Diagnosing environment...'); @@ -50,7 +50,17 @@ const doctor = async () => { // Check remote type if (remoteUrl.startsWith('http')) { - logger.warn('Remote uses HTTPS. Ensure credential helper is configured for non-interactive push.'); + let hasHelper = false; + try { + const { stdout: helper } = await execa('git', ['config', '--get', 'credential.helper'], { cwd: repoPath }); + if (helper.trim()) hasHelper = true; + } catch (e) { /* ignore */ } + + if (hasHelper) { + logger.success('Remote uses HTTPS with credential helper configured.'); + } else { + logger.warn('Remote uses HTTPS. Ensure credential helper is configured for non-interactive push.'); + } } else if (remoteUrl.startsWith('git@') || remoteUrl.startsWith('ssh://')) { logger.success('Remote uses SSH (recommended).'); } else { @@ -102,8 +112,8 @@ const doctor = async () => { logger.success('Branch is up to date with remote.'); } } else { - // Could be no upstream configured, which is fine for local-only initially - logger.info('Could not check remote status (upstream might not be set).'); + // Could be no upstream configured, which is fine for local-only initially + logger.info('Could not check remote status (upstream might not be set).'); } } catch (error) { logger.info('Skipping remote status check.'); diff --git a/src/commands/insights.js b/src/commands/insights.js index 7a56bbc..6a6f628 100644 --- a/src/commands/insights.js +++ b/src/commands/insights.js @@ -6,86 +6,52 @@ const { createObjectCsvWriter } = require('csv-writer'); async function getGitStats(repoPath) { try { - // Get commit log with stats - // We use custom delimiters to safely parse multi-line bodies and stats const { stdout } = await git.runGit(repoPath, [ 'log', - '--pretty=format:====COMMIT====%n%H|%an|%ad|%s|%b%n====BODY_END====', + '--pretty=format:===C===%H|%an|%ad|%s|%b===E===', '--date=iso', '--numstat' ]); + if (!stdout) return []; + const commits = []; - const rawCommits = stdout.split('====COMMIT===='); + const rawCommits = stdout.split('===C===').filter(Boolean); for (const raw of rawCommits) { - if (!raw.trim()) continue; - - const [metadataPart, statsPart] = raw.split('====BODY_END===='); - if (!metadataPart) continue; - - const lines = metadataPart.trim().split('\n'); - const header = lines[0]; // hash|author|date|subject|body_start... - // The body might continue on next lines if %b has newlines. - // Actually, my format puts %b starting on the first line. - // But let's be safer: split header by | first 4 times only. - - // header format: hash|author|date|subject|rest... - // But wait, if body has newlines, "lines" array has them. - - // Let's reconstruct the full message body - const fullMetadata = metadataPart.trim(); - const firstPipe = fullMetadata.indexOf('|'); - const secondPipe = fullMetadata.indexOf('|', firstPipe + 1); - const thirdPipe = fullMetadata.indexOf('|', secondPipe + 1); - const fourthPipe = fullMetadata.indexOf('|', thirdPipe + 1); - - if (firstPipe === -1 || fourthPipe === -1) continue; - - const hash = fullMetadata.substring(0, firstPipe); - const author = fullMetadata.substring(firstPipe + 1, secondPipe); - const dateStr = fullMetadata.substring(secondPipe + 1, thirdPipe); - const subject = fullMetadata.substring(thirdPipe + 1, fourthPipe); - const body = fullMetadata.substring(fourthPipe + 1); - - // TRUST VERIFICATION - // Check for Autopilot trailers - if (!body.includes('Autopilot-Commit: true')) { - continue; // Skip non-autopilot commits - } + const [metadataPlusBody, ...statsParts] = raw.split('===E==='); + if (!metadataPlusBody) continue; + + const [hash, author, dateStr, subject, ...bodyParts] = metadataPlusBody.trim().split('|'); + const body = bodyParts.join('|'); // Rejoin in case body had pipes - // TODO: Verify Signature (Optional but recommended for strict mode) - // const signature = extractTrailer(body, 'Autopilot-Signature'); - // if (!verifySignature(signature, ...)) continue; + // Trust Verification: Only process autopilot commits + if (!body.includes('Autopilot-Commit: true')) continue; const commit = { hash, author, date: new Date(dateStr), - message: subject + '\n' + body, + message: `${subject}\n${body}`.trim(), files: [], additions: 0, deletions: 0 }; - // Parse Stats - if (statsPart) { - const statLines = statsPart.trim().split('\n'); - for (const statLine of statLines) { - if (!statLine.trim()) continue; - const parts = statLine.split(/\s+/); - if (parts.length >= 3) { - const additions = parseInt(parts[0]) || 0; - const deletions = parseInt(parts[1]) || 0; - const file = parts.slice(2).join(' '); // handle spaces in filenames - + const statsText = statsParts.join('===E===').trim(); + if (statsText) { + const statLines = statsText.split('\n'); + for (const line of statLines) { + const [add, del, file] = line.trim().split(/\s+/); + if (file) { + const additions = parseInt(add) || 0; + const deletions = parseInt(del) || 0; commit.files.push({ file, additions, deletions }); commit.additions += additions; commit.deletions += deletions; } } } - commits.push(commit); } @@ -130,7 +96,7 @@ function calculateMetrics(commits) { // Time analysis const dateStr = c.date.toISOString().split('T')[0]; const hour = c.date.getHours(); - + stats.commitsByDay[dateStr] = (stats.commitsByDay[dateStr] || 0) + 1; stats.commitsByHour[hour] = (stats.commitsByHour[hour] || 0) + 1; dates.add(dateStr); @@ -145,7 +111,7 @@ function calculateMetrics(commits) { // Calculate Averages stats.totalFilesCount = stats.totalFilesChanged.size; stats.quality.avgLength = commits.length ? Math.round(totalMessageLength / commits.length) : 0; - + // Calculate Score (0-100) // 40% Conventional, 30% Message Length (>30 chars), 30% Consistency const convScore = commits.length ? (stats.quality.conventional / commits.length) * 40 : 0; @@ -165,7 +131,7 @@ function calculateMetrics(commits) { currentStreak = 1; } else { const diffTime = Math.abs(d - lastDate); - const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); if (diffDays === 1) { currentStreak++; } else { @@ -176,12 +142,12 @@ function calculateMetrics(commits) { lastDate = d; }); stats.streak.max = Math.max(maxStreak, currentStreak); - + // Check if streak is active (last commit today or yesterday) const today = new Date().toISOString().split('T')[0]; const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0]; const lastCommitDate = sortedDates[sortedDates.length - 1]; - + if (lastCommitDate === today || lastCommitDate === yesterday) { stats.streak.current = currentStreak; } else { @@ -217,10 +183,10 @@ async function insights(options) { console.log(`Lines Added: ${metrics.totalAdditions}`); console.log(`Lines Deleted: ${metrics.totalDeletions}`); console.log(`Current Streak: ${metrics.streak.current} days (Max: ${metrics.streak.max})`); - + // Find most productive hour const productiveHour = Object.entries(metrics.commitsByHour) - .sort(([,a], [,b]) => b - a)[0]; + .sort(([, a], [, b]) => b - a)[0]; console.log(`Peak Productivity: ${productiveHour ? productiveHour[0] + ':00' : 'N/A'}`); console.log(''); @@ -241,12 +207,12 @@ async function insights(options) { const csvWriter = createObjectCsvWriter({ path: csvPath, header: [ - {id: 'hash', title: 'Hash'}, - {id: 'date', title: 'Date'}, - {id: 'author', title: 'Author'}, - {id: 'message', title: 'Message'}, - {id: 'additions', title: 'Additions'}, - {id: 'deletions', title: 'Deletions'} + { id: 'hash', title: 'Hash' }, + { id: 'date', title: 'Date' }, + { id: 'author', title: 'Author' }, + { id: 'message', title: 'Message' }, + { id: 'additions', title: 'Additions' }, + { id: 'deletions', title: 'Deletions' } ] }); diff --git a/src/commands/leaderboard.js b/src/commands/leaderboard.js index cccda47..53b02b3 100644 --- a/src/commands/leaderboard.js +++ b/src/commands/leaderboard.js @@ -42,6 +42,7 @@ async function leaderboard(options) { await syncLeaderboard(apiUrl, options); } else { logger.info(`Opening leaderboard at ${apiUrl}/leaderboard...`); + const { default: open } = await import('open'); await open(`${apiUrl}/leaderboard`); } } diff --git a/src/core/git.js b/src/core/git.js index 2281824..0c56480 100644 --- a/src/core/git.js +++ b/src/core/git.js @@ -42,13 +42,14 @@ async function getPorcelainStatus(root) { try { const { stdout } = await execa('git', ['status', '--porcelain'], { cwd: root }); const raw = stdout.trim(); - + if (!raw) { return { ok: true, files: [], raw: '' }; } const files = raw .split(/\r?\n/) + .filter(line => line.trim().length > 0) .map(line => { const status = line.slice(0, 2).trim(); const file = line.slice(3).trim(); @@ -134,7 +135,7 @@ async function isRemoteAhead(root) { const { stdout } = await execa('git', ['rev-list', '--left-right', '--count', `${branch}...origin/${branch}`], { cwd: root }); const [aheadCount, behindCount] = stdout.trim().split(/\s+/).map(Number); - + return { ok: true, ahead: aheadCount > 0, @@ -259,10 +260,10 @@ async function isMergeInProgress(root) { 'REVERT_HEAD', 'BISECT_LOG' ]; - + // Check if .git/rebase-merge or .git/rebase-apply exists (directory check) - if (await fs.pathExists(path.join(gitDir, 'rebase-merge')) || - await fs.pathExists(path.join(gitDir, 'rebase-apply'))) { + if (await fs.pathExists(path.join(gitDir, 'rebase-merge')) || + await fs.pathExists(path.join(gitDir, 'rebase-apply'))) { return true; } diff --git a/src/index.js b/src/index.js index 37694bf..f1d5eed 100644 --- a/src/index.js +++ b/src/index.js @@ -8,7 +8,6 @@ const { doctor } = require('./commands/doctor'); const { insights } = require('./commands/insights'); const pauseCommand = require('./commands/pause'); const resumeCommand = require('./commands/resume'); -const runDashboard = require('./commands/dashboard'); const { leaderboard } = require('./commands/leaderboard'); const pkg = require('../package.json'); @@ -65,7 +64,14 @@ function run() { program .command('dashboard') .description('View real-time Autopilot dashboard') - .action(runDashboard); + .action(async () => { + try { + const { default: runDashboard } = await import('./commands/dashboard.mjs'); + runDashboard(); + } catch (error) { + console.error('Failed to launch dashboard:', error); + } + }); program .command('doctor') diff --git a/src/utils/logger.js b/src/utils/logger.js index 2d856f4..b476d3c 100644 --- a/src/utils/logger.js +++ b/src/utils/logger.js @@ -18,7 +18,7 @@ const logger = { * @param {string} message - Message to log */ info: (message) => { - console.log(`ā„¹ļø ${message}`); + console.log(`${logger.colors.cyan('ā„¹ļø')} ${message}`); }, /** @@ -27,7 +27,7 @@ const logger = { */ debug: (message) => { if (process.env.DEBUG) { - console.log(`šŸ” ${message}`); + console.log(`${logger.colors.blue('šŸ”')} ${message}`); } }, @@ -36,7 +36,7 @@ const logger = { * @param {string} message - Message to log */ success: (message) => { - console.log(`āœ… ${message}`); + console.log(`${logger.colors.green('āœ…')} ${message}`); }, /** @@ -44,7 +44,7 @@ const logger = { * @param {string} message - Message to log */ warn: (message) => { - console.warn(`āš ļø ${message}`); + console.warn(`${logger.colors.yellow('āš ļø')} ${message}`); }, /** @@ -52,7 +52,7 @@ const logger = { * @param {string} message - Message to log */ error: (message) => { - console.error(`āŒ ${message}`); + console.error(`${logger.colors.red('āŒ')} ${message}`); }, /** @@ -60,8 +60,8 @@ const logger = { * @param {string} title - Section title */ section: (title) => { - console.log(`\n${title}`); - console.log('─'.repeat(50)); + console.log(`\n${logger.colors.bold(logger.colors.cyan(title))}`); + console.log(logger.colors.cyan('─'.repeat(50))); }, }; diff --git a/test/dashboard_check.mjs b/test/dashboard_check.mjs index ad005c2..ea09990 100644 --- a/test/dashboard_check.mjs +++ b/test/dashboard_check.mjs @@ -9,7 +9,8 @@ console.log('Running dashboard check...'); const child = spawn('node', [binPath, 'dashboard'], { stdio: 'pipe', - timeout: 5000 + timeout: 5000, + env: { ...process.env, AUTOPILOT_TEST_MODE: '1' } }); let output = ''; diff --git a/test/missing_commands.test.js b/test/missing_commands.test.js index 17ab769..a22db93 100644 --- a/test/missing_commands.test.js +++ b/test/missing_commands.test.js @@ -38,7 +38,7 @@ function runGit(args, cwd) { test('Missing Commands Integration', async (t) => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'autopilot-cli-missing-')); - + t.after(async () => { // Wait a bit for any lingering file handles to close await new Promise(r => setTimeout(r, 500)); @@ -126,43 +126,43 @@ test('Missing Commands Integration', async (t) => { // Start a dummy process to simulate autopilot // We can't easily start the real `start` command because it blocks or runs in background in a way that's hard to kill cleanly in test without being flaky. // Instead, we fake the PID file. - + // We'll just test that `stop` handles "not running" correctly first const { code, stdout } = await run(['stop'], tmpDir); assert.strictEqual(code, 0); assert.match(stdout, /Autopilot is not running/); - + // Now fake a PID // We use the current process PID just to have a valid PID, but we won't actually kill it because `stop` uses process.kill(pid, 'SIGTERM') // Wait, if we use current process PID, `stop` will kill the test runner! Bad idea. // We should spawn a sleep process. - + const sleep = spawn('node', ['-e', 'setTimeout(() => {}, 10000)'], { detached: true }); const pid = sleep.pid; - + await fs.writeFile(path.join(tmpDir, '.autopilot.pid'), pid.toString()); - + const stopResult = await run(['stop'], tmpDir); assert.strictEqual(stopResult.code, 0); assert.match(stopResult.stdout, /Autopilot stopped successfully/); - + // Check if PID file is gone const pidExists = await fs.pathExists(path.join(tmpDir, '.autopilot.pid')); assert.strictEqual(pidExists, false); - + // Cleanup sleep process if it's still alive (it should be killed by stop) try { - process.kill(pid, 0); // Check if exists - process.kill(pid); // Kill if still exists + process.kill(pid, 0); // Check if exists + process.kill(pid); // Kill if still exists } catch (e) { - // Expected if it was killed + // Expected if it was killed } }); await t.test('dashboard command', async () => { const child = spawn(process.execPath, [BIN_PATH, 'dashboard'], { cwd: tmpDir, - env: { ...process.env, FORCE_COLOR: '1' }, + env: { ...process.env, FORCE_COLOR: '1', AUTOPILOT_TEST_MODE: '1' }, stdio: 'pipe' }); @@ -175,21 +175,21 @@ test('Missing Commands Integration', async (t) => { setTimeout(() => { child.kill(); }, 3000); - + // Wait for process to exit to avoid EBUSY on cleanup child.on('close', () => resolve()); }); // It should not have printed "Failed to launch dashboard" assert.doesNotMatch(output, /Failed to launch dashboard/); - + // If we are in a CI/Test environment without TTY, ink might not output anything. // We mainly want to ensure it didn't crash with the import error we fixed. if (output.length === 0) { - // Log warning but don't fail if we know it didn't crash with the specific error - console.warn('Warning: Dashboard produced no output (likely due to non-TTY environment).'); + // Log warning but don't fail if we know it didn't crash with the specific error + console.warn('Warning: Dashboard produced no output (likely due to non-TTY environment).'); } else { - assert.match(output, /Autopilot|Loading|Status/); + assert.match(output, /Autopilot|Loading|Status/); } });