@the-bearded-bear/claude-craft 7.35.0 → 8.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.
- package/Dev/scripts/install-php-rules.sh +1 -1
- package/Dev/scripts/validate-skills-spec.sh +121 -0
- package/README.md +13 -11
- package/cli/index.js +6 -0
- package/cli/kanban/client/index.html +17 -0
- package/cli/kanban/client/src/App.svelte +106 -0
- package/cli/kanban/client/src/app.css +175 -0
- package/cli/kanban/client/src/lib/router.svelte.js +19 -0
- package/cli/kanban/client/src/lib/store.svelte.js +132 -0
- package/cli/kanban/client/src/main.js +6 -0
- package/cli/kanban/client/src/views/BacklogView.svelte +344 -0
- package/cli/kanban/client/src/views/BurndownView.svelte +189 -0
- package/cli/kanban/client/src/views/DepsView.svelte +334 -0
- package/cli/kanban/client/src/views/DocsView.svelte +451 -0
- package/cli/kanban/client/src/views/KanbanView.svelte +227 -0
- package/cli/kanban/client/vite.config.js +21 -0
- package/cli/kanban/server/app.js +201 -0
- package/cli/kanban/server/middleware/security.js +53 -0
- package/cli/kanban/server/services/event-bus.js +33 -0
- package/cli/kanban/server/services/file-scanner.js +113 -0
- package/cli/kanban/server/services/file-watcher.js +68 -0
- package/cli/kanban/server/services/file-writer.js +107 -0
- package/cli/kanban/server/services/frontmatter.js +55 -0
- package/cli/kanban/server/services/repository.js +173 -0
- package/cli/kanban/server/services/sprint-cache.js +208 -0
- package/cli/kanban/server/services/state-machine.js +156 -0
- package/cli/kanban/shared/schemas.js +127 -0
- package/cli/lib/help.js +4 -0
- package/cli/lib/kanban.js +103 -0
- package/package.json +21 -3
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { streamSSE } from 'hono/streaming';
|
|
3
|
+
import { serveStatic } from '@hono/node-server/serve-static';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import { csrfGuard, readonlyGuard, resolveSafe } from './middleware/security.js';
|
|
8
|
+
import { validateTransition, validateUnblock, computeTransitionPatch } from './services/state-machine.js';
|
|
9
|
+
import { updateFrontmatter, LockedError, MtimeMismatchError } from './services/file-writer.js';
|
|
10
|
+
import { loadSprintStatus, computeBurndown } from './services/sprint-cache.js';
|
|
11
|
+
import { TransitionRequestSchema } from '../shared/schemas.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Build the Hono app. Kept separate from the HTTP server boot so tests can
|
|
15
|
+
* invoke routes via app.request() without opening a socket.
|
|
16
|
+
*
|
|
17
|
+
* @param {object} opts
|
|
18
|
+
* @param {import('./services/repository.js').Repository} opts.repository
|
|
19
|
+
* @param {number} opts.port
|
|
20
|
+
* @param {boolean} [opts.readonly=false]
|
|
21
|
+
* @param {string} [opts.projectRoot] - Parent of project-management/, defaults to parent of repository.rootDir
|
|
22
|
+
* @returns {Hono}
|
|
23
|
+
*/
|
|
24
|
+
export function createApp({ repository, port, readonly = false, eventBus = null, projectRoot = null }) {
|
|
25
|
+
const resolvedProjectRoot = projectRoot ?? path.dirname(repository.rootDir);
|
|
26
|
+
const app = new Hono();
|
|
27
|
+
|
|
28
|
+
app.use('*', csrfGuard(port));
|
|
29
|
+
app.use('*', readonlyGuard(readonly));
|
|
30
|
+
|
|
31
|
+
app.get('/api/health', (c) => c.json({ ok: true, readonly }));
|
|
32
|
+
|
|
33
|
+
app.get('/api/stories', (c) => {
|
|
34
|
+
const sprintId = c.req.query('sprint_id') || undefined;
|
|
35
|
+
const status = c.req.query('status') || undefined;
|
|
36
|
+
const epicId = c.req.query('epic_id') || undefined;
|
|
37
|
+
return c.json({
|
|
38
|
+
stories: repository.listStories({ sprintId, status, epicId }),
|
|
39
|
+
epics: repository.listEpics(),
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
app.get('/api/stories/:id', (c) => {
|
|
44
|
+
const id = c.req.param('id');
|
|
45
|
+
const story = repository.getStory(id);
|
|
46
|
+
if (!story) return c.json({ error: 'not_found' }, 404);
|
|
47
|
+
const tasks = repository.listTasksForStory(id);
|
|
48
|
+
return c.json({ story, tasks });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
app.get('/api/dependencies', (c) => {
|
|
52
|
+
return c.json(repository.buildDependenciesGraph());
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
app.get('/api/docs', (c) => {
|
|
56
|
+
return c.json({ docs: repository.listDocs().map((d) => ({ rel: d.rel })) });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
app.get('/api/docs/:rel{.+}', async (c) => {
|
|
60
|
+
const rel = c.req.param('rel');
|
|
61
|
+
try {
|
|
62
|
+
resolveSafe(repository.rootDir, rel);
|
|
63
|
+
} catch {
|
|
64
|
+
return c.json({ error: 'invalid_path' }, 400);
|
|
65
|
+
}
|
|
66
|
+
const content = await repository.readDoc(rel);
|
|
67
|
+
if (content === null) return c.json({ error: 'not_found' }, 404);
|
|
68
|
+
return c.text(content);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
app.patch('/api/stories/:id/status', async (c) => {
|
|
72
|
+
const id = c.req.param('id');
|
|
73
|
+
const filepath = repository.getStoryFile(id);
|
|
74
|
+
const story = repository.getStory(id);
|
|
75
|
+
if (!story || !filepath) return c.json({ error: 'not_found' }, 404);
|
|
76
|
+
|
|
77
|
+
let body;
|
|
78
|
+
try {
|
|
79
|
+
body = await c.req.json();
|
|
80
|
+
} catch {
|
|
81
|
+
return c.json({ error: 'invalid_json' }, 400);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const parsed = TransitionRequestSchema.safeParse(body);
|
|
85
|
+
if (!parsed.success) {
|
|
86
|
+
return c.json({ error: 'invalid_payload', issues: parsed.error.issues }, 400);
|
|
87
|
+
}
|
|
88
|
+
const { status: target, blocked_reason } = parsed.data;
|
|
89
|
+
|
|
90
|
+
// Unblock : dispatch when story is blocked and target matches previous_status
|
|
91
|
+
if (story.status === 'blocked' && target !== 'blocked') {
|
|
92
|
+
const unblock = validateUnblock(story);
|
|
93
|
+
if (!unblock.allowed) {
|
|
94
|
+
return c.json({ error: 'invalid_transition', reason: unblock.reason }, 409);
|
|
95
|
+
}
|
|
96
|
+
if (unblock.status !== target) {
|
|
97
|
+
return c.json(
|
|
98
|
+
{
|
|
99
|
+
error: 'invalid_transition',
|
|
100
|
+
reason: `blocked stories may only be restored to previous_status (${unblock.status})`,
|
|
101
|
+
},
|
|
102
|
+
409
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
} else {
|
|
106
|
+
const check = validateTransition(story, target, { blocked_reason });
|
|
107
|
+
if (!check.allowed) {
|
|
108
|
+
return c.json(
|
|
109
|
+
{
|
|
110
|
+
error: 'invalid_transition',
|
|
111
|
+
reason: check.reason,
|
|
112
|
+
gate: check.gate,
|
|
113
|
+
missing: check.missing,
|
|
114
|
+
},
|
|
115
|
+
409
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const patch = computeTransitionPatch(story, target, { blocked_reason });
|
|
121
|
+
try {
|
|
122
|
+
await updateFrontmatter(filepath, patch, { expectedMtime: story._mtime });
|
|
123
|
+
await repository.refresh();
|
|
124
|
+
const fresh = repository.getStory(id);
|
|
125
|
+
if (eventBus) eventBus.publish('story:updated', { id, story: fresh });
|
|
126
|
+
return c.json({ story: fresh });
|
|
127
|
+
} catch (err) {
|
|
128
|
+
if (err instanceof LockedError) return c.json({ error: 'locked' }, 423);
|
|
129
|
+
if (err instanceof MtimeMismatchError) return c.json({ error: 'conflict', reason: 'file changed on disk' }, 409);
|
|
130
|
+
return c.json({ error: 'write_failed', message: err.message }, 500);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
app.get('/api/events', (c) => {
|
|
135
|
+
if (!eventBus) return c.json({ error: 'events_disabled' }, 501);
|
|
136
|
+
return streamSSE(c, async (stream) => {
|
|
137
|
+
const unsubscribe = eventBus.subscribe((msg) => {
|
|
138
|
+
stream
|
|
139
|
+
.writeSSE({
|
|
140
|
+
event: msg.event,
|
|
141
|
+
data: JSON.stringify(msg),
|
|
142
|
+
})
|
|
143
|
+
.catch(() => {
|
|
144
|
+
/* client disconnected */
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
stream.onAbort(() => unsubscribe());
|
|
148
|
+
// heartbeat toutes les 30s
|
|
149
|
+
try {
|
|
150
|
+
while (!stream.aborted) {
|
|
151
|
+
await stream.sleep(30_000);
|
|
152
|
+
await stream.writeSSE({ event: 'heartbeat', data: String(Date.now()) });
|
|
153
|
+
}
|
|
154
|
+
} catch {
|
|
155
|
+
/* aborted */
|
|
156
|
+
}
|
|
157
|
+
unsubscribe();
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
app.get('/api/sprints/current', async (c) => {
|
|
162
|
+
const sprintStatus = await loadSprintStatus(resolvedProjectRoot);
|
|
163
|
+
if (!sprintStatus || sprintStatus._invalid) {
|
|
164
|
+
return c.json({ error: 'not_found' }, 404);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const sprintId = sprintStatus.metadata.sprint_id;
|
|
168
|
+
const stories = repository.listStories({ sprintId });
|
|
169
|
+
if (stories.length === 0) {
|
|
170
|
+
return c.json({ error: 'not_found' }, 404);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const burndown = computeBurndown(sprintStatus, stories);
|
|
174
|
+
|
|
175
|
+
return c.json({
|
|
176
|
+
sprint: sprintStatus.metadata,
|
|
177
|
+
stories: stories.map((s) => ({
|
|
178
|
+
id: s.id,
|
|
179
|
+
title: s.title,
|
|
180
|
+
status: s.status,
|
|
181
|
+
story_points: s.story_points,
|
|
182
|
+
assigned_to: s.assigned_to,
|
|
183
|
+
tasks: s.tasks,
|
|
184
|
+
})),
|
|
185
|
+
burndown,
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
190
|
+
const clientDist = path.resolve(__dirname, '../client/dist');
|
|
191
|
+
if (fs.existsSync(clientDist)) {
|
|
192
|
+
app.use('/assets/*', serveStatic({ root: path.relative(process.cwd(), clientDist) }));
|
|
193
|
+
app.get('/', (c) => c.html(fs.readFileSync(path.join(clientDist, 'index.html'), 'utf8')));
|
|
194
|
+
} else {
|
|
195
|
+
app.get('/', (c) =>
|
|
196
|
+
c.text('claude-craft kanban : client not built.\n' + 'Run "npm run kanban:build" to build the UI.\n')
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return app;
|
|
201
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Middleware factory : enforces same-origin for mutating requests.
|
|
5
|
+
* Only http://127.0.0.1:<port> and http://localhost:<port> are accepted.
|
|
6
|
+
*/
|
|
7
|
+
export function csrfGuard(port) {
|
|
8
|
+
const allowed = new Set([`http://127.0.0.1:${port}`, `http://localhost:${port}`]);
|
|
9
|
+
return async (c, next) => {
|
|
10
|
+
if (!['POST', 'PATCH', 'PUT', 'DELETE'].includes(c.req.method)) {
|
|
11
|
+
return next();
|
|
12
|
+
}
|
|
13
|
+
const origin = c.req.header('origin');
|
|
14
|
+
const referer = c.req.header('referer');
|
|
15
|
+
const source = origin ?? (referer ? new URL(referer).origin : null);
|
|
16
|
+
if (!source || !allowed.has(source)) {
|
|
17
|
+
return c.json({ error: 'forbidden', reason: 'invalid origin' }, 403);
|
|
18
|
+
}
|
|
19
|
+
return next();
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Resolves a user-provided relative path safely under a base directory.
|
|
25
|
+
* Throws on path traversal attempts.
|
|
26
|
+
* @param {string} baseDir - absolute, trusted
|
|
27
|
+
* @param {string} userPath - user-provided, untrusted
|
|
28
|
+
* @returns {string} absolute resolved path, guaranteed under baseDir
|
|
29
|
+
*/
|
|
30
|
+
export function resolveSafe(baseDir, userPath) {
|
|
31
|
+
const base = path.resolve(baseDir);
|
|
32
|
+
const resolved = path.resolve(base, userPath);
|
|
33
|
+
const rel = path.relative(base, resolved);
|
|
34
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
35
|
+
const err = new Error('path traversal detected');
|
|
36
|
+
err.code = 'PATH_TRAVERSAL';
|
|
37
|
+
throw err;
|
|
38
|
+
}
|
|
39
|
+
return resolved;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Rejects requests when --readonly mode is active.
|
|
44
|
+
*/
|
|
45
|
+
export function readonlyGuard(enabled) {
|
|
46
|
+
return async (c, next) => {
|
|
47
|
+
if (!enabled) return next();
|
|
48
|
+
if (['POST', 'PATCH', 'PUT', 'DELETE'].includes(c.req.method)) {
|
|
49
|
+
return c.json({ error: 'readonly', reason: 'server started in --readonly mode' }, 403);
|
|
50
|
+
}
|
|
51
|
+
return next();
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal in-memory event bus with SSE-friendly fan-out.
|
|
3
|
+
* Subscribers receive objects ; the route layer serializes them for SSE.
|
|
4
|
+
*/
|
|
5
|
+
export class EventBus {
|
|
6
|
+
constructor() {
|
|
7
|
+
this.subscribers = new Set();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
subscribe(fn) {
|
|
11
|
+
this.subscribers.add(fn);
|
|
12
|
+
return () => this.subscribers.delete(fn);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
publish(event, payload) {
|
|
16
|
+
const msg = { event, payload, ts: Date.now() };
|
|
17
|
+
for (const fn of [...this.subscribers]) {
|
|
18
|
+
try {
|
|
19
|
+
fn(msg);
|
|
20
|
+
} catch {
|
|
21
|
+
/* isolate subscriber failures */
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
get size() {
|
|
27
|
+
return this.subscribers.size;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
clear() {
|
|
31
|
+
this.subscribers.clear();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Recursive scanner for a project-management/ directory (BMAD v6 layout).
|
|
6
|
+
* Returns categorized file paths with mtime for cache invalidation.
|
|
7
|
+
*
|
|
8
|
+
* Layout :
|
|
9
|
+
* project-management/
|
|
10
|
+
* prd.md, tech-spec.md, personas.md, definition-of-done.md, dependencies-matrix.md
|
|
11
|
+
* architecture/*.md
|
|
12
|
+
* backlog/epics/EPIC-*.md
|
|
13
|
+
* backlog/user-stories/US-*.md
|
|
14
|
+
* sprints/sprint-*\/{sprint-goal,board,status-*}.md
|
|
15
|
+
* sprints/sprint-*\/tasks/TASK-*.md
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const IGNORE_PATTERNS = [/\.bak$/, /\.lock$/, /^\./];
|
|
19
|
+
|
|
20
|
+
function isIgnored(name) {
|
|
21
|
+
return IGNORE_PATTERNS.some((re) => re.test(name));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function walk(dir, onFile) {
|
|
25
|
+
let entries;
|
|
26
|
+
try {
|
|
27
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
28
|
+
} catch (err) {
|
|
29
|
+
if (err.code === 'ENOENT') return;
|
|
30
|
+
throw err;
|
|
31
|
+
}
|
|
32
|
+
for (const entry of entries) {
|
|
33
|
+
if (isIgnored(entry.name)) continue;
|
|
34
|
+
const full = path.join(dir, entry.name);
|
|
35
|
+
if (entry.isDirectory()) {
|
|
36
|
+
await walk(full, onFile);
|
|
37
|
+
} else if (entry.isFile()) {
|
|
38
|
+
await onFile(full);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function classify(rootDir, absPath) {
|
|
44
|
+
const rel = path.relative(rootDir, absPath).split(path.sep).join('/');
|
|
45
|
+
if (!rel.endsWith('.md') && !rel.endsWith('.yaml') && !rel.endsWith('.yml')) return null;
|
|
46
|
+
|
|
47
|
+
if (rel.startsWith('backlog/user-stories/') && /US-\d+/.test(rel)) return 'story';
|
|
48
|
+
if (rel.startsWith('backlog/epics/') && /EPIC-\d+/.test(rel)) return 'epic';
|
|
49
|
+
|
|
50
|
+
const sprintMatch = rel.match(/^sprints\/([^/]+)\/(.+)$/);
|
|
51
|
+
if (sprintMatch) {
|
|
52
|
+
const [, , rest] = sprintMatch;
|
|
53
|
+
if (rest.startsWith('tasks/') && /TASK-\d+/.test(rest)) return 'task';
|
|
54
|
+
if (rest === 'sprint-goal.md') return 'sprint-goal';
|
|
55
|
+
if (rest === 'board.md') return 'board';
|
|
56
|
+
if (rest.startsWith('status-')) return 'sprint-status-snapshot';
|
|
57
|
+
if (rest === 'sprint-review.md') return 'sprint-review';
|
|
58
|
+
if (rest === 'sprint-dependencies.md') return 'sprint-deps';
|
|
59
|
+
return 'sprint-other';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (rel.startsWith('architecture/')) return 'architecture';
|
|
63
|
+
|
|
64
|
+
const docs = new Set([
|
|
65
|
+
'prd.md',
|
|
66
|
+
'tech-spec.md',
|
|
67
|
+
'personas.md',
|
|
68
|
+
'definition-of-done.md',
|
|
69
|
+
'dependencies-matrix.md',
|
|
70
|
+
'README.md',
|
|
71
|
+
]);
|
|
72
|
+
if (docs.has(rel)) return 'doc';
|
|
73
|
+
if (rel === 'workflow-status.yaml') return 'workflow-status';
|
|
74
|
+
|
|
75
|
+
return 'other';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function sprintIdOf(rootDir, absPath) {
|
|
79
|
+
const rel = path.relative(rootDir, absPath).split(path.sep).join('/');
|
|
80
|
+
const m = rel.match(/^sprints\/([^/]+)\//);
|
|
81
|
+
return m ? m[1] : null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @param {string} rootDir - absolute path to project-management/
|
|
86
|
+
* @returns {Promise<{ files: Array<{path:string, category:string, mtime:number, sprintId:string|null}> }>}
|
|
87
|
+
*/
|
|
88
|
+
export async function scan(rootDir) {
|
|
89
|
+
const absRoot = path.resolve(rootDir);
|
|
90
|
+
const files = [];
|
|
91
|
+
await walk(absRoot, async (full) => {
|
|
92
|
+
const category = classify(absRoot, full);
|
|
93
|
+
if (!category) return;
|
|
94
|
+
const st = await stat(full);
|
|
95
|
+
files.push({
|
|
96
|
+
path: full,
|
|
97
|
+
category,
|
|
98
|
+
mtime: st.mtimeMs,
|
|
99
|
+
sprintId: sprintIdOf(absRoot, full),
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
return { files };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function groupByCategory(files) {
|
|
106
|
+
const groups = {};
|
|
107
|
+
for (const f of files) {
|
|
108
|
+
(groups[f.category] ??= []).push(f);
|
|
109
|
+
}
|
|
110
|
+
return groups;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const _internal = { classify, sprintIdOf };
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import chokidar from 'chokidar';
|
|
2
|
+
import { _internal } from './file-scanner.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Start watching project-management/ directory for changes.
|
|
6
|
+
* Publishes 'file:changed' events to eventBus after debounce.
|
|
7
|
+
*
|
|
8
|
+
* @param {object} opts
|
|
9
|
+
* @param {string} opts.rootDir - absolute path to watch
|
|
10
|
+
* @param {EventBus} opts.eventBus - bus to publish events
|
|
11
|
+
* @param {Repository} opts.repository - cache to refresh on changes
|
|
12
|
+
* @param {number} [opts.debounceMs=200] - debounce delay per path
|
|
13
|
+
* @returns {object} - { stop: async () => void }
|
|
14
|
+
*/
|
|
15
|
+
export function startWatcher({ rootDir, eventBus, repository, debounceMs = 200 }) {
|
|
16
|
+
const debounceTimers = new Map();
|
|
17
|
+
let watcher = null;
|
|
18
|
+
|
|
19
|
+
const onEvent = async (event, filePath) => {
|
|
20
|
+
const category = _internal.classify(rootDir, filePath);
|
|
21
|
+
if (!category || category === 'other') return;
|
|
22
|
+
|
|
23
|
+
if (debounceTimers.has(filePath)) {
|
|
24
|
+
clearTimeout(debounceTimers.get(filePath));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
debounceTimers.set(
|
|
28
|
+
filePath,
|
|
29
|
+
setTimeout(async () => {
|
|
30
|
+
debounceTimers.delete(filePath);
|
|
31
|
+
try {
|
|
32
|
+
await repository.refresh();
|
|
33
|
+
eventBus.publish('file:changed', { path: filePath, event, category });
|
|
34
|
+
} catch (err) {
|
|
35
|
+
console.error('[file-watcher] Error processing change:', err.message);
|
|
36
|
+
}
|
|
37
|
+
}, debounceMs)
|
|
38
|
+
);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
watcher = chokidar.watch(rootDir, {
|
|
43
|
+
ignored: [/\.bak$/, /\.lock$/, /\.tmp-/, /(^|[/\\])\../, /node_modules/],
|
|
44
|
+
ignoreInitial: true,
|
|
45
|
+
persistent: true,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
watcher
|
|
49
|
+
.on('add', (path) => onEvent('add', path))
|
|
50
|
+
.on('change', (path) => onEvent('change', path))
|
|
51
|
+
.on('unlink', (path) => onEvent('unlink', path));
|
|
52
|
+
} catch (err) {
|
|
53
|
+
console.error('[file-watcher] Failed to start watcher:', err.message);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
async stop() {
|
|
58
|
+
for (const timer of debounceTimers.values()) {
|
|
59
|
+
clearTimeout(timer);
|
|
60
|
+
}
|
|
61
|
+
debounceTimers.clear();
|
|
62
|
+
if (watcher) {
|
|
63
|
+
await watcher.close();
|
|
64
|
+
watcher = null;
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { open, rename, copyFile, unlink, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parseFile, stringify } from './frontmatter.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Atomic update of a Markdown file's frontmatter.
|
|
7
|
+
*
|
|
8
|
+
* Safety :
|
|
9
|
+
* 1. Exclusive lock file (<path>.lock, O_CREAT|O_EXCL) — refuses concurrent writes.
|
|
10
|
+
* 2. Optional mtime check (ETag-like) — rejects writes if file changed on disk.
|
|
11
|
+
* 3. Backup (<path>.bak) — restored if the write fails.
|
|
12
|
+
* 4. Write to a temp file then rename — atomic on POSIX.
|
|
13
|
+
* 5. Body of the markdown file is preserved byte-for-byte.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
class LockedError extends Error {
|
|
17
|
+
constructor(filepath) {
|
|
18
|
+
super(`File is locked: ${filepath}`);
|
|
19
|
+
this.code = 'LOCKED';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
class MtimeMismatchError extends Error {
|
|
24
|
+
constructor(filepath, expected, actual) {
|
|
25
|
+
super(`mtime mismatch for ${filepath}: expected ${expected}, got ${actual}`);
|
|
26
|
+
this.code = 'MTIME_MISMATCH';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function acquireLock(filepath) {
|
|
31
|
+
const lockPath = `${filepath}.lock`;
|
|
32
|
+
try {
|
|
33
|
+
const fd = await open(lockPath, 'wx');
|
|
34
|
+
await fd.close();
|
|
35
|
+
return lockPath;
|
|
36
|
+
} catch (err) {
|
|
37
|
+
if (err.code === 'EEXIST') throw new LockedError(filepath);
|
|
38
|
+
throw err;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function releaseLock(lockPath) {
|
|
43
|
+
try {
|
|
44
|
+
await unlink(lockPath);
|
|
45
|
+
} catch {
|
|
46
|
+
/* ignore */
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {string} filepath
|
|
52
|
+
* @param {object} patch - partial frontmatter to merge
|
|
53
|
+
* @param {object} [opts]
|
|
54
|
+
* @param {number} [opts.expectedMtime] - if provided, write only if current mtime matches
|
|
55
|
+
* @returns {Promise<{ data: object, body: string, mtime: number }>}
|
|
56
|
+
*/
|
|
57
|
+
export async function updateFrontmatter(filepath, patch, opts = {}) {
|
|
58
|
+
const abs = path.resolve(filepath);
|
|
59
|
+
const lockPath = await acquireLock(abs);
|
|
60
|
+
const backup = `${abs}.bak`;
|
|
61
|
+
try {
|
|
62
|
+
if (typeof opts.expectedMtime === 'number') {
|
|
63
|
+
const st = await stat(abs);
|
|
64
|
+
if (st.mtimeMs !== opts.expectedMtime) {
|
|
65
|
+
throw new MtimeMismatchError(abs, opts.expectedMtime, st.mtimeMs);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const { data, body } = await parseFile(abs);
|
|
70
|
+
await copyFile(abs, backup);
|
|
71
|
+
|
|
72
|
+
const merged = { ...data, ...patch };
|
|
73
|
+
const next = stringify(merged, body);
|
|
74
|
+
const tmp = `${abs}.tmp-${process.pid}-${Date.now()}`;
|
|
75
|
+
try {
|
|
76
|
+
await writeFile(tmp, next, 'utf8');
|
|
77
|
+
await rename(tmp, abs);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
// Rollback from backup
|
|
80
|
+
try {
|
|
81
|
+
await copyFile(backup, abs);
|
|
82
|
+
} catch {
|
|
83
|
+
/* best effort */
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
await unlink(tmp);
|
|
87
|
+
} catch {
|
|
88
|
+
/* ignore */
|
|
89
|
+
}
|
|
90
|
+
throw err;
|
|
91
|
+
}
|
|
92
|
+
await unlink(backup);
|
|
93
|
+
const st = await stat(abs);
|
|
94
|
+
return { data: merged, body, mtime: st.mtimeMs };
|
|
95
|
+
} catch (err) {
|
|
96
|
+
try {
|
|
97
|
+
await unlink(backup);
|
|
98
|
+
} catch {
|
|
99
|
+
/* backup may not exist yet */
|
|
100
|
+
}
|
|
101
|
+
throw err;
|
|
102
|
+
} finally {
|
|
103
|
+
await releaseLock(lockPath);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export { LockedError, MtimeMismatchError };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import matter from 'gray-matter';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Parse a Markdown file with YAML frontmatter.
|
|
6
|
+
* Returns { data, body, raw } where `data` is the parsed frontmatter,
|
|
7
|
+
* `body` is everything after the second '---', and `raw` is the full file.
|
|
8
|
+
*
|
|
9
|
+
* gray-matter handles missing frontmatter gracefully (returns data = {}).
|
|
10
|
+
*
|
|
11
|
+
* @param {string} filepath
|
|
12
|
+
* @returns {Promise<{ data: object, body: string, raw: string }>}
|
|
13
|
+
*/
|
|
14
|
+
export async function parseFile(filepath) {
|
|
15
|
+
const raw = await readFile(filepath, 'utf8');
|
|
16
|
+
return parseString(raw);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Parse a Markdown string with YAML frontmatter.
|
|
21
|
+
*/
|
|
22
|
+
export function parseString(raw) {
|
|
23
|
+
const { data, content } = matter(raw);
|
|
24
|
+
return { data: data ?? {}, body: content ?? '', raw };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Serialize frontmatter + body back to a Markdown string.
|
|
29
|
+
* Uses gray-matter's stringify which preserves YAML canonical format.
|
|
30
|
+
*
|
|
31
|
+
* @param {object} data
|
|
32
|
+
* @param {string} body
|
|
33
|
+
* @returns {string}
|
|
34
|
+
*/
|
|
35
|
+
export function stringify(data, body) {
|
|
36
|
+
return matter.stringify(body ?? '', data ?? {});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Parse and validate frontmatter against a Zod schema.
|
|
41
|
+
* @param {string} filepath
|
|
42
|
+
* @param {import('zod').ZodTypeAny} schema
|
|
43
|
+
* @returns {Promise<{ ok: true, data: object, body: string } | { ok: false, errors: Array, body: string, raw: any }>}
|
|
44
|
+
*/
|
|
45
|
+
export async function parseAndValidate(filepath, schema) {
|
|
46
|
+
const { data, body } = await parseFile(filepath);
|
|
47
|
+
const result = schema.safeParse(data);
|
|
48
|
+
if (result.success) return { ok: true, data: result.data, body };
|
|
49
|
+
return {
|
|
50
|
+
ok: false,
|
|
51
|
+
errors: result.error.issues,
|
|
52
|
+
body,
|
|
53
|
+
raw: data,
|
|
54
|
+
};
|
|
55
|
+
}
|