@forgeailab/spark 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -15
- package/package.json +4 -7
- package/packs/README.md +22 -24
- package/packs/ai-anthropic/files/lib/anthropic.ts +46 -3
- package/packs/ai-anthropic/pack.toml +2 -3
- package/packs/auth-better-auth/files/app/api/auth/[...all]/route.ts +2 -2
- package/packs/auth-better-auth/files/lib/auth.ts +40 -1
- package/packs/auth-better-auth/pack.toml +1 -5
- package/packs/auth-better-auth-pg/files/app/api/auth/[...all]/route.ts +2 -2
- package/packs/auth-better-auth-pg/files/lib/auth.ts +40 -1
- package/packs/auth-better-auth-pg/pack.toml +1 -5
- package/packs/payments-stripe/files/lib/stripe.ts +104 -6
- package/packs/payments-stripe/pack.toml +1 -5
- package/packs/sync-zero/files/components/ZeroProvider.tsx +11 -1
- package/packs/sync-zero/files/lib/zero/client.ts +3 -3
- package/packs/sync-zero/files/lib/zero/schema.ts +15 -2
- package/packs/sync-zero/pack.toml +0 -4
- package/src/commands/add.ts +3 -16
- package/src/commands/check.ts +6 -37
- package/src/commands/info.ts +1 -8
- package/src/internal/board.ts +541 -0
- package/src/internal/skill-utils.ts +233 -0
- package/src/internal/state.ts +89 -0
- package/src/io/board.ts +3 -3
- package/src/io/skills.ts +1 -1
- package/src/io/state.ts +1 -1
- package/src/runtime-package.ts +0 -132
- /package/templates/nextjs/{anvil.config.json → spark.config.json} +0 -0
package/src/commands/check.ts
CHANGED
|
@@ -2,11 +2,10 @@ import { readFile, stat } from 'node:fs/promises';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { defineCommand } from 'citty';
|
|
4
4
|
import pc from 'picocolors';
|
|
5
|
-
import {
|
|
5
|
+
import type { AppSkillsConfig } from '../config.ts';
|
|
6
6
|
import { missingBoardTasks } from '../io/board.ts';
|
|
7
|
-
import {
|
|
7
|
+
import type { Registry } from '../io/registry.ts';
|
|
8
8
|
import { readState } from '../io/state.ts';
|
|
9
|
-
import { installedRuntimeHelperSpecifier } from '../runtime-package.ts';
|
|
10
9
|
|
|
11
10
|
type CheckOutput = Pick<Console, 'log' | 'error'>;
|
|
12
11
|
|
|
@@ -14,7 +13,6 @@ export type DriftReport = {
|
|
|
14
13
|
missingFiles: string[];
|
|
15
14
|
missingEnv: string[];
|
|
16
15
|
missingTasks: string[];
|
|
17
|
-
missingHelpers: string[];
|
|
18
16
|
};
|
|
19
17
|
|
|
20
18
|
export type CheckOptions = {
|
|
@@ -56,13 +54,9 @@ async function readEnvLocal(projectRoot: string): Promise<string> {
|
|
|
56
54
|
export async function runCheck(
|
|
57
55
|
projectRoot = process.cwd(),
|
|
58
56
|
output: CheckOutput = console,
|
|
59
|
-
|
|
57
|
+
_options: CheckOptions = {},
|
|
60
58
|
): Promise<DriftReport> {
|
|
61
|
-
const
|
|
62
|
-
options.config ? Promise.resolve(options.config) : readConfig(projectRoot),
|
|
63
|
-
options.registry ? Promise.resolve(options.registry) : readRegistry(projectRoot),
|
|
64
|
-
readState(projectRoot),
|
|
65
|
-
]);
|
|
59
|
+
const state = await readState(projectRoot);
|
|
66
60
|
const recordedFiles = [
|
|
67
61
|
...new Set(state.installed_packs.flatMap((pack) => pack.files)),
|
|
68
62
|
].sort();
|
|
@@ -79,33 +73,17 @@ export async function runCheck(
|
|
|
79
73
|
const envLocal = await readEnvLocal(projectRoot);
|
|
80
74
|
const missingEnv = recordedEnv.filter((key) => !hasEnvVar(envLocal, key));
|
|
81
75
|
const missingTasks = await missingBoardTasks(projectRoot, recordedTasks);
|
|
82
|
-
const missingHelpers: string[] = [];
|
|
83
|
-
|
|
84
|
-
for (const pack of state.installed_packs) {
|
|
85
|
-
const runtimePackage = registry.packs.get(pack.name)?.manifest.runtime_package;
|
|
86
|
-
if (!runtimePackage) {
|
|
87
|
-
continue;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
if (!(await installedRuntimeHelperSpecifier(projectRoot, runtimePackage))) {
|
|
91
|
-
missingHelpers.push(
|
|
92
|
-
`${pack.name}: helper package ${runtimePackage.package} missing from package.json`,
|
|
93
|
-
);
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
76
|
|
|
97
77
|
if (
|
|
98
78
|
missingFiles.length === 0 &&
|
|
99
79
|
missingEnv.length === 0 &&
|
|
100
|
-
missingTasks.length === 0
|
|
101
|
-
missingHelpers.length === 0
|
|
80
|
+
missingTasks.length === 0
|
|
102
81
|
) {
|
|
103
82
|
output.log(pc.green('OK: spark state matches the project filesystem.'));
|
|
104
83
|
return {
|
|
105
84
|
missingFiles,
|
|
106
85
|
missingEnv,
|
|
107
86
|
missingTasks,
|
|
108
|
-
missingHelpers,
|
|
109
87
|
};
|
|
110
88
|
}
|
|
111
89
|
|
|
@@ -132,18 +110,10 @@ export async function runCheck(
|
|
|
132
110
|
}
|
|
133
111
|
}
|
|
134
112
|
|
|
135
|
-
if (missingHelpers.length > 0) {
|
|
136
|
-
output.error('drift: helper packages');
|
|
137
|
-
for (const helper of missingHelpers) {
|
|
138
|
-
output.error(` ${helper}`);
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
113
|
return {
|
|
143
114
|
missingFiles,
|
|
144
115
|
missingEnv,
|
|
145
116
|
missingTasks,
|
|
146
|
-
missingHelpers,
|
|
147
117
|
};
|
|
148
118
|
}
|
|
149
119
|
|
|
@@ -157,8 +127,7 @@ export const checkCommand = defineCommand({
|
|
|
157
127
|
if (
|
|
158
128
|
report.missingFiles.length > 0 ||
|
|
159
129
|
report.missingEnv.length > 0 ||
|
|
160
|
-
report.missingTasks.length > 0
|
|
161
|
-
report.missingHelpers.length > 0
|
|
130
|
+
report.missingTasks.length > 0
|
|
162
131
|
) {
|
|
163
132
|
process.exitCode = 1;
|
|
164
133
|
}
|
package/src/commands/info.ts
CHANGED
|
@@ -3,7 +3,6 @@ import pc from 'picocolors';
|
|
|
3
3
|
import { readConfig, type AppSkillsConfig } from '../config.ts';
|
|
4
4
|
import { readRegistry, type Registry } from '../io/registry.ts';
|
|
5
5
|
import { installedPackNames, readState } from '../io/state.ts';
|
|
6
|
-
import { formatResolvedRuntimeHelper } from '../runtime-package.ts';
|
|
7
6
|
|
|
8
7
|
type InfoOutput = Pick<Console, 'log'>;
|
|
9
8
|
|
|
@@ -50,13 +49,7 @@ export async function runInfo(
|
|
|
50
49
|
output.log(pc.bold(`${manifest.name}@${manifest.version}`));
|
|
51
50
|
output.log(manifest.description ?? '');
|
|
52
51
|
output.log(`status: ${installed ? 'installed' : 'available'}`);
|
|
53
|
-
output.log(
|
|
54
|
-
if (manifest.runtime_package) {
|
|
55
|
-
const resolved = await formatResolvedRuntimeHelper(projectRoot, manifest.runtime_package);
|
|
56
|
-
output.log(
|
|
57
|
-
`Runtime helper: ${manifest.runtime_package.package} (range ${manifest.runtime_package.version}, resolved ${resolved})`,
|
|
58
|
-
);
|
|
59
|
-
}
|
|
52
|
+
output.log('Install mode: copy');
|
|
60
53
|
output.log(`category: ${manifest.category}`);
|
|
61
54
|
output.log(`provides: ${formatList(manifest.provides)}`);
|
|
62
55
|
output.log(`requires: ${formatList(manifest.requires)}`);
|
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export enum BoardTaskStatus {
|
|
5
|
+
Todo = 'todo',
|
|
6
|
+
InProgress = 'in-progress',
|
|
7
|
+
Done = 'done',
|
|
8
|
+
Blocked = 'blocked',
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type BoardTask = {
|
|
12
|
+
id: string;
|
|
13
|
+
title: string;
|
|
14
|
+
status: BoardTaskStatus;
|
|
15
|
+
description: string;
|
|
16
|
+
raw: string;
|
|
17
|
+
startLine: number;
|
|
18
|
+
endLine: number;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type BoardEpic = {
|
|
22
|
+
name: string;
|
|
23
|
+
description: string;
|
|
24
|
+
tasks: BoardTask[];
|
|
25
|
+
raw: string;
|
|
26
|
+
startLine: number;
|
|
27
|
+
endLine: number;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type Board = {
|
|
31
|
+
path: string;
|
|
32
|
+
raw: string;
|
|
33
|
+
epics: BoardEpic[];
|
|
34
|
+
toMarkdown(): string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type SeedTask = {
|
|
38
|
+
id?: string;
|
|
39
|
+
title: string;
|
|
40
|
+
status?: BoardTaskStatus;
|
|
41
|
+
description?: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type DraftTask = {
|
|
45
|
+
kind: 'checkbox' | 'yaml';
|
|
46
|
+
id: string;
|
|
47
|
+
title: string;
|
|
48
|
+
status: BoardTaskStatus;
|
|
49
|
+
startIndex: number;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
type DraftEpic = {
|
|
53
|
+
name: string;
|
|
54
|
+
startIndex: number;
|
|
55
|
+
firstTaskIndex?: number;
|
|
56
|
+
tasks: BoardTask[];
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const statusMarkers: Record<BoardTaskStatus, string> = {
|
|
60
|
+
[BoardTaskStatus.Todo]: ' ',
|
|
61
|
+
[BoardTaskStatus.InProgress]: '~',
|
|
62
|
+
[BoardTaskStatus.Done]: 'x',
|
|
63
|
+
[BoardTaskStatus.Blocked]: '!',
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
function boardFilePath(projectRoot: string): string {
|
|
67
|
+
return join(projectRoot, '.ai', 'board.md');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function cleanLine(line: string): string {
|
|
71
|
+
return line.endsWith('\r') ? line.slice(0, -1) : line;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function boardLines(raw: string): string[] {
|
|
75
|
+
if (raw.length === 0) {
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return raw.endsWith('\n') ? raw.slice(0, -1).split('\n') : raw.split('\n');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function stripYamlValue(value: string): string {
|
|
83
|
+
const trimmed = value.trim();
|
|
84
|
+
if (
|
|
85
|
+
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
|
86
|
+
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
|
87
|
+
) {
|
|
88
|
+
return trimmed.slice(1, -1);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return trimmed;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function parseKeyValue(line: string): { key: string; value: string } | undefined {
|
|
95
|
+
const index = line.indexOf(':');
|
|
96
|
+
if (index === -1) {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
key: line.slice(0, index).trim(),
|
|
102
|
+
value: stripYamlValue(line.slice(index + 1)),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function parseError(path: string, line: number, message: string): Error {
|
|
107
|
+
return new Error(`Malformed board at ${path}:${line}: ${message}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function statusFromMarker(path: string, lineNumber: number, marker: string): BoardTaskStatus {
|
|
111
|
+
if (marker === ' ') {
|
|
112
|
+
return BoardTaskStatus.Todo;
|
|
113
|
+
}
|
|
114
|
+
if (marker === '~' || marker === '/') {
|
|
115
|
+
return BoardTaskStatus.InProgress;
|
|
116
|
+
}
|
|
117
|
+
if (marker === 'x' || marker === 'X') {
|
|
118
|
+
return BoardTaskStatus.Done;
|
|
119
|
+
}
|
|
120
|
+
if (marker === '!') {
|
|
121
|
+
return BoardTaskStatus.Blocked;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
throw parseError(path, lineNumber, `unsupported status marker "${marker}"`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function statusFromValue(value: string): BoardTaskStatus | undefined {
|
|
128
|
+
const normalized = stripYamlValue(value).trim().toLowerCase().replace(/\s+/g, ' ');
|
|
129
|
+
|
|
130
|
+
if (
|
|
131
|
+
normalized === BoardTaskStatus.Todo ||
|
|
132
|
+
normalized === 'to do' ||
|
|
133
|
+
normalized === 'clarifying' ||
|
|
134
|
+
normalized === 'approved for planning'
|
|
135
|
+
) {
|
|
136
|
+
return BoardTaskStatus.Todo;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (
|
|
140
|
+
normalized === BoardTaskStatus.InProgress ||
|
|
141
|
+
normalized === 'in progress' ||
|
|
142
|
+
normalized === 'approved for execution' ||
|
|
143
|
+
normalized === 'needs review'
|
|
144
|
+
) {
|
|
145
|
+
return BoardTaskStatus.InProgress;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (normalized === BoardTaskStatus.Done || normalized === 'validated') {
|
|
149
|
+
return BoardTaskStatus.Done;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (normalized === BoardTaskStatus.Blocked || normalized === 'cut from mvp') {
|
|
153
|
+
return BoardTaskStatus.Blocked;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function assertStatus(status: BoardTaskStatus, path: string): BoardTaskStatus {
|
|
160
|
+
if (Object.values(BoardTaskStatus).includes(status)) {
|
|
161
|
+
return status;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
throw new Error(`Unsupported board task status "${status}" for ${path}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function parseCheckboxTask(path: string, lineNumber: number, line: string): DraftTask {
|
|
168
|
+
const match = /^\s*[-*]\s+\[([^\]])\]\s+([^\s:]+)\s*:\s*(.+?)\s*$/u.exec(line);
|
|
169
|
+
if (!match) {
|
|
170
|
+
throw parseError(path, lineNumber, 'expected checkbox task format "- [ ] TASK-ID: Title"');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
kind: 'checkbox',
|
|
175
|
+
id: match[2],
|
|
176
|
+
title: match[3],
|
|
177
|
+
status: statusFromMarker(path, lineNumber, match[1]),
|
|
178
|
+
startIndex: lineNumber - 1,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function parseYamlTaskStart(path: string, lineNumber: number, line: string): DraftTask {
|
|
183
|
+
const match = /^\s*-\s+id:\s*(.*)$/u.exec(line);
|
|
184
|
+
if (!match) {
|
|
185
|
+
throw parseError(path, lineNumber, 'expected YAML task id');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const id = stripYamlValue(match[1]);
|
|
189
|
+
if (id.length === 0) {
|
|
190
|
+
throw parseError(path, lineNumber, 'task id is required');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
kind: 'yaml',
|
|
195
|
+
id,
|
|
196
|
+
title: id,
|
|
197
|
+
status: BoardTaskStatus.Todo,
|
|
198
|
+
startIndex: lineNumber - 1,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function materializeTask(
|
|
203
|
+
path: string,
|
|
204
|
+
lines: readonly string[],
|
|
205
|
+
draft: DraftTask,
|
|
206
|
+
endIndex: number,
|
|
207
|
+
): BoardTask {
|
|
208
|
+
const rawLines = lines.slice(draft.startIndex, endIndex);
|
|
209
|
+
let title = draft.title;
|
|
210
|
+
let status = draft.status;
|
|
211
|
+
|
|
212
|
+
if (draft.kind === 'yaml') {
|
|
213
|
+
for (const rawLine of rawLines.slice(1)) {
|
|
214
|
+
const kv = parseKeyValue(cleanLine(rawLine).trim());
|
|
215
|
+
if (!kv) {
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (kv.key === 'title') {
|
|
220
|
+
title = kv.value || title;
|
|
221
|
+
} else if (kv.key === 'status') {
|
|
222
|
+
const parsed = statusFromValue(kv.value);
|
|
223
|
+
if (!parsed) {
|
|
224
|
+
throw parseError(path, draft.startIndex + 1, `unsupported status "${kv.value}"`);
|
|
225
|
+
}
|
|
226
|
+
status = parsed;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
id: draft.id,
|
|
233
|
+
title,
|
|
234
|
+
status,
|
|
235
|
+
description: rawLines.slice(1).join('\n'),
|
|
236
|
+
raw: rawLines.join('\n'),
|
|
237
|
+
startLine: draft.startIndex + 1,
|
|
238
|
+
endLine: endIndex,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function parseBoardMarkdown(path: string, raw: string): Board {
|
|
243
|
+
const lines = boardLines(raw);
|
|
244
|
+
const epics: BoardEpic[] = [];
|
|
245
|
+
const seenTaskIds = new Set<string>();
|
|
246
|
+
let currentEpic: DraftEpic | undefined;
|
|
247
|
+
let currentTask: DraftTask | undefined;
|
|
248
|
+
|
|
249
|
+
function finishTask(endIndex: number): void {
|
|
250
|
+
if (!currentTask || !currentEpic) {
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const task = materializeTask(path, lines, currentTask, endIndex);
|
|
255
|
+
if (seenTaskIds.has(task.id)) {
|
|
256
|
+
throw parseError(path, task.startLine, `duplicate task id "${task.id}"`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
seenTaskIds.add(task.id);
|
|
260
|
+
currentEpic.firstTaskIndex ??= currentTask.startIndex;
|
|
261
|
+
currentEpic.tasks.push(task);
|
|
262
|
+
currentTask = undefined;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function finishEpic(endIndex: number): void {
|
|
266
|
+
if (!currentEpic) {
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
finishTask(endIndex);
|
|
271
|
+
const descriptionEnd = currentEpic.firstTaskIndex ?? endIndex;
|
|
272
|
+
epics.push({
|
|
273
|
+
name: currentEpic.name,
|
|
274
|
+
description: lines.slice(currentEpic.startIndex + 1, descriptionEnd).join('\n'),
|
|
275
|
+
tasks: currentEpic.tasks,
|
|
276
|
+
raw: lines.slice(currentEpic.startIndex, endIndex).join('\n'),
|
|
277
|
+
startLine: currentEpic.startIndex + 1,
|
|
278
|
+
endLine: endIndex,
|
|
279
|
+
});
|
|
280
|
+
currentEpic = undefined;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
284
|
+
const lineNumber = index + 1;
|
|
285
|
+
const line = cleanLine(lines[index]);
|
|
286
|
+
const epicMatch = /^##(?!#)\s+(.+?)\s*$/u.exec(line);
|
|
287
|
+
if (epicMatch) {
|
|
288
|
+
finishEpic(index);
|
|
289
|
+
currentEpic = {
|
|
290
|
+
name: epicMatch[1],
|
|
291
|
+
startIndex: index,
|
|
292
|
+
tasks: [],
|
|
293
|
+
};
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const checkboxLike = /^\s*[-*]\s+\[[^\]]+\]\s+/u.test(line);
|
|
298
|
+
if (checkboxLike) {
|
|
299
|
+
if (!currentEpic) {
|
|
300
|
+
throw parseError(path, lineNumber, 'task appears before any epic heading');
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
finishTask(index);
|
|
304
|
+
currentTask = parseCheckboxTask(path, lineNumber, line);
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const yamlTaskLike = /^\s*-\s+id:/u.test(line);
|
|
309
|
+
if (yamlTaskLike) {
|
|
310
|
+
if (!currentEpic) {
|
|
311
|
+
throw parseError(path, lineNumber, 'task appears before any epic heading');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
finishTask(index);
|
|
315
|
+
currentTask = parseYamlTaskStart(path, lineNumber, line);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
finishEpic(lines.length);
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
path,
|
|
323
|
+
raw,
|
|
324
|
+
epics,
|
|
325
|
+
toMarkdown() {
|
|
326
|
+
return raw;
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function readExistingBoard(path: string): Promise<string> {
|
|
332
|
+
try {
|
|
333
|
+
return await readFile(path, 'utf8');
|
|
334
|
+
} catch (error) {
|
|
335
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
336
|
+
return '# Board\n';
|
|
337
|
+
}
|
|
338
|
+
throw error;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export async function readBoard(projectRoot: string): Promise<Board> {
|
|
343
|
+
const path = boardFilePath(projectRoot);
|
|
344
|
+
|
|
345
|
+
try {
|
|
346
|
+
const raw = await readFile(path, 'utf8');
|
|
347
|
+
return parseBoardMarkdown(path, raw);
|
|
348
|
+
} catch (error) {
|
|
349
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
350
|
+
throw new Error(`Board file not found at ${path}`, { cause: error });
|
|
351
|
+
}
|
|
352
|
+
throw error;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function escapeRegex(value: string): string {
|
|
357
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function boardHasTask(board: string, taskId: string): boolean {
|
|
361
|
+
const escaped = escapeRegex(taskId);
|
|
362
|
+
const checkboxPattern = new RegExp(`(^|\\n)\\s*[-*]\\s+\\[[^\\]]+\\]\\s+${escaped}\\s*:`, 'u');
|
|
363
|
+
const yamlPattern = new RegExp(`(^|\\n)\\s*-\\s+id:\\s*["']?${escaped}["']?\\s*(\\n|$)`, 'u');
|
|
364
|
+
|
|
365
|
+
return checkboxPattern.test(board) || yamlPattern.test(board);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function generatedTaskId(packName: string, index: number): string {
|
|
369
|
+
const prefix = packName
|
|
370
|
+
.toUpperCase()
|
|
371
|
+
.replace(/[^A-Z0-9]+/g, '-')
|
|
372
|
+
.replace(/^-+|-+$/g, '');
|
|
373
|
+
|
|
374
|
+
return `${prefix || 'PACK'}-${String(index + 1).padStart(3, '0')}`;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function normalizeSeedTask(
|
|
378
|
+
path: string,
|
|
379
|
+
packName: string,
|
|
380
|
+
task: SeedTask,
|
|
381
|
+
index: number,
|
|
382
|
+
): Required<SeedTask> {
|
|
383
|
+
if (task.title.trim().length === 0) {
|
|
384
|
+
throw new Error(`Cannot seed board task with an empty title for ${path}`);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return {
|
|
388
|
+
id: task.id?.trim() || generatedTaskId(packName, index),
|
|
389
|
+
title: task.title.trim(),
|
|
390
|
+
status: assertStatus(task.status ?? BoardTaskStatus.Todo, path),
|
|
391
|
+
description: task.description ?? '',
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function formatSeedTask(task: Required<SeedTask>): string {
|
|
396
|
+
const lines = [`- [${statusMarkers[task.status]}] ${task.id}: ${task.title}`];
|
|
397
|
+
const description = task.description.replace(/\r\n/g, '\n').replace(/\n$/u, '');
|
|
398
|
+
|
|
399
|
+
if (description.trim().length > 0) {
|
|
400
|
+
lines.push(...description.split('\n').map((line) => ` ${line}`));
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return lines.join('\n');
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function lineStartOffsets(raw: string): number[] {
|
|
407
|
+
const offsets = [0];
|
|
408
|
+
for (let index = 0; index < raw.length; index += 1) {
|
|
409
|
+
if (raw[index] === '\n') {
|
|
410
|
+
offsets.push(index + 1);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return offsets;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function findSectionInsertIndex(board: string, packName: string): number | undefined {
|
|
418
|
+
const heading = `## ${packName}`;
|
|
419
|
+
const lines = board.split('\n');
|
|
420
|
+
const offsets = lineStartOffsets(board);
|
|
421
|
+
let headingIndex = -1;
|
|
422
|
+
|
|
423
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
424
|
+
if (cleanLine(lines[index]).trim() === heading) {
|
|
425
|
+
headingIndex = index;
|
|
426
|
+
break;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (headingIndex === -1) {
|
|
431
|
+
return undefined;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
for (let index = headingIndex + 1; index < lines.length; index += 1) {
|
|
435
|
+
if (/^##(?!#)\s+/u.test(cleanLine(lines[index]))) {
|
|
436
|
+
return offsets[index] ?? board.length;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
return board.length;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function appendTasks(board: string, packName: string, tasks: readonly Required<SeedTask>[]): string {
|
|
444
|
+
const taskBlock = tasks.map(formatSeedTask).join('\n\n');
|
|
445
|
+
const insertIndex = findSectionInsertIndex(board, packName);
|
|
446
|
+
|
|
447
|
+
if (insertIndex === undefined) {
|
|
448
|
+
const separator = board.endsWith('\n') ? '\n' : '\n\n';
|
|
449
|
+
return `${board}${separator}## ${packName}\n\n${taskBlock}\n`;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const before = board.slice(0, insertIndex);
|
|
453
|
+
const after = board.slice(insertIndex);
|
|
454
|
+
const separator = before.endsWith('\n\n') ? '' : before.endsWith('\n') ? '\n' : '\n\n';
|
|
455
|
+
const afterSeparator = after.length > 0 ? '\n' : '';
|
|
456
|
+
|
|
457
|
+
return `${before}${separator}${taskBlock}\n${afterSeparator}${after}`;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export async function seedTasks(
|
|
461
|
+
projectRoot: string,
|
|
462
|
+
packName: string,
|
|
463
|
+
tasks: Array<SeedTask>,
|
|
464
|
+
): Promise<void> {
|
|
465
|
+
if (tasks.length === 0) {
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const path = boardFilePath(projectRoot);
|
|
470
|
+
const board = await readExistingBoard(path);
|
|
471
|
+
const normalizedTasks = tasks
|
|
472
|
+
.map((task, index) => normalizeSeedTask(path, packName, task, index))
|
|
473
|
+
.filter((task) => !boardHasTask(board, task.id));
|
|
474
|
+
|
|
475
|
+
if (normalizedTasks.length === 0) {
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
await mkdir(dirname(path), { recursive: true });
|
|
480
|
+
await writeFile(path, appendTasks(board, packName, normalizedTasks));
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
export async function updateStatus(
|
|
484
|
+
projectRoot: string,
|
|
485
|
+
taskId: string,
|
|
486
|
+
status: BoardTaskStatus,
|
|
487
|
+
): Promise<void> {
|
|
488
|
+
const path = boardFilePath(projectRoot);
|
|
489
|
+
const nextStatus = assertStatus(status, path);
|
|
490
|
+
const raw = await readFile(path, 'utf8');
|
|
491
|
+
const lines = raw.split('\n');
|
|
492
|
+
const marker = statusMarkers[nextStatus];
|
|
493
|
+
const checkboxPattern = new RegExp(
|
|
494
|
+
`^(\\s*[-*]\\s+\\[)([^\\]])(\\]\\s+${escapeRegex(taskId)}\\s*:\\s*.+)$`,
|
|
495
|
+
'u',
|
|
496
|
+
);
|
|
497
|
+
|
|
498
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
499
|
+
const originalLine = lines[index];
|
|
500
|
+
const hasCarriage = originalLine.endsWith('\r');
|
|
501
|
+
const line = cleanLine(originalLine);
|
|
502
|
+
const checkboxMatch = checkboxPattern.exec(line);
|
|
503
|
+
|
|
504
|
+
if (checkboxMatch) {
|
|
505
|
+
lines[index] = `${checkboxMatch[1]}${marker}${checkboxMatch[3]}${hasCarriage ? '\r' : ''}`;
|
|
506
|
+
const nextRaw = lines.join('\n');
|
|
507
|
+
if (nextRaw !== raw) {
|
|
508
|
+
await writeFile(path, nextRaw);
|
|
509
|
+
}
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const yamlMatch = /^\s*-\s+id:\s*(.*)$/u.exec(line);
|
|
514
|
+
if (yamlMatch && stripYamlValue(yamlMatch[1]) === taskId) {
|
|
515
|
+
for (let nextIndex = index + 1; nextIndex < lines.length; nextIndex += 1) {
|
|
516
|
+
const candidate = cleanLine(lines[nextIndex]);
|
|
517
|
+
if (/^##(?!#)\s+/u.test(candidate) || /^\s*-\s+id:/u.test(candidate)) {
|
|
518
|
+
break;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const statusMatch = /^(\s*status:\s*)(.*?)(\s*)$/u.exec(candidate);
|
|
522
|
+
if (!statusMatch) {
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const candidateHasCarriage = lines[nextIndex].endsWith('\r');
|
|
527
|
+
lines[nextIndex] =
|
|
528
|
+
`${statusMatch[1]}${nextStatus}${statusMatch[3]}${candidateHasCarriage ? '\r' : ''}`;
|
|
529
|
+
const nextRaw = lines.join('\n');
|
|
530
|
+
if (nextRaw !== raw) {
|
|
531
|
+
await writeFile(path, nextRaw);
|
|
532
|
+
}
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
throw new Error(`Task "${taskId}" has no status marker in ${path}`);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
throw new Error(`Task "${taskId}" not found in ${path}`);
|
|
541
|
+
}
|