cc-plan-viewer 0.1.0 → 0.2.2
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 +47 -9
- package/dist/cli-bundle.mjs +28407 -0
- package/dist/client/assets/index-DjikHpVb.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/server/bin/cc-plan-viewer.js +336 -66
- package/dist/server/server/app.js +171 -0
- package/dist/server/server/app.test.js +154 -0
- package/dist/server/server/index.js +18 -157
- package/dist/server/server/lifecycle.test.js +101 -0
- package/dist/server/server/planParser.test.js +104 -0
- package/dist/server/server/planWatcher.test.js +100 -0
- package/dist/server/server/reviewStore.test.js +88 -0
- package/dist/server-bundle.mjs +27988 -0
- package/hooks/plan-viewer-hook.cjs +19 -16
- package/package.json +30 -9
- package/dist/client/assets/index-CFUAZEOC.css +0 -1
- /package/dist/client/assets/{index-B9Ku0w1F.js → index-BRrHI1zG.js} +0 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { parsePlan } from './planParser.js';
|
|
7
|
+
import { saveReview, getReview } from './reviewStore.js';
|
|
8
|
+
import { resetIdleTimer } from './lifecycle.js';
|
|
9
|
+
export function createApp(plansDirs) {
|
|
10
|
+
const plansDir = plansDirs[0] || '';
|
|
11
|
+
const app = express();
|
|
12
|
+
const server = createServer(app);
|
|
13
|
+
const wss = new WebSocketServer({ server });
|
|
14
|
+
app.use(express.json());
|
|
15
|
+
// Reset idle timer on every request
|
|
16
|
+
app.use((_req, _res, next) => {
|
|
17
|
+
resetIdleTimer();
|
|
18
|
+
next();
|
|
19
|
+
});
|
|
20
|
+
// Health check
|
|
21
|
+
app.get('/health', (_req, res) => {
|
|
22
|
+
res.json({ status: 'ok', plansDirs });
|
|
23
|
+
});
|
|
24
|
+
// List all plans across all ~/.claude*/plans/ directories
|
|
25
|
+
app.get('/api/plans', (_req, res) => {
|
|
26
|
+
try {
|
|
27
|
+
const allFiles = [];
|
|
28
|
+
for (const dir of plansDirs) {
|
|
29
|
+
if (!fs.existsSync(dir))
|
|
30
|
+
continue;
|
|
31
|
+
const files = fs.readdirSync(dir)
|
|
32
|
+
.filter(f => f.endsWith('.md') && !f.endsWith('.review.json'));
|
|
33
|
+
for (const f of files) {
|
|
34
|
+
const filePath = path.join(dir, f);
|
|
35
|
+
const stat = fs.statSync(filePath);
|
|
36
|
+
const review = getReview(filePath);
|
|
37
|
+
allFiles.push({
|
|
38
|
+
filename: f,
|
|
39
|
+
modified: stat.mtime.toISOString(),
|
|
40
|
+
size: stat.size,
|
|
41
|
+
hasReview: !!review,
|
|
42
|
+
reviewAction: review?.action ?? null,
|
|
43
|
+
dir,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
allFiles.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime());
|
|
48
|
+
res.json(allFiles);
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
res.status(500).json({ error: 'Failed to list plans' });
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
// Find a plan file across all plan directories
|
|
55
|
+
function findPlanFile(filename) {
|
|
56
|
+
for (const dir of plansDirs) {
|
|
57
|
+
const filePath = path.join(dir, filename);
|
|
58
|
+
if (fs.existsSync(filePath))
|
|
59
|
+
return filePath;
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
// Get a specific plan
|
|
64
|
+
app.get('/api/plans/:filename', (req, res) => {
|
|
65
|
+
const filename = req.params.filename;
|
|
66
|
+
if (!filename.endsWith('.md') || filename.includes('..')) {
|
|
67
|
+
res.status(400).json({ error: 'Invalid filename' });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const filePath = findPlanFile(filename);
|
|
71
|
+
if (!filePath) {
|
|
72
|
+
res.status(404).json({ error: 'Plan not found' });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
77
|
+
const parsed = parsePlan(content);
|
|
78
|
+
const review = getReview(filePath);
|
|
79
|
+
res.json({ filename, parsed, review });
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
res.status(404).json({ error: 'Plan not found' });
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
// Hook notifies of plan update
|
|
86
|
+
app.post('/api/plan-updated', (req, res) => {
|
|
87
|
+
const { filePath, planOptions } = req.body;
|
|
88
|
+
const filename = path.basename(filePath || '');
|
|
89
|
+
// Broadcast to all WebSocket clients
|
|
90
|
+
const message = JSON.stringify({
|
|
91
|
+
type: 'plan-updated',
|
|
92
|
+
filename,
|
|
93
|
+
planOptions: planOptions || null,
|
|
94
|
+
});
|
|
95
|
+
for (const client of wss.clients) {
|
|
96
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
97
|
+
client.send(message);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
res.json({ ok: true });
|
|
101
|
+
});
|
|
102
|
+
// Save a review
|
|
103
|
+
app.post('/api/reviews/:filename', (req, res) => {
|
|
104
|
+
const filename = req.params.filename;
|
|
105
|
+
if (!filename.endsWith('.md') || filename.includes('..')) {
|
|
106
|
+
res.status(400).json({ error: 'Invalid filename' });
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const filePath = findPlanFile(filename);
|
|
110
|
+
if (!filePath) {
|
|
111
|
+
res.status(404).json({ error: 'Plan not found' });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const review = {
|
|
115
|
+
planFile: filename,
|
|
116
|
+
action: req.body.action || 'feedback',
|
|
117
|
+
submittedAt: new Date().toISOString(),
|
|
118
|
+
consumedAt: null,
|
|
119
|
+
overallComment: req.body.overallComment || '',
|
|
120
|
+
inlineComments: req.body.inlineComments || [],
|
|
121
|
+
};
|
|
122
|
+
saveReview(filePath, review);
|
|
123
|
+
// Notify clients
|
|
124
|
+
const message = JSON.stringify({
|
|
125
|
+
type: 'review-submitted',
|
|
126
|
+
filename,
|
|
127
|
+
action: review.action,
|
|
128
|
+
});
|
|
129
|
+
for (const client of wss.clients) {
|
|
130
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
131
|
+
client.send(message);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
res.json({ ok: true, review });
|
|
135
|
+
});
|
|
136
|
+
// Get a review
|
|
137
|
+
app.get('/api/reviews/:filename', (req, res) => {
|
|
138
|
+
const filename = req.params.filename;
|
|
139
|
+
const filePath = findPlanFile(filename);
|
|
140
|
+
if (!filePath) {
|
|
141
|
+
res.status(404).json({ error: 'No review found' });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const review = getReview(filePath);
|
|
145
|
+
if (!review) {
|
|
146
|
+
res.status(404).json({ error: 'No review found' });
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
res.json(review);
|
|
150
|
+
});
|
|
151
|
+
// Serve SPA static files
|
|
152
|
+
// Try multiple paths depending on how the server is running
|
|
153
|
+
const clientDistCandidates = [
|
|
154
|
+
path.join(import.meta.dirname, 'client'), // bundled: ~/.cc-plan-viewer/server-bundle.mjs → ~/.cc-plan-viewer/client/
|
|
155
|
+
path.join(import.meta.dirname, '..', '..', 'client'), // compiled: dist/server/server/ → dist/client/
|
|
156
|
+
path.join(import.meta.dirname, '..', 'dist', 'client'), // dev: server/ → dist/client/
|
|
157
|
+
];
|
|
158
|
+
const clientDist = clientDistCandidates.find((d) => fs.existsSync(d));
|
|
159
|
+
if (clientDist) {
|
|
160
|
+
app.use(express.static(clientDist));
|
|
161
|
+
app.get('/{*path}', (_req, res) => {
|
|
162
|
+
res.sendFile(path.join(clientDist, 'index.html'));
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
// WebSocket connection
|
|
166
|
+
wss.on('connection', (ws) => {
|
|
167
|
+
resetIdleTimer();
|
|
168
|
+
ws.on('message', () => resetIdleTimer());
|
|
169
|
+
});
|
|
170
|
+
return { app, server, wss };
|
|
171
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import request from 'supertest';
|
|
6
|
+
import { createApp } from './app.js';
|
|
7
|
+
// Suppress lifecycle idle timer output
|
|
8
|
+
vi.mock('./lifecycle.js', () => ({
|
|
9
|
+
resetIdleTimer: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
describe('server app', () => {
|
|
12
|
+
let testDir;
|
|
13
|
+
let app;
|
|
14
|
+
let server;
|
|
15
|
+
let wss;
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
// Create a temp directory for plan files
|
|
18
|
+
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-plan-viewer-test-'));
|
|
19
|
+
const result = createApp([testDir]);
|
|
20
|
+
app = result.app;
|
|
21
|
+
server = result.server;
|
|
22
|
+
wss = result.wss;
|
|
23
|
+
});
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
// Close server and clean up
|
|
26
|
+
server.close();
|
|
27
|
+
wss.close();
|
|
28
|
+
// Clean up temp directory
|
|
29
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
30
|
+
});
|
|
31
|
+
describe('GET /health', () => {
|
|
32
|
+
it('returns status ok with plan dirs', async () => {
|
|
33
|
+
const res = await request(app).get('/health');
|
|
34
|
+
expect(res.status).toBe(200);
|
|
35
|
+
expect(res.body.status).toBe('ok');
|
|
36
|
+
expect(res.body.plansDirs).toContain(testDir);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
describe('GET /api/plans', () => {
|
|
40
|
+
it('returns empty array when no plans exist', async () => {
|
|
41
|
+
const res = await request(app).get('/api/plans');
|
|
42
|
+
expect(res.status).toBe(200);
|
|
43
|
+
expect(res.body).toEqual([]);
|
|
44
|
+
});
|
|
45
|
+
it('returns list of plan files', async () => {
|
|
46
|
+
fs.writeFileSync(path.join(testDir, 'plan-a.md'), '# Plan A');
|
|
47
|
+
fs.writeFileSync(path.join(testDir, 'plan-b.md'), '# Plan B');
|
|
48
|
+
const res = await request(app).get('/api/plans');
|
|
49
|
+
expect(res.status).toBe(200);
|
|
50
|
+
expect(res.body).toHaveLength(2);
|
|
51
|
+
expect(res.body.map((p) => p.filename).sort()).toEqual(['plan-a.md', 'plan-b.md']);
|
|
52
|
+
});
|
|
53
|
+
it('sorts by modification time descending', async () => {
|
|
54
|
+
fs.writeFileSync(path.join(testDir, 'old.md'), '# Old');
|
|
55
|
+
// Touch the file with a future time to ensure ordering
|
|
56
|
+
const futureTime = new Date(Date.now() + 10000);
|
|
57
|
+
fs.writeFileSync(path.join(testDir, 'new.md'), '# New');
|
|
58
|
+
fs.utimesSync(path.join(testDir, 'new.md'), futureTime, futureTime);
|
|
59
|
+
const res = await request(app).get('/api/plans');
|
|
60
|
+
expect(res.body[0].filename).toBe('new.md');
|
|
61
|
+
});
|
|
62
|
+
it('excludes .review.json files', async () => {
|
|
63
|
+
fs.writeFileSync(path.join(testDir, 'plan.md'), '# Plan');
|
|
64
|
+
fs.writeFileSync(path.join(testDir, 'plan.review.json'), '{}');
|
|
65
|
+
const res = await request(app).get('/api/plans');
|
|
66
|
+
expect(res.body).toHaveLength(1);
|
|
67
|
+
expect(res.body[0].filename).toBe('plan.md');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
describe('GET /api/plans/:filename', () => {
|
|
71
|
+
it('returns parsed plan data', async () => {
|
|
72
|
+
fs.writeFileSync(path.join(testDir, 'test.md'), '# My Plan\n\nSome content');
|
|
73
|
+
const res = await request(app).get('/api/plans/test.md');
|
|
74
|
+
expect(res.status).toBe(200);
|
|
75
|
+
expect(res.body.filename).toBe('test.md');
|
|
76
|
+
expect(res.body.parsed.title).toBe('My Plan');
|
|
77
|
+
expect(res.body.parsed.rawMarkdown).toContain('Some content');
|
|
78
|
+
});
|
|
79
|
+
it('returns 404 for missing plan', async () => {
|
|
80
|
+
const res = await request(app).get('/api/plans/missing.md');
|
|
81
|
+
expect(res.status).toBe(404);
|
|
82
|
+
});
|
|
83
|
+
it('returns 400 for non-.md filename', async () => {
|
|
84
|
+
const res = await request(app).get('/api/plans/bad.txt');
|
|
85
|
+
expect(res.status).toBe(400);
|
|
86
|
+
});
|
|
87
|
+
it('returns 400 for path traversal', async () => {
|
|
88
|
+
const res = await request(app).get('/api/plans/..%2Fetc%2Fpasswd.md');
|
|
89
|
+
expect(res.status).toBe(400);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
describe('POST /api/plan-updated', () => {
|
|
93
|
+
it('returns ok', async () => {
|
|
94
|
+
const res = await request(app)
|
|
95
|
+
.post('/api/plan-updated')
|
|
96
|
+
.send({ filePath: '/some/path/plan.md' });
|
|
97
|
+
expect(res.status).toBe(200);
|
|
98
|
+
expect(res.body.ok).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
describe('POST /api/reviews/:filename', () => {
|
|
102
|
+
it('saves review and returns it', async () => {
|
|
103
|
+
fs.writeFileSync(path.join(testDir, 'plan.md'), '# Plan');
|
|
104
|
+
const res = await request(app)
|
|
105
|
+
.post('/api/reviews/plan.md')
|
|
106
|
+
.send({
|
|
107
|
+
action: 'approve',
|
|
108
|
+
overallComment: 'LGTM',
|
|
109
|
+
inlineComments: [],
|
|
110
|
+
});
|
|
111
|
+
expect(res.status).toBe(200);
|
|
112
|
+
expect(res.body.ok).toBe(true);
|
|
113
|
+
expect(res.body.review.action).toBe('approve');
|
|
114
|
+
expect(res.body.review.overallComment).toBe('LGTM');
|
|
115
|
+
// Verify file was written
|
|
116
|
+
const reviewPath = path.join(testDir, 'plan.review.json');
|
|
117
|
+
expect(fs.existsSync(reviewPath)).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
it('returns 404 for missing plan', async () => {
|
|
120
|
+
const res = await request(app)
|
|
121
|
+
.post('/api/reviews/missing.md')
|
|
122
|
+
.send({ action: 'approve' });
|
|
123
|
+
expect(res.status).toBe(404);
|
|
124
|
+
});
|
|
125
|
+
it('returns 400 for path traversal', async () => {
|
|
126
|
+
const res = await request(app)
|
|
127
|
+
.post('/api/reviews/..%2Fhack.md')
|
|
128
|
+
.send({ action: 'approve' });
|
|
129
|
+
expect(res.status).toBe(400);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
describe('GET /api/reviews/:filename', () => {
|
|
133
|
+
it('returns saved review', async () => {
|
|
134
|
+
fs.writeFileSync(path.join(testDir, 'plan.md'), '# Plan');
|
|
135
|
+
// First save a review
|
|
136
|
+
await request(app)
|
|
137
|
+
.post('/api/reviews/plan.md')
|
|
138
|
+
.send({ action: 'feedback', overallComment: 'Nice' });
|
|
139
|
+
const res = await request(app).get('/api/reviews/plan.md');
|
|
140
|
+
expect(res.status).toBe(200);
|
|
141
|
+
expect(res.body.action).toBe('feedback');
|
|
142
|
+
expect(res.body.overallComment).toBe('Nice');
|
|
143
|
+
});
|
|
144
|
+
it('returns 404 when no review exists', async () => {
|
|
145
|
+
fs.writeFileSync(path.join(testDir, 'plan.md'), '# Plan');
|
|
146
|
+
const res = await request(app).get('/api/reviews/plan.md');
|
|
147
|
+
expect(res.status).toBe(404);
|
|
148
|
+
});
|
|
149
|
+
it('returns 404 for missing plan file', async () => {
|
|
150
|
+
const res = await request(app).get('/api/reviews/missing.md');
|
|
151
|
+
expect(res.status).toBe(404);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -1,178 +1,36 @@
|
|
|
1
|
-
import express from 'express';
|
|
2
|
-
import { createServer } from 'node:http';
|
|
3
|
-
import { WebSocketServer, WebSocket } from 'ws';
|
|
4
1
|
import fs from 'node:fs';
|
|
5
2
|
import path from 'node:path';
|
|
6
3
|
import os from 'node:os';
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
4
|
+
import { WebSocket } from 'ws';
|
|
5
|
+
import { createApp } from './app.js';
|
|
9
6
|
import { writePidFile, writePortFile, cleanupFiles, resetIdleTimer } from './lifecycle.js';
|
|
10
7
|
import { watchPlansDir } from './planWatcher.js';
|
|
11
8
|
const PORT = parseInt(process.env.PORT || '3847', 10);
|
|
12
|
-
// Auto-detect plans
|
|
13
|
-
function
|
|
9
|
+
// Auto-detect all ~/.claude*/plans/ directories
|
|
10
|
+
function findAllPlansDirs() {
|
|
14
11
|
const home = os.homedir();
|
|
15
|
-
const candidates = [
|
|
16
|
-
path.join(home, '.claude-personal', 'plans'),
|
|
17
|
-
path.join(home, '.claude', 'plans'),
|
|
18
|
-
];
|
|
19
|
-
for (const dir of candidates) {
|
|
20
|
-
if (fs.existsSync(dir))
|
|
21
|
-
return dir;
|
|
22
|
-
}
|
|
23
|
-
// Default to first candidate even if it doesn't exist yet
|
|
24
|
-
return candidates[0];
|
|
25
|
-
}
|
|
26
|
-
const plansDir = findPlansDir();
|
|
27
|
-
const app = express();
|
|
28
|
-
const server = createServer(app);
|
|
29
|
-
const wss = new WebSocketServer({ server });
|
|
30
|
-
app.use(express.json());
|
|
31
|
-
// Reset idle timer on every request
|
|
32
|
-
app.use((_req, _res, next) => {
|
|
33
|
-
resetIdleTimer();
|
|
34
|
-
next();
|
|
35
|
-
});
|
|
36
|
-
// Health check
|
|
37
|
-
app.get('/health', (_req, res) => {
|
|
38
|
-
res.json({ status: 'ok', plansDir });
|
|
39
|
-
});
|
|
40
|
-
// List all plans
|
|
41
|
-
app.get('/api/plans', (_req, res) => {
|
|
42
|
-
try {
|
|
43
|
-
if (!fs.existsSync(plansDir)) {
|
|
44
|
-
res.json([]);
|
|
45
|
-
return;
|
|
46
|
-
}
|
|
47
|
-
const files = fs.readdirSync(plansDir)
|
|
48
|
-
.filter(f => f.endsWith('.md') && !f.endsWith('.review.json'))
|
|
49
|
-
.map(f => {
|
|
50
|
-
const filePath = path.join(plansDir, f);
|
|
51
|
-
const stat = fs.statSync(filePath);
|
|
52
|
-
const review = getReview(filePath);
|
|
53
|
-
return {
|
|
54
|
-
filename: f,
|
|
55
|
-
modified: stat.mtime.toISOString(),
|
|
56
|
-
size: stat.size,
|
|
57
|
-
hasReview: !!review,
|
|
58
|
-
reviewAction: review?.action ?? null,
|
|
59
|
-
};
|
|
60
|
-
})
|
|
61
|
-
.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime());
|
|
62
|
-
res.json(files);
|
|
63
|
-
}
|
|
64
|
-
catch (err) {
|
|
65
|
-
res.status(500).json({ error: 'Failed to list plans' });
|
|
66
|
-
}
|
|
67
|
-
});
|
|
68
|
-
// Get a specific plan
|
|
69
|
-
app.get('/api/plans/:filename', (req, res) => {
|
|
70
|
-
const filename = req.params.filename;
|
|
71
|
-
if (!filename.endsWith('.md') || filename.includes('..')) {
|
|
72
|
-
res.status(400).json({ error: 'Invalid filename' });
|
|
73
|
-
return;
|
|
74
|
-
}
|
|
75
|
-
const filePath = path.join(plansDir, filename);
|
|
76
12
|
try {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
13
|
+
return fs.readdirSync(home)
|
|
14
|
+
.filter(name => name.startsWith('.claude'))
|
|
15
|
+
.map(name => path.join(home, name, 'plans'))
|
|
16
|
+
.filter(dir => fs.existsSync(dir));
|
|
81
17
|
}
|
|
82
18
|
catch {
|
|
83
|
-
|
|
84
|
-
}
|
|
85
|
-
});
|
|
86
|
-
// Hook notifies of plan update
|
|
87
|
-
app.post('/api/plan-updated', (req, res) => {
|
|
88
|
-
const { filePath, planOptions } = req.body;
|
|
89
|
-
const filename = path.basename(filePath || '');
|
|
90
|
-
// Broadcast to all WebSocket clients
|
|
91
|
-
const message = JSON.stringify({
|
|
92
|
-
type: 'plan-updated',
|
|
93
|
-
filename,
|
|
94
|
-
planOptions: planOptions || null,
|
|
95
|
-
});
|
|
96
|
-
for (const client of wss.clients) {
|
|
97
|
-
if (client.readyState === WebSocket.OPEN) {
|
|
98
|
-
client.send(message);
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
res.json({ ok: true });
|
|
102
|
-
});
|
|
103
|
-
// Save a review
|
|
104
|
-
app.post('/api/reviews/:filename', (req, res) => {
|
|
105
|
-
const filename = req.params.filename;
|
|
106
|
-
if (!filename.endsWith('.md') || filename.includes('..')) {
|
|
107
|
-
res.status(400).json({ error: 'Invalid filename' });
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
const filePath = path.join(plansDir, filename);
|
|
111
|
-
if (!fs.existsSync(filePath)) {
|
|
112
|
-
res.status(404).json({ error: 'Plan not found' });
|
|
113
|
-
return;
|
|
114
|
-
}
|
|
115
|
-
const review = {
|
|
116
|
-
planFile: filename,
|
|
117
|
-
action: req.body.action || 'feedback',
|
|
118
|
-
submittedAt: new Date().toISOString(),
|
|
119
|
-
consumedAt: null,
|
|
120
|
-
overallComment: req.body.overallComment || '',
|
|
121
|
-
inlineComments: req.body.inlineComments || [],
|
|
122
|
-
};
|
|
123
|
-
saveReview(filePath, review);
|
|
124
|
-
// Notify clients
|
|
125
|
-
const message = JSON.stringify({
|
|
126
|
-
type: 'review-submitted',
|
|
127
|
-
filename,
|
|
128
|
-
action: review.action,
|
|
129
|
-
});
|
|
130
|
-
for (const client of wss.clients) {
|
|
131
|
-
if (client.readyState === WebSocket.OPEN) {
|
|
132
|
-
client.send(message);
|
|
133
|
-
}
|
|
19
|
+
return [path.join(home, '.claude', 'plans')];
|
|
134
20
|
}
|
|
135
|
-
res.json({ ok: true, review });
|
|
136
|
-
});
|
|
137
|
-
// Get a review
|
|
138
|
-
app.get('/api/reviews/:filename', (req, res) => {
|
|
139
|
-
const filename = req.params.filename;
|
|
140
|
-
const filePath = path.join(plansDir, filename);
|
|
141
|
-
const review = getReview(filePath);
|
|
142
|
-
if (!review) {
|
|
143
|
-
res.status(404).json({ error: 'No review found' });
|
|
144
|
-
return;
|
|
145
|
-
}
|
|
146
|
-
res.json(review);
|
|
147
|
-
});
|
|
148
|
-
// Serve SPA static files
|
|
149
|
-
// Try multiple paths: dist/client relative to project root, or relative to compiled output
|
|
150
|
-
const clientDistCandidates = [
|
|
151
|
-
path.join(import.meta.dirname, '..', '..', 'client'), // prod: dist/server/server/ → dist/client/
|
|
152
|
-
path.join(import.meta.dirname, '..', 'dist', 'client'), // dev: server/ → dist/client/
|
|
153
|
-
];
|
|
154
|
-
const clientDist = clientDistCandidates.find((d) => fs.existsSync(d));
|
|
155
|
-
if (clientDist) {
|
|
156
|
-
app.use(express.static(clientDist));
|
|
157
|
-
app.get('/{*path}', (_req, res) => {
|
|
158
|
-
res.sendFile(path.join(clientDist, 'index.html'));
|
|
159
|
-
});
|
|
160
21
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
resetIdleTimer();
|
|
164
|
-
ws.on('message', () => resetIdleTimer());
|
|
165
|
-
});
|
|
22
|
+
const plansDirs = findAllPlansDirs();
|
|
23
|
+
const { server, wss } = createApp(plansDirs);
|
|
166
24
|
// Start
|
|
167
25
|
server.listen(PORT, () => {
|
|
168
26
|
writePidFile();
|
|
169
27
|
writePortFile(PORT);
|
|
170
28
|
resetIdleTimer();
|
|
171
29
|
console.log(`[cc-plan-viewer] Server running at http://localhost:${PORT}`);
|
|
172
|
-
console.log(`[cc-plan-viewer] Plans
|
|
30
|
+
console.log(`[cc-plan-viewer] Plans directories: ${plansDirs.join(', ')}`);
|
|
173
31
|
});
|
|
174
|
-
// Watch
|
|
175
|
-
|
|
32
|
+
// Watch all plan directories for changes
|
|
33
|
+
const onPlanChange = (filename, _content) => {
|
|
176
34
|
const message = JSON.stringify({
|
|
177
35
|
type: 'plan-updated',
|
|
178
36
|
filename,
|
|
@@ -182,7 +40,10 @@ watchPlansDir(plansDir, (filename, content) => {
|
|
|
182
40
|
client.send(message);
|
|
183
41
|
}
|
|
184
42
|
}
|
|
185
|
-
}
|
|
43
|
+
};
|
|
44
|
+
for (const dir of plansDirs) {
|
|
45
|
+
watchPlansDir(dir, onPlanChange);
|
|
46
|
+
}
|
|
186
47
|
// Cleanup on exit
|
|
187
48
|
process.on('SIGINT', () => { cleanupFiles(); process.exit(0); });
|
|
188
49
|
process.on('SIGTERM', () => { cleanupFiles(); process.exit(0); });
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
const mockFs = vi.hoisted(() => ({
|
|
3
|
+
writeFileSync: vi.fn(),
|
|
4
|
+
readFileSync: vi.fn(),
|
|
5
|
+
unlinkSync: vi.fn(),
|
|
6
|
+
}));
|
|
7
|
+
vi.mock('node:fs', () => ({ default: mockFs, ...mockFs }));
|
|
8
|
+
vi.mock('node:os', () => ({
|
|
9
|
+
default: { tmpdir: () => '/tmp' },
|
|
10
|
+
tmpdir: () => '/tmp',
|
|
11
|
+
}));
|
|
12
|
+
const { writePidFile, writePortFile, cleanupFiles, readPortFile, isServerRunning, resetIdleTimer } = await import('./lifecycle.js');
|
|
13
|
+
describe('lifecycle', () => {
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
vi.resetAllMocks();
|
|
16
|
+
vi.useFakeTimers();
|
|
17
|
+
});
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
vi.useRealTimers();
|
|
20
|
+
});
|
|
21
|
+
describe('writePidFile', () => {
|
|
22
|
+
it('writes process PID to correct path', () => {
|
|
23
|
+
writePidFile();
|
|
24
|
+
expect(mockFs.writeFileSync).toHaveBeenCalledWith('/tmp/cc-plan-viewer.pid', String(process.pid), 'utf8');
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
describe('writePortFile', () => {
|
|
28
|
+
it('writes port number to correct path', () => {
|
|
29
|
+
writePortFile(3847);
|
|
30
|
+
expect(mockFs.writeFileSync).toHaveBeenCalledWith('/tmp/cc-plan-viewer-port', '3847', 'utf8');
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
describe('cleanupFiles', () => {
|
|
34
|
+
it('unlinks both PID and port files', () => {
|
|
35
|
+
cleanupFiles();
|
|
36
|
+
expect(mockFs.unlinkSync).toHaveBeenCalledWith('/tmp/cc-plan-viewer.pid');
|
|
37
|
+
expect(mockFs.unlinkSync).toHaveBeenCalledWith('/tmp/cc-plan-viewer-port');
|
|
38
|
+
});
|
|
39
|
+
it('swallows errors when files do not exist', () => {
|
|
40
|
+
mockFs.unlinkSync.mockImplementation(() => {
|
|
41
|
+
throw new Error('ENOENT');
|
|
42
|
+
});
|
|
43
|
+
expect(() => cleanupFiles()).not.toThrow();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
describe('readPortFile', () => {
|
|
47
|
+
it('returns port number from file', () => {
|
|
48
|
+
mockFs.readFileSync.mockReturnValue('3847');
|
|
49
|
+
expect(readPortFile()).toBe(3847);
|
|
50
|
+
});
|
|
51
|
+
it('returns null when file does not exist', () => {
|
|
52
|
+
mockFs.readFileSync.mockImplementation(() => {
|
|
53
|
+
throw new Error('ENOENT');
|
|
54
|
+
});
|
|
55
|
+
expect(readPortFile()).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
it('returns null when file contains non-numeric content', () => {
|
|
58
|
+
mockFs.readFileSync.mockReturnValue('not-a-number');
|
|
59
|
+
expect(readPortFile()).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
it('handles port with whitespace', () => {
|
|
62
|
+
mockFs.readFileSync.mockReturnValue(' 3847\n');
|
|
63
|
+
expect(readPortFile()).toBe(3847);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
describe('isServerRunning', () => {
|
|
67
|
+
it('returns true when PID file exists and process is alive', () => {
|
|
68
|
+
mockFs.readFileSync.mockReturnValue('12345');
|
|
69
|
+
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true);
|
|
70
|
+
expect(isServerRunning()).toBe(true);
|
|
71
|
+
expect(killSpy).toHaveBeenCalledWith(12345, 0);
|
|
72
|
+
killSpy.mockRestore();
|
|
73
|
+
});
|
|
74
|
+
it('returns false when PID file does not exist', () => {
|
|
75
|
+
mockFs.readFileSync.mockImplementation(() => {
|
|
76
|
+
throw new Error('ENOENT');
|
|
77
|
+
});
|
|
78
|
+
expect(isServerRunning()).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
it('returns false when process is not alive', () => {
|
|
81
|
+
mockFs.readFileSync.mockReturnValue('12345');
|
|
82
|
+
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => {
|
|
83
|
+
throw new Error('ESRCH');
|
|
84
|
+
});
|
|
85
|
+
expect(isServerRunning()).toBe(false);
|
|
86
|
+
killSpy.mockRestore();
|
|
87
|
+
});
|
|
88
|
+
it('returns false when PID is NaN', () => {
|
|
89
|
+
mockFs.readFileSync.mockReturnValue('garbage');
|
|
90
|
+
expect(isServerRunning()).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
describe('resetIdleTimer', () => {
|
|
94
|
+
it('sets a timer', () => {
|
|
95
|
+
const spy = vi.spyOn(global, 'setTimeout');
|
|
96
|
+
resetIdleTimer();
|
|
97
|
+
expect(spy).toHaveBeenCalled();
|
|
98
|
+
spy.mockRestore();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
});
|