tlc-claude-code 0.6.1 → 0.6.3
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/PROJECT.md +46 -0
- package/README.md +6 -0
- package/bin/install.js +1 -0
- package/dashboard/dist/App.d.ts +5 -0
- package/dashboard/dist/App.js +49 -0
- package/dashboard/dist/App.test.d.ts +1 -0
- package/dashboard/dist/App.test.js +137 -0
- package/dashboard/dist/components/AgentsPane.d.ts +18 -0
- package/dashboard/dist/components/AgentsPane.js +77 -0
- package/dashboard/dist/components/AgentsPane.test.d.ts +1 -0
- package/dashboard/dist/components/AgentsPane.test.js +52 -0
- package/dashboard/dist/components/ChatPane.d.ts +6 -0
- package/dashboard/dist/components/ChatPane.js +34 -0
- package/dashboard/dist/components/ChatPane.test.d.ts +1 -0
- package/dashboard/dist/components/ChatPane.test.js +36 -0
- package/dashboard/dist/components/GitHubPane.d.ts +16 -0
- package/dashboard/dist/components/GitHubPane.js +121 -0
- package/dashboard/dist/components/GitHubPane.test.d.ts +1 -0
- package/dashboard/dist/components/GitHubPane.test.js +79 -0
- package/dashboard/dist/components/PhasesPane.d.ts +8 -0
- package/dashboard/dist/components/PhasesPane.js +65 -0
- package/dashboard/dist/components/PhasesPane.test.d.ts +1 -0
- package/dashboard/dist/components/PhasesPane.test.js +119 -0
- package/dashboard/dist/components/PlanSync.d.ts +13 -0
- package/dashboard/dist/components/PlanSync.js +89 -0
- package/dashboard/dist/components/PlanSync.test.d.ts +1 -0
- package/dashboard/dist/components/PlanSync.test.js +117 -0
- package/dashboard/dist/components/PreviewPane.d.ts +6 -0
- package/dashboard/dist/components/PreviewPane.js +43 -0
- package/dashboard/dist/components/PreviewPane.test.d.ts +1 -0
- package/dashboard/dist/components/PreviewPane.test.js +36 -0
- package/dashboard/dist/components/StatusPane.d.ts +1 -0
- package/dashboard/dist/components/StatusPane.js +32 -0
- package/dashboard/dist/components/StatusPane.test.d.ts +1 -0
- package/dashboard/dist/components/StatusPane.test.js +51 -0
- package/dashboard/dist/index.d.ts +2 -0
- package/dashboard/dist/index.js +13 -0
- package/dashboard/package.json +34 -0
- package/help.md +99 -115
- package/import-project.md +246 -0
- package/package.json +9 -2
- package/tlc.md +197 -217
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
3
|
+
import { render } from 'ink-testing-library';
|
|
4
|
+
import { GitHubPane } from './GitHubPane.js';
|
|
5
|
+
// Mock child_process to avoid actual gh calls
|
|
6
|
+
vi.mock('child_process', () => ({
|
|
7
|
+
exec: vi.fn((cmd, opts, cb) => {
|
|
8
|
+
if (typeof opts === 'function') {
|
|
9
|
+
cb = opts;
|
|
10
|
+
}
|
|
11
|
+
// Simulate gh CLI not available by default
|
|
12
|
+
const error = new Error('gh not found');
|
|
13
|
+
if (cb)
|
|
14
|
+
cb(error, '', '');
|
|
15
|
+
return { stdout: null, stderr: null };
|
|
16
|
+
}),
|
|
17
|
+
}));
|
|
18
|
+
vi.mock('util', async () => {
|
|
19
|
+
const actual = await vi.importActual('util');
|
|
20
|
+
return {
|
|
21
|
+
...actual,
|
|
22
|
+
promisify: (fn) => async (...args) => {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
fn(...args, (err, stdout, stderr) => {
|
|
25
|
+
if (err)
|
|
26
|
+
reject(err);
|
|
27
|
+
else
|
|
28
|
+
resolve({ stdout, stderr });
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
});
|
|
34
|
+
describe('GitHubPane', () => {
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
vi.clearAllMocks();
|
|
37
|
+
});
|
|
38
|
+
describe('loading state', () => {
|
|
39
|
+
it('shows loading message initially', () => {
|
|
40
|
+
const { lastFrame } = render(_jsx(GitHubPane, { isActive: false }));
|
|
41
|
+
const output = lastFrame();
|
|
42
|
+
expect(output).toContain('Loading issues...');
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
describe('error state', () => {
|
|
46
|
+
it('shows error when gh CLI fails', async () => {
|
|
47
|
+
const { lastFrame } = render(_jsx(GitHubPane, { isActive: false }));
|
|
48
|
+
// Wait for effect to run
|
|
49
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
50
|
+
const output = lastFrame();
|
|
51
|
+
expect(output).toContain('gh CLI not available');
|
|
52
|
+
});
|
|
53
|
+
it('shows install hint on error', async () => {
|
|
54
|
+
const { lastFrame } = render(_jsx(GitHubPane, { isActive: false }));
|
|
55
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
56
|
+
const output = lastFrame();
|
|
57
|
+
expect(output).toContain('Make sure gh CLI is installed');
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
describe('controls', () => {
|
|
61
|
+
it('shows controls when active and has issues', async () => {
|
|
62
|
+
// This test would need proper mocking of successful gh response
|
|
63
|
+
// For now, we test that the component doesn't crash
|
|
64
|
+
const { lastFrame } = render(_jsx(GitHubPane, { isActive: true }));
|
|
65
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
66
|
+
const output = lastFrame();
|
|
67
|
+
// Either shows controls or error state
|
|
68
|
+
expect(output).toBeDefined();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
describe('callback', () => {
|
|
72
|
+
it('accepts onAssignToAgent callback', () => {
|
|
73
|
+
const mockCallback = vi.fn();
|
|
74
|
+
const { lastFrame } = render(_jsx(GitHubPane, { isActive: true, onAssignToAgent: mockCallback }));
|
|
75
|
+
// Component should render without error
|
|
76
|
+
expect(lastFrame()).toBeDefined();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
interface Phase {
|
|
2
|
+
number: number;
|
|
3
|
+
name: string;
|
|
4
|
+
status: 'completed' | 'in_progress' | 'pending';
|
|
5
|
+
}
|
|
6
|
+
export declare function PhasesPane(): import("react/jsx-runtime").JSX.Element;
|
|
7
|
+
export declare function parseRoadmap(content: string): Phase[];
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { useState, useEffect } from 'react';
|
|
4
|
+
import { readFile } from 'fs/promises';
|
|
5
|
+
import { existsSync } from 'fs';
|
|
6
|
+
import { join } from 'path';
|
|
7
|
+
export function PhasesPane() {
|
|
8
|
+
const [phases, setPhases] = useState([]);
|
|
9
|
+
const [loading, setLoading] = useState(true);
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
async function loadPhases() {
|
|
12
|
+
const roadmapPath = join(process.cwd(), '.planning', 'ROADMAP.md');
|
|
13
|
+
if (!existsSync(roadmapPath)) {
|
|
14
|
+
setPhases([]);
|
|
15
|
+
setLoading(false);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
const content = await readFile(roadmapPath, 'utf-8');
|
|
20
|
+
const parsed = parseRoadmap(content);
|
|
21
|
+
setPhases(parsed);
|
|
22
|
+
}
|
|
23
|
+
catch (e) {
|
|
24
|
+
setPhases([]);
|
|
25
|
+
}
|
|
26
|
+
setLoading(false);
|
|
27
|
+
}
|
|
28
|
+
loadPhases();
|
|
29
|
+
const interval = setInterval(loadPhases, 5000);
|
|
30
|
+
return () => clearInterval(interval);
|
|
31
|
+
}, []);
|
|
32
|
+
if (loading) {
|
|
33
|
+
return (_jsx(Box, { padding: 1, children: _jsx(Text, { color: "gray", children: "Loading..." }) }));
|
|
34
|
+
}
|
|
35
|
+
if (phases.length === 0) {
|
|
36
|
+
return (_jsxs(Box, { padding: 1, flexDirection: "column", children: [_jsx(Text, { color: "gray", children: "No roadmap found." }), _jsx(Text, { color: "gray", dimColor: true, children: "Run /tdd:new-project or /tdd:init" })] }));
|
|
37
|
+
}
|
|
38
|
+
return (_jsx(Box, { padding: 1, flexDirection: "column", children: phases.map((phase) => (_jsxs(Box, { children: [_jsx(Text, { color: phase.status === 'completed' ? 'green' :
|
|
39
|
+
phase.status === 'in_progress' ? 'yellow' :
|
|
40
|
+
'gray', children: phase.status === 'completed' ? ' [x] ' :
|
|
41
|
+
phase.status === 'in_progress' ? ' [>] ' :
|
|
42
|
+
' [ ] ' }), _jsxs(Text, { color: phase.status === 'in_progress' ? 'cyan' : 'white', children: [phase.number, ". ", phase.name] })] }, phase.number))) }));
|
|
43
|
+
}
|
|
44
|
+
export function parseRoadmap(content) {
|
|
45
|
+
const phases = [];
|
|
46
|
+
const lines = content.split('\n');
|
|
47
|
+
for (const line of lines) {
|
|
48
|
+
// Match patterns like "## Phase 1: Setup" or "### 1. Auth System"
|
|
49
|
+
const match = line.match(/^#+\s*(?:Phase\s+)?(\d+)[.:]?\s*(.+)/i);
|
|
50
|
+
if (match) {
|
|
51
|
+
const num = parseInt(match[1], 10);
|
|
52
|
+
const name = match[2].replace(/\[.*?\]/g, '').trim();
|
|
53
|
+
// Determine status from markers
|
|
54
|
+
let status = 'pending';
|
|
55
|
+
if (line.includes('[x]') || line.includes('[completed]')) {
|
|
56
|
+
status = 'completed';
|
|
57
|
+
}
|
|
58
|
+
else if (line.includes('[>]') || line.includes('[in progress]') || line.includes('[current]')) {
|
|
59
|
+
status = 'in_progress';
|
|
60
|
+
}
|
|
61
|
+
phases.push({ number: num, name, status });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return phases;
|
|
65
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
3
|
+
import { render } from 'ink-testing-library';
|
|
4
|
+
import { PhasesPane, parseRoadmap } from './PhasesPane.js';
|
|
5
|
+
import { vol } from 'memfs';
|
|
6
|
+
// Mock fs modules
|
|
7
|
+
vi.mock('fs', async () => {
|
|
8
|
+
const memfs = await import('memfs');
|
|
9
|
+
return memfs.fs;
|
|
10
|
+
});
|
|
11
|
+
vi.mock('fs/promises', async () => {
|
|
12
|
+
const memfs = await import('memfs');
|
|
13
|
+
return memfs.fs.promises;
|
|
14
|
+
});
|
|
15
|
+
describe('PhasesPane', () => {
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
vol.reset();
|
|
18
|
+
});
|
|
19
|
+
describe('parseRoadmap', () => {
|
|
20
|
+
it('parses "## Phase N: Name" format', () => {
|
|
21
|
+
const content = `# Roadmap
|
|
22
|
+
|
|
23
|
+
## Phase 1: Setup
|
|
24
|
+
Initial setup
|
|
25
|
+
|
|
26
|
+
## Phase 2: Core Features
|
|
27
|
+
Build the main stuff`;
|
|
28
|
+
const phases = parseRoadmap(content);
|
|
29
|
+
expect(phases).toHaveLength(2);
|
|
30
|
+
expect(phases[0]).toEqual({ number: 1, name: 'Setup', status: 'pending' });
|
|
31
|
+
expect(phases[1]).toEqual({ number: 2, name: 'Core Features', status: 'pending' });
|
|
32
|
+
});
|
|
33
|
+
it('parses "### N. Name" format', () => {
|
|
34
|
+
const content = `# Roadmap
|
|
35
|
+
|
|
36
|
+
### 1. Auth System
|
|
37
|
+
### 2. User Dashboard
|
|
38
|
+
### 3. Reports`;
|
|
39
|
+
const phases = parseRoadmap(content);
|
|
40
|
+
expect(phases).toHaveLength(3);
|
|
41
|
+
expect(phases[0].name).toBe('Auth System');
|
|
42
|
+
expect(phases[2].number).toBe(3);
|
|
43
|
+
});
|
|
44
|
+
it('detects completed status from [x]', () => {
|
|
45
|
+
const content = `## Phase 1: Setup [x]
|
|
46
|
+
## Phase 2: Build`;
|
|
47
|
+
const phases = parseRoadmap(content);
|
|
48
|
+
expect(phases[0].status).toBe('completed');
|
|
49
|
+
expect(phases[1].status).toBe('pending');
|
|
50
|
+
});
|
|
51
|
+
it('detects completed status from [completed]', () => {
|
|
52
|
+
const content = `## Phase 1: Setup [completed]`;
|
|
53
|
+
const phases = parseRoadmap(content);
|
|
54
|
+
expect(phases[0].status).toBe('completed');
|
|
55
|
+
});
|
|
56
|
+
it('detects in_progress status from [>]', () => {
|
|
57
|
+
const content = `## Phase 1: Setup [x]
|
|
58
|
+
## Phase 2: Build [>]
|
|
59
|
+
## Phase 3: Deploy`;
|
|
60
|
+
const phases = parseRoadmap(content);
|
|
61
|
+
expect(phases[0].status).toBe('completed');
|
|
62
|
+
expect(phases[1].status).toBe('in_progress');
|
|
63
|
+
expect(phases[2].status).toBe('pending');
|
|
64
|
+
});
|
|
65
|
+
it('detects in_progress from [in progress]', () => {
|
|
66
|
+
const content = `## Phase 1: Build [in progress]`;
|
|
67
|
+
const phases = parseRoadmap(content);
|
|
68
|
+
expect(phases[0].status).toBe('in_progress');
|
|
69
|
+
});
|
|
70
|
+
it('detects in_progress from [current]', () => {
|
|
71
|
+
const content = `## Phase 1: Build [current]`;
|
|
72
|
+
const phases = parseRoadmap(content);
|
|
73
|
+
expect(phases[0].status).toBe('in_progress');
|
|
74
|
+
});
|
|
75
|
+
it('returns empty array for content with no phases', () => {
|
|
76
|
+
const content = `# Just a readme
|
|
77
|
+
|
|
78
|
+
Some text without phases.`;
|
|
79
|
+
const phases = parseRoadmap(content);
|
|
80
|
+
expect(phases).toEqual([]);
|
|
81
|
+
});
|
|
82
|
+
it('strips status markers from name', () => {
|
|
83
|
+
const content = `## Phase 1: Setup [completed]`;
|
|
84
|
+
const phases = parseRoadmap(content);
|
|
85
|
+
expect(phases[0].name).toBe('Setup');
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
describe('component rendering', () => {
|
|
89
|
+
it('shows loading initially', () => {
|
|
90
|
+
const { lastFrame } = render(_jsx(PhasesPane, {}));
|
|
91
|
+
expect(lastFrame()).toContain('Loading...');
|
|
92
|
+
});
|
|
93
|
+
it('shows no roadmap message when file missing', async () => {
|
|
94
|
+
const { lastFrame } = render(_jsx(PhasesPane, {}));
|
|
95
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
96
|
+
const output = lastFrame();
|
|
97
|
+
expect(output).toContain('No roadmap found');
|
|
98
|
+
expect(output).toContain('/tdd:new-project');
|
|
99
|
+
});
|
|
100
|
+
it('renders phases from roadmap file', async () => {
|
|
101
|
+
// Create mock roadmap file
|
|
102
|
+
vol.fromJSON({
|
|
103
|
+
[process.cwd() + '/.planning/ROADMAP.md']: `# Roadmap
|
|
104
|
+
|
|
105
|
+
## Phase 1: Auth [x]
|
|
106
|
+
## Phase 2: Dashboard [>]
|
|
107
|
+
## Phase 3: Reports`
|
|
108
|
+
});
|
|
109
|
+
const { lastFrame } = render(_jsx(PhasesPane, {}));
|
|
110
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
111
|
+
const output = lastFrame();
|
|
112
|
+
expect(output).toContain('1. Auth');
|
|
113
|
+
expect(output).toContain('2. Dashboard');
|
|
114
|
+
expect(output).toContain('3. Reports');
|
|
115
|
+
expect(output).toContain('[x]');
|
|
116
|
+
expect(output).toContain('[>]');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
interface Task {
|
|
2
|
+
id: string;
|
|
3
|
+
title: string;
|
|
4
|
+
description: string;
|
|
5
|
+
phase: number;
|
|
6
|
+
}
|
|
7
|
+
export declare function parseTasksFromPlan(planPath: string): Promise<Task[]>;
|
|
8
|
+
export declare function syncPlanToGitHub(tasks: Task[], phaseNumber: number, phaseName: string): Promise<Map<string, number>>;
|
|
9
|
+
export declare function markIssueInProgress(issueNumber: number): Promise<void>;
|
|
10
|
+
export declare function markIssueComplete(issueNumber: number): Promise<void>;
|
|
11
|
+
export declare function isPlanApproved(planPath: string): Promise<boolean>;
|
|
12
|
+
export declare function approvePlan(planPath: string): Promise<void>;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { exec } from 'child_process';
|
|
2
|
+
import { promisify } from 'util';
|
|
3
|
+
import { readFile } from 'fs/promises';
|
|
4
|
+
import { existsSync } from 'fs';
|
|
5
|
+
const execAsync = promisify(exec);
|
|
6
|
+
// Parse tasks from a PLAN.md file
|
|
7
|
+
export async function parseTasksFromPlan(planPath) {
|
|
8
|
+
if (!existsSync(planPath))
|
|
9
|
+
return [];
|
|
10
|
+
const content = await readFile(planPath, 'utf-8');
|
|
11
|
+
const tasks = [];
|
|
12
|
+
// Match task blocks like:
|
|
13
|
+
// ## Task 1: Setup auth
|
|
14
|
+
// or <task id="01-01">
|
|
15
|
+
const taskRegex = /(?:##\s*Task\s*(\d+)[:\s]+(.+)|<task\s+id="([^"]+)"[^>]*>)/gi;
|
|
16
|
+
let match;
|
|
17
|
+
while ((match = taskRegex.exec(content)) !== null) {
|
|
18
|
+
const id = match[3] || `task-${match[1]}`;
|
|
19
|
+
const title = match[2] || id;
|
|
20
|
+
// Get description (text until next heading or task tag)
|
|
21
|
+
const startIdx = match.index + match[0].length;
|
|
22
|
+
const nextMatch = content.slice(startIdx).match(/(?:##\s*Task|<task\s|<\/task>)/);
|
|
23
|
+
const endIdx = nextMatch ? startIdx + nextMatch.index : startIdx + 500;
|
|
24
|
+
const description = content.slice(startIdx, endIdx).trim().slice(0, 500);
|
|
25
|
+
tasks.push({ id, title, description, phase: 0 });
|
|
26
|
+
}
|
|
27
|
+
return tasks;
|
|
28
|
+
}
|
|
29
|
+
// Push approved plan tasks to GitHub Issues
|
|
30
|
+
export async function syncPlanToGitHub(tasks, phaseNumber, phaseName) {
|
|
31
|
+
const taskToIssue = new Map();
|
|
32
|
+
for (const task of tasks) {
|
|
33
|
+
try {
|
|
34
|
+
const body = `## Phase ${phaseNumber}: ${phaseName}
|
|
35
|
+
|
|
36
|
+
### Task: ${task.title}
|
|
37
|
+
|
|
38
|
+
${task.description}
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
*Created by TDD Dashboard*
|
|
42
|
+
*Task ID: ${task.id}*`;
|
|
43
|
+
const { stdout } = await execAsync(`gh issue create --title "[Phase ${phaseNumber}] ${task.title}" --body "${body.replace(/"/g, '\\"')}" --label "tdd,phase-${phaseNumber}"`, { cwd: process.cwd() });
|
|
44
|
+
// Extract issue number from output
|
|
45
|
+
const issueMatch = stdout.match(/issues\/(\d+)/);
|
|
46
|
+
if (issueMatch) {
|
|
47
|
+
taskToIssue.set(task.id, parseInt(issueMatch[1], 10));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
console.error(`Failed to create issue for task ${task.id}:`, e);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return taskToIssue;
|
|
55
|
+
}
|
|
56
|
+
// Mark issue as in-progress when agent starts
|
|
57
|
+
export async function markIssueInProgress(issueNumber) {
|
|
58
|
+
try {
|
|
59
|
+
await execAsync(`gh issue edit ${issueNumber} --add-label "in-progress" --remove-label "tdd"`, { cwd: process.cwd() });
|
|
60
|
+
}
|
|
61
|
+
catch (e) {
|
|
62
|
+
// Ignore - label might not exist
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// Mark issue as complete when agent finishes
|
|
66
|
+
export async function markIssueComplete(issueNumber) {
|
|
67
|
+
try {
|
|
68
|
+
await execAsync(`gh issue close ${issueNumber} --comment "Completed by TDD agent"`, { cwd: process.cwd() });
|
|
69
|
+
}
|
|
70
|
+
catch (e) {
|
|
71
|
+
// Ignore errors
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// Check if plan is approved (has APPROVED marker or user confirmed)
|
|
75
|
+
export async function isPlanApproved(planPath) {
|
|
76
|
+
if (!existsSync(planPath))
|
|
77
|
+
return false;
|
|
78
|
+
const content = await readFile(planPath, 'utf-8');
|
|
79
|
+
return content.includes('[APPROVED]') || content.includes('Status: Approved');
|
|
80
|
+
}
|
|
81
|
+
// Mark plan as approved
|
|
82
|
+
export async function approvePlan(planPath) {
|
|
83
|
+
if (!existsSync(planPath))
|
|
84
|
+
return;
|
|
85
|
+
const content = await readFile(planPath, 'utf-8');
|
|
86
|
+
const updatedContent = content.replace(/^(#.+)$/m, '$1\n\n> **Status: Approved** - Tasks synced to GitHub');
|
|
87
|
+
const { writeFile } = await import('fs/promises');
|
|
88
|
+
await writeFile(planPath, updatedContent);
|
|
89
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { parseTasksFromPlan, isPlanApproved, approvePlan } from './PlanSync.js';
|
|
3
|
+
import { vol } from 'memfs';
|
|
4
|
+
// Mock fs modules
|
|
5
|
+
vi.mock('fs', async () => {
|
|
6
|
+
const memfs = await import('memfs');
|
|
7
|
+
return memfs.fs;
|
|
8
|
+
});
|
|
9
|
+
vi.mock('fs/promises', async () => {
|
|
10
|
+
const memfs = await import('memfs');
|
|
11
|
+
return memfs.fs.promises;
|
|
12
|
+
});
|
|
13
|
+
describe('PlanSync', () => {
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
vol.reset();
|
|
16
|
+
});
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
vi.restoreAllMocks();
|
|
19
|
+
});
|
|
20
|
+
describe('parseTasksFromPlan', () => {
|
|
21
|
+
it('returns empty array for non-existent file', async () => {
|
|
22
|
+
const tasks = await parseTasksFromPlan('/fake/path.md');
|
|
23
|
+
expect(tasks).toEqual([]);
|
|
24
|
+
});
|
|
25
|
+
it('parses markdown task format (## Task N: Title)', async () => {
|
|
26
|
+
vol.fromJSON({
|
|
27
|
+
'/plan.md': `# Phase 1 Plan
|
|
28
|
+
|
|
29
|
+
## Task 1: Setup authentication
|
|
30
|
+
Configure JWT tokens and session management.
|
|
31
|
+
|
|
32
|
+
## Task 2: Create login endpoint
|
|
33
|
+
Build POST /api/login route.
|
|
34
|
+
`
|
|
35
|
+
});
|
|
36
|
+
const tasks = await parseTasksFromPlan('/plan.md');
|
|
37
|
+
expect(tasks).toHaveLength(2);
|
|
38
|
+
expect(tasks[0]).toMatchObject({
|
|
39
|
+
id: 'task-1',
|
|
40
|
+
title: 'Setup authentication',
|
|
41
|
+
});
|
|
42
|
+
expect(tasks[0].description).toContain('Configure JWT');
|
|
43
|
+
expect(tasks[1]).toMatchObject({
|
|
44
|
+
id: 'task-2',
|
|
45
|
+
title: 'Create login endpoint',
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
it('parses XML task format (<task id="...">)', async () => {
|
|
49
|
+
vol.fromJSON({
|
|
50
|
+
'/plan.md': `# Phase 1
|
|
51
|
+
|
|
52
|
+
<task id="01-01">
|
|
53
|
+
Setup the database connection
|
|
54
|
+
</task>
|
|
55
|
+
|
|
56
|
+
<task id="01-02">
|
|
57
|
+
Create user model
|
|
58
|
+
</task>
|
|
59
|
+
`
|
|
60
|
+
});
|
|
61
|
+
const tasks = await parseTasksFromPlan('/plan.md');
|
|
62
|
+
expect(tasks).toHaveLength(2);
|
|
63
|
+
expect(tasks[0].id).toBe('01-01');
|
|
64
|
+
expect(tasks[1].id).toBe('01-02');
|
|
65
|
+
});
|
|
66
|
+
it('truncates description to 500 chars', async () => {
|
|
67
|
+
const longDescription = 'A'.repeat(600);
|
|
68
|
+
vol.fromJSON({
|
|
69
|
+
'/plan.md': `## Task 1: Long task\n${longDescription}`
|
|
70
|
+
});
|
|
71
|
+
const tasks = await parseTasksFromPlan('/plan.md');
|
|
72
|
+
expect(tasks[0].description.length).toBeLessThanOrEqual(500);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
describe('isPlanApproved', () => {
|
|
76
|
+
it('returns false for non-existent file', async () => {
|
|
77
|
+
const result = await isPlanApproved('/fake/plan.md');
|
|
78
|
+
expect(result).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
it('returns true when file contains [APPROVED]', async () => {
|
|
81
|
+
vol.fromJSON({
|
|
82
|
+
'/plan.md': `# Plan [APPROVED]\n\nSome content`
|
|
83
|
+
});
|
|
84
|
+
const result = await isPlanApproved('/plan.md');
|
|
85
|
+
expect(result).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
it('returns true when file contains Status: Approved', async () => {
|
|
88
|
+
vol.fromJSON({
|
|
89
|
+
'/plan.md': `# Plan\n\n> **Status: Approved**\n\nContent`
|
|
90
|
+
});
|
|
91
|
+
const result = await isPlanApproved('/plan.md');
|
|
92
|
+
expect(result).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
it('returns false when not approved', async () => {
|
|
95
|
+
vol.fromJSON({
|
|
96
|
+
'/plan.md': `# Plan\n\nNot yet approved.`
|
|
97
|
+
});
|
|
98
|
+
const result = await isPlanApproved('/plan.md');
|
|
99
|
+
expect(result).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
describe('approvePlan', () => {
|
|
103
|
+
it('does nothing for non-existent file', async () => {
|
|
104
|
+
await approvePlan('/fake/plan.md');
|
|
105
|
+
// No error thrown
|
|
106
|
+
});
|
|
107
|
+
it('adds approval status after first heading', async () => {
|
|
108
|
+
vol.fromJSON({
|
|
109
|
+
'/plan.md': `# My Plan\n\n## Task 1: Something`
|
|
110
|
+
});
|
|
111
|
+
await approvePlan('/plan.md');
|
|
112
|
+
const content = vol.readFileSync('/plan.md', 'utf-8');
|
|
113
|
+
expect(content).toContain('Status: Approved');
|
|
114
|
+
expect(content).toContain('Tasks synced to GitHub');
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text, useInput } from 'ink';
|
|
3
|
+
import { useState, useCallback } from 'react';
|
|
4
|
+
import Spinner from 'ink-spinner';
|
|
5
|
+
export function PreviewPane({ isActive, isTTY = true }) {
|
|
6
|
+
const [status, setStatus] = useState('stopped');
|
|
7
|
+
const [url, setUrl] = useState(null);
|
|
8
|
+
const [error, setError] = useState(null);
|
|
9
|
+
const startContainer = useCallback(async () => {
|
|
10
|
+
setStatus('starting');
|
|
11
|
+
setError(null);
|
|
12
|
+
try {
|
|
13
|
+
// TODO: Integrate with dockerode
|
|
14
|
+
// For now, simulate container start
|
|
15
|
+
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
16
|
+
setStatus('running');
|
|
17
|
+
setUrl('http://localhost:3000');
|
|
18
|
+
}
|
|
19
|
+
catch (e) {
|
|
20
|
+
setStatus('error');
|
|
21
|
+
setError(e instanceof Error ? e.message : 'Failed to start container');
|
|
22
|
+
}
|
|
23
|
+
}, []);
|
|
24
|
+
const stopContainer = useCallback(async () => {
|
|
25
|
+
setStatus('stopped');
|
|
26
|
+
setUrl(null);
|
|
27
|
+
}, []);
|
|
28
|
+
useInput((input, key) => {
|
|
29
|
+
if (!isActive)
|
|
30
|
+
return;
|
|
31
|
+
if (input === 's' && status === 'stopped') {
|
|
32
|
+
startContainer();
|
|
33
|
+
}
|
|
34
|
+
else if (input === 'x' && status === 'running') {
|
|
35
|
+
stopContainer();
|
|
36
|
+
}
|
|
37
|
+
else if (input === 'r' && status === 'running') {
|
|
38
|
+
// Restart
|
|
39
|
+
stopContainer().then(startContainer);
|
|
40
|
+
}
|
|
41
|
+
}, { isActive: isTTY });
|
|
42
|
+
return (_jsxs(Box, { padding: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { children: "Status: " }), status === 'stopped' && _jsx(Text, { color: "gray", children: "Stopped" }), status === 'starting' && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { color: "yellow", children: " Starting..." })] })), status === 'running' && _jsx(Text, { color: "green", children: "Running" }), status === 'error' && _jsx(Text, { color: "red", children: "Error" })] }), url && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "cyan", children: url }) })), error && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "red", children: error }) })), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsxs(Text, { dimColor: true, children: [status === 'stopped' && isActive && '[s] Start', status === 'running' && isActive && '[x] Stop | [r] Restart', !isActive && 'Tab to this pane for controls'] }) })] }));
|
|
43
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { describe, it, expect } from 'vitest';
|
|
3
|
+
import { render } from 'ink-testing-library';
|
|
4
|
+
import { PreviewPane } from './PreviewPane.js';
|
|
5
|
+
describe('PreviewPane', () => {
|
|
6
|
+
describe('initial state', () => {
|
|
7
|
+
it('shows stopped status initially', () => {
|
|
8
|
+
const { lastFrame } = render(_jsx(PreviewPane, { isActive: false }));
|
|
9
|
+
const output = lastFrame();
|
|
10
|
+
expect(output).toContain('Status:');
|
|
11
|
+
expect(output).toContain('Stopped');
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
describe('controls', () => {
|
|
15
|
+
it('shows start control when active and stopped', () => {
|
|
16
|
+
const { lastFrame } = render(_jsx(PreviewPane, { isActive: true }));
|
|
17
|
+
const output = lastFrame();
|
|
18
|
+
expect(output).toContain('[s] Start');
|
|
19
|
+
});
|
|
20
|
+
it('shows hint when inactive', () => {
|
|
21
|
+
const { lastFrame } = render(_jsx(PreviewPane, { isActive: false }));
|
|
22
|
+
const output = lastFrame();
|
|
23
|
+
expect(output).toContain('Tab to this pane');
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
describe('render states', () => {
|
|
27
|
+
it('renders without error when active', () => {
|
|
28
|
+
const { lastFrame } = render(_jsx(PreviewPane, { isActive: true }));
|
|
29
|
+
expect(lastFrame()).toBeDefined();
|
|
30
|
+
});
|
|
31
|
+
it('renders without error when inactive', () => {
|
|
32
|
+
const { lastFrame } = render(_jsx(PreviewPane, { isActive: false }));
|
|
33
|
+
expect(lastFrame()).toBeDefined();
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function StatusPane(): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { useState, useEffect } from 'react';
|
|
4
|
+
import { exec } from 'child_process';
|
|
5
|
+
import { promisify } from 'util';
|
|
6
|
+
const execAsync = promisify(exec);
|
|
7
|
+
export function StatusPane() {
|
|
8
|
+
const [tests, setTests] = useState(null);
|
|
9
|
+
const [lastRun, setLastRun] = useState('Never');
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
async function checkTests() {
|
|
12
|
+
try {
|
|
13
|
+
// Try to detect test framework and get status
|
|
14
|
+
// This is a simplified version - real implementation would parse test output
|
|
15
|
+
const { stdout } = await execAsync('npm test -- --reporter=json 2>/dev/null || echo "{}"', {
|
|
16
|
+
timeout: 30000,
|
|
17
|
+
cwd: process.cwd()
|
|
18
|
+
});
|
|
19
|
+
// For now, show placeholder
|
|
20
|
+
setTests({ total: 0, passed: 0, failed: 0, pending: 0 });
|
|
21
|
+
setLastRun(new Date().toLocaleTimeString());
|
|
22
|
+
}
|
|
23
|
+
catch (e) {
|
|
24
|
+
// Tests not configured or failed to run
|
|
25
|
+
setTests(null);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
// Check on mount
|
|
29
|
+
checkTests();
|
|
30
|
+
}, []);
|
|
31
|
+
return (_jsx(Box, { padding: 1, flexDirection: "column", children: tests ? (_jsxs(_Fragment, { children: [_jsxs(Box, { children: [_jsxs(Text, { color: "green", children: ["Passed: ", tests.passed] }), _jsx(Text, { children: " | " }), _jsxs(Text, { color: "red", children: ["Failed: ", tests.failed] }), _jsx(Text, { children: " | " }), _jsxs(Text, { color: "yellow", children: ["Pending: ", tests.pending] })] }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "gray", children: ["Total: ", tests.total] }) }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["Last run: ", lastRun] }) })] })) : (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "gray", children: "No test results." }), _jsx(Text, { dimColor: true, children: "Run tests to see status." })] })) }));
|
|
32
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|