htmlspec-kit 0.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/README.md +78 -0
- package/bin/htmlspec.ts +9 -0
- package/package.json +33 -0
- package/skills/htmlspec-kit/SKILL.md +584 -0
- package/skills/htmlspec-kit/agents/openai.yaml +4 -0
- package/skills/htmlspec-kit/assets/change-template.html +692 -0
- package/skills/htmlspec-kit/assets/spec.schema.json +155 -0
- package/src/cli.ts +422 -0
- package/src/html-document.ts +56 -0
- package/src/json-patch.ts +177 -0
- package/src/paths.ts +76 -0
- package/src/spec-data.ts +265 -0
- package/src/types.ts +106 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://htmlspec.dev/schemas/spec.schema.json",
|
|
4
|
+
"title": "HTMLSpec SPEC payload",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": ["format", "version", "id", "title", "status", "owner", "createdAt", "updatedAt", "summaryHtml", "proposal", "spec", "design", "tasks", "history"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"format": { "const": "htmlspec" },
|
|
10
|
+
"version": { "type": "string" },
|
|
11
|
+
"id": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" },
|
|
12
|
+
"title": { "type": "string", "minLength": 1 },
|
|
13
|
+
"status": { "enum": ["draft", "ready", "in-progress", "done", "archived"] },
|
|
14
|
+
"owner": { "type": "string" },
|
|
15
|
+
"createdAt": { "type": "string", "format": "date-time" },
|
|
16
|
+
"updatedAt": { "type": "string", "format": "date-time" },
|
|
17
|
+
"summaryHtml": { "type": "string" },
|
|
18
|
+
"proposal": { "$ref": "#/$defs/proposal" },
|
|
19
|
+
"spec": { "$ref": "#/$defs/spec" },
|
|
20
|
+
"design": { "$ref": "#/$defs/design" },
|
|
21
|
+
"tasks": { "type": "array", "items": { "$ref": "#/$defs/taskGroup" } },
|
|
22
|
+
"history": { "type": "array", "items": { "$ref": "#/$defs/historyEntry" } }
|
|
23
|
+
},
|
|
24
|
+
"$defs": {
|
|
25
|
+
"proposal": {
|
|
26
|
+
"type": "object",
|
|
27
|
+
"additionalProperties": false,
|
|
28
|
+
"required": ["whyHtml", "whatChanges", "capabilities", "impact"],
|
|
29
|
+
"properties": {
|
|
30
|
+
"whyHtml": { "type": "string" },
|
|
31
|
+
"whatChanges": { "type": "array", "items": { "type": "string" } },
|
|
32
|
+
"capabilities": {
|
|
33
|
+
"type": "object",
|
|
34
|
+
"additionalProperties": false,
|
|
35
|
+
"required": ["new", "modified"],
|
|
36
|
+
"properties": {
|
|
37
|
+
"new": { "type": "array", "items": { "$ref": "#/$defs/capability" } },
|
|
38
|
+
"modified": { "type": "array", "items": { "$ref": "#/$defs/capability" } }
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"impact": { "type": "array", "items": { "type": "string" } }
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"capability": {
|
|
45
|
+
"type": "object",
|
|
46
|
+
"additionalProperties": false,
|
|
47
|
+
"required": ["name", "description"],
|
|
48
|
+
"properties": {
|
|
49
|
+
"name": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" },
|
|
50
|
+
"description": { "type": "string" }
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"spec": {
|
|
54
|
+
"type": "object",
|
|
55
|
+
"additionalProperties": false,
|
|
56
|
+
"required": ["overviewHtml", "operations"],
|
|
57
|
+
"properties": {
|
|
58
|
+
"overviewHtml": { "type": "string" },
|
|
59
|
+
"operations": { "type": "array", "items": { "$ref": "#/$defs/requirementDelta" } }
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
"requirementDelta": {
|
|
63
|
+
"type": "object",
|
|
64
|
+
"additionalProperties": false,
|
|
65
|
+
"required": ["type", "requirements"],
|
|
66
|
+
"properties": {
|
|
67
|
+
"type": { "enum": ["ADDED", "MODIFIED", "REMOVED", "RENAMED"] },
|
|
68
|
+
"requirements": { "type": "array", "items": { "$ref": "#/$defs/requirement" } }
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
"requirement": {
|
|
72
|
+
"type": "object",
|
|
73
|
+
"additionalProperties": false,
|
|
74
|
+
"required": ["id", "title", "bodyHtml", "scenarios"],
|
|
75
|
+
"properties": {
|
|
76
|
+
"id": { "type": "string" },
|
|
77
|
+
"title": { "type": "string" },
|
|
78
|
+
"bodyHtml": { "type": "string" },
|
|
79
|
+
"scenarios": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/scenario" } },
|
|
80
|
+
"reasonHtml": { "type": "string" },
|
|
81
|
+
"migrationHtml": { "type": "string" },
|
|
82
|
+
"from": { "type": "string" },
|
|
83
|
+
"to": { "type": "string" }
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
"scenario": {
|
|
87
|
+
"type": "object",
|
|
88
|
+
"additionalProperties": false,
|
|
89
|
+
"required": ["id", "title", "when", "then"],
|
|
90
|
+
"properties": {
|
|
91
|
+
"id": { "type": "string" },
|
|
92
|
+
"title": { "type": "string" },
|
|
93
|
+
"given": { "type": "string" },
|
|
94
|
+
"when": { "type": "string" },
|
|
95
|
+
"then": { "type": "string" }
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
"design": {
|
|
99
|
+
"type": "object",
|
|
100
|
+
"additionalProperties": false,
|
|
101
|
+
"required": ["contextHtml", "goals", "nonGoals", "decisions", "risksTradeoffs", "migrationPlan", "openQuestions"],
|
|
102
|
+
"properties": {
|
|
103
|
+
"contextHtml": { "type": "string" },
|
|
104
|
+
"goals": { "type": "array", "items": { "type": "string" } },
|
|
105
|
+
"nonGoals": { "type": "array", "items": { "type": "string" } },
|
|
106
|
+
"decisions": { "type": "array", "items": { "$ref": "#/$defs/designDecision" } },
|
|
107
|
+
"risksTradeoffs": { "type": "array", "items": { "type": "string" } },
|
|
108
|
+
"migrationPlan": { "type": "array", "items": { "type": "string" } },
|
|
109
|
+
"openQuestions": { "type": "array", "items": { "type": "string" } }
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
"designDecision": {
|
|
113
|
+
"type": "object",
|
|
114
|
+
"additionalProperties": false,
|
|
115
|
+
"required": ["id", "title", "decisionHtml", "rationaleHtml", "alternatives"],
|
|
116
|
+
"properties": {
|
|
117
|
+
"id": { "type": "string" },
|
|
118
|
+
"title": { "type": "string" },
|
|
119
|
+
"decisionHtml": { "type": "string" },
|
|
120
|
+
"rationaleHtml": { "type": "string" },
|
|
121
|
+
"alternatives": { "type": "array", "items": { "type": "string" } }
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
"taskGroup": {
|
|
125
|
+
"type": "object",
|
|
126
|
+
"additionalProperties": false,
|
|
127
|
+
"required": ["id", "title", "items"],
|
|
128
|
+
"properties": {
|
|
129
|
+
"id": { "type": "string" },
|
|
130
|
+
"title": { "type": "string" },
|
|
131
|
+
"items": { "type": "array", "items": { "$ref": "#/$defs/task" } }
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
"task": {
|
|
135
|
+
"type": "object",
|
|
136
|
+
"additionalProperties": false,
|
|
137
|
+
"required": ["id", "title", "status"],
|
|
138
|
+
"properties": {
|
|
139
|
+
"id": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+$" },
|
|
140
|
+
"title": { "type": "string" },
|
|
141
|
+
"status": { "enum": ["todo", "in-progress", "done"] },
|
|
142
|
+
"notes": { "type": "string" }
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
"historyEntry": {
|
|
146
|
+
"type": "object",
|
|
147
|
+
"additionalProperties": false,
|
|
148
|
+
"required": ["at", "action"],
|
|
149
|
+
"properties": {
|
|
150
|
+
"at": { "type": "string", "format": "date-time" },
|
|
151
|
+
"action": { "type": "string" }
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { readdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import { readSpecData, writeSpecData } from "./html-document";
|
|
6
|
+
import { applyJsonPatch, assertJsonPatch } from "./json-patch";
|
|
7
|
+
import {
|
|
8
|
+
addTask,
|
|
9
|
+
createHtmlSpecData,
|
|
10
|
+
setChangeStatus,
|
|
11
|
+
setTaskStatus,
|
|
12
|
+
slugify,
|
|
13
|
+
titleFromId,
|
|
14
|
+
touch,
|
|
15
|
+
validateSpecData,
|
|
16
|
+
} from "./spec-data";
|
|
17
|
+
import type { ChangeStatus, Design, HtmlSpecData, SpecSection, TaskStatus } from "./types";
|
|
18
|
+
import {
|
|
19
|
+
changePath,
|
|
20
|
+
copyTemplate,
|
|
21
|
+
ensureSpecLayout,
|
|
22
|
+
projectPath,
|
|
23
|
+
readInputContent,
|
|
24
|
+
resolveExistingSpecPath,
|
|
25
|
+
specRoot,
|
|
26
|
+
templateSourcePath,
|
|
27
|
+
titleizePath,
|
|
28
|
+
} from "./paths";
|
|
29
|
+
|
|
30
|
+
type ListEntry = {
|
|
31
|
+
kind: "change" | "spec" | "archive";
|
|
32
|
+
id: string;
|
|
33
|
+
path: string;
|
|
34
|
+
data?: HtmlSpecData;
|
|
35
|
+
error?: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export async function runCli(argv = process.argv): Promise<void> {
|
|
39
|
+
const program = new Command();
|
|
40
|
+
|
|
41
|
+
program
|
|
42
|
+
.name("htmlspec")
|
|
43
|
+
.description("HTML-backed spec-driven development CLI")
|
|
44
|
+
.version("0.1.0");
|
|
45
|
+
|
|
46
|
+
program
|
|
47
|
+
.command("init")
|
|
48
|
+
.description("Create the spec/ workspace and copy the HTMLSpec template")
|
|
49
|
+
.option("-f, --force", "Overwrite spec/templates/change-template.html")
|
|
50
|
+
.action(async (options: { force?: boolean }) => {
|
|
51
|
+
await ensureSpecLayout();
|
|
52
|
+
const template = await copyTemplate(options.force);
|
|
53
|
+
console.log(`Created ${titleizePath(specRoot())}`);
|
|
54
|
+
console.log(`Template ${options.force ? "written" : "available"} at ${titleizePath(template)}`);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
program
|
|
58
|
+
.command("new <id>")
|
|
59
|
+
.description("Create spec/changes/<id>.html from the HTMLSpec template")
|
|
60
|
+
.option("-t, --title <title>", "Human-readable title")
|
|
61
|
+
.option("-f, --force", "Overwrite an existing change")
|
|
62
|
+
.action(async (rawId: string, options: { title?: string; force?: boolean }) => {
|
|
63
|
+
await ensureSpecLayout();
|
|
64
|
+
const id = slugify(rawId);
|
|
65
|
+
if (!id) throw new Error("Change id cannot be empty");
|
|
66
|
+
|
|
67
|
+
const destination = changePath(id);
|
|
68
|
+
if (!options.force && existsSync(destination)) {
|
|
69
|
+
throw new Error(`${titleizePath(destination)} already exists. Use --force to overwrite.`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const template = await readFile(templateSourcePath(), "utf8");
|
|
73
|
+
await writeFile(destination, template, "utf8");
|
|
74
|
+
await writeSpecData(destination, createHtmlSpecData(id, options.title ?? titleFromId(id)));
|
|
75
|
+
console.log(`Created ${titleizePath(destination)}`);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
program
|
|
79
|
+
.command("list")
|
|
80
|
+
.description("List HTMLSpec files")
|
|
81
|
+
.option("--json", "Output JSON")
|
|
82
|
+
.action(async (options: { json?: boolean }) => {
|
|
83
|
+
const entries = await listEntries();
|
|
84
|
+
if (options.json) {
|
|
85
|
+
console.log(JSON.stringify(entries, null, 2));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (entries.length === 0) {
|
|
90
|
+
console.log("No specs found");
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
for (const entry of entries) {
|
|
95
|
+
const status = entry.data?.status ?? "invalid";
|
|
96
|
+
const title = entry.data?.title ?? entry.error ?? "";
|
|
97
|
+
console.log(`${entry.kind.padEnd(7)} ${entry.id.padEnd(24)} ${status.padEnd(12)} ${title}`);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
program
|
|
102
|
+
.command("show <id-or-path>")
|
|
103
|
+
.description("Show a spec summary or the raw SPEC JSON")
|
|
104
|
+
.option("--json", "Output the SPEC JSON")
|
|
105
|
+
.action(async (idOrPath: string, options: { json?: boolean }) => {
|
|
106
|
+
const filePath = resolveExistingSpecPath(idOrPath);
|
|
107
|
+
const data = await readSpecData(filePath);
|
|
108
|
+
|
|
109
|
+
if (options.json) {
|
|
110
|
+
console.log(JSON.stringify(data, null, 2));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
printSummary(data, filePath);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
program
|
|
118
|
+
.command("patch <id-or-path> [patch-file]")
|
|
119
|
+
.description("Apply an RFC 6902 JSON Patch to SPEC data, or replace one JSON Pointer path")
|
|
120
|
+
.option("-p, --patch <json>", "Inline JSON Patch array")
|
|
121
|
+
.option("-f, --from <file>", "Read JSON Patch array from file")
|
|
122
|
+
.option("--replace <pointer>", "Replace a JSON Pointer path")
|
|
123
|
+
.option("--value <json>", "JSON value for --replace, for example '\"Ready\"' or '{\"a\":1}'")
|
|
124
|
+
.option("--value-from <file>", "Read JSON value for --replace from file")
|
|
125
|
+
.option("--string-value <text>", "String value for --replace")
|
|
126
|
+
.action(async (
|
|
127
|
+
idOrPath: string,
|
|
128
|
+
patchFile: string | undefined,
|
|
129
|
+
options: { patch?: string; from?: string; replace?: string; value?: string; valueFrom?: string; stringValue?: string },
|
|
130
|
+
) => {
|
|
131
|
+
const operations = await resolvePatchOperations(patchFile, options);
|
|
132
|
+
await mutateSpec(idOrPath, (data) => touch(applyJsonPatch(data, operations), "patch:applied"));
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
program
|
|
136
|
+
.command("status [id-or-path]")
|
|
137
|
+
.description("Show task status for one change or all changes")
|
|
138
|
+
.action(async (idOrPath?: string) => {
|
|
139
|
+
if (idOrPath) {
|
|
140
|
+
const filePath = resolveExistingSpecPath(idOrPath);
|
|
141
|
+
printTaskStatus(await readSpecData(filePath));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const entries = await listEntries();
|
|
146
|
+
for (const entry of entries.filter((item) => item.data)) {
|
|
147
|
+
const data = entry.data!;
|
|
148
|
+
const counts = countTasks(data);
|
|
149
|
+
console.log(`${entry.id}: ${counts.done}/${counts.total} done, ${counts.inProgress} in progress`);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
program
|
|
154
|
+
.command("validate [id-or-path]")
|
|
155
|
+
.description("Validate one HTMLSpec file or every file under spec/")
|
|
156
|
+
.option("--json", "Output JSON")
|
|
157
|
+
.action(async (idOrPath: string | undefined, options: { json?: boolean }) => {
|
|
158
|
+
const reports = idOrPath
|
|
159
|
+
? [await validateFile(resolveExistingSpecPath(idOrPath))]
|
|
160
|
+
: await Promise.all((await listEntries()).map((entry) => validateFile(entry.path)));
|
|
161
|
+
|
|
162
|
+
if (options.json) {
|
|
163
|
+
console.log(JSON.stringify(reports, null, 2));
|
|
164
|
+
process.exitCode = reports.some((report) => !report.valid) ? 1 : 0;
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
for (const report of reports) {
|
|
169
|
+
if (report.valid) {
|
|
170
|
+
console.log(`OK ${titleizePath(report.path)}`);
|
|
171
|
+
} else {
|
|
172
|
+
console.error(`FAIL ${titleizePath(report.path)}`);
|
|
173
|
+
for (const issue of report.issues) console.error(` - ${issue}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
process.exitCode = reports.some((report) => !report.valid) ? 1 : 0;
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
program
|
|
180
|
+
.command("tasks <id-or-path>")
|
|
181
|
+
.description("List tasks for a change")
|
|
182
|
+
.action(async (idOrPath: string) => {
|
|
183
|
+
const data = await readSpecData(resolveExistingSpecPath(idOrPath));
|
|
184
|
+
printTaskStatus(data);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const task = program.command("task").description("Add or mark HTMLSpec tasks");
|
|
188
|
+
|
|
189
|
+
task
|
|
190
|
+
.command("add <id-or-path> <title>")
|
|
191
|
+
.description("Add a task to a task group")
|
|
192
|
+
.option("-g, --group <group>", "Task group id or title", "Implementation")
|
|
193
|
+
.option("--task-id <taskId>", "Explicit task id")
|
|
194
|
+
.option("-s, --status <status>", "Initial status: todo, in-progress, done", "todo")
|
|
195
|
+
.action(async (idOrPath: string, title: string, options: { group?: string; taskId?: string; status?: TaskStatus }) => {
|
|
196
|
+
assertTaskStatus(options.status);
|
|
197
|
+
await mutateSpec(idOrPath, (data) => addTask(data, title, options));
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
for (const [name, status] of [
|
|
201
|
+
["todo", "todo"],
|
|
202
|
+
["start", "in-progress"],
|
|
203
|
+
["in-progress", "in-progress"],
|
|
204
|
+
["done", "done"],
|
|
205
|
+
] as const) {
|
|
206
|
+
task
|
|
207
|
+
.command(`${name} <id-or-path> <task-id>`)
|
|
208
|
+
.description(`Mark a task ${status}`)
|
|
209
|
+
.action(async (idOrPath: string, taskId: string) => {
|
|
210
|
+
await mutateSpec(idOrPath, (data) => setTaskStatus(data, taskId, status));
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const spec = program.command("spec").description("Update the spec section");
|
|
215
|
+
|
|
216
|
+
spec
|
|
217
|
+
.command("update <id-or-path>")
|
|
218
|
+
.description("Replace spec.overviewHtml from HTML, or spec from a JSON object")
|
|
219
|
+
.option("-f, --from <file>", "Read content from file")
|
|
220
|
+
.option("-m, --message <html>", "Inline HTML or JSON")
|
|
221
|
+
.action(async (idOrPath: string, options: { from?: string; message?: string }) => {
|
|
222
|
+
const content = await readInputContent(options);
|
|
223
|
+
await mutateSpec(idOrPath, (data) => updateSpecSection(data, content));
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const design = program.command("design").description("Update the design section");
|
|
227
|
+
|
|
228
|
+
design
|
|
229
|
+
.command("update <id-or-path>")
|
|
230
|
+
.description("Replace design.overviewHtml from HTML, or design from a JSON object")
|
|
231
|
+
.option("-f, --from <file>", "Read content from file")
|
|
232
|
+
.option("-m, --message <html>", "Inline HTML or JSON")
|
|
233
|
+
.action(async (idOrPath: string, options: { from?: string; message?: string }) => {
|
|
234
|
+
const content = await readInputContent(options);
|
|
235
|
+
await mutateSpec(idOrPath, (data) => updateDesignSection(data, content));
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
program
|
|
239
|
+
.command("summary <id-or-path>")
|
|
240
|
+
.description("Update summaryHtml")
|
|
241
|
+
.option("-f, --from <file>", "Read content from file")
|
|
242
|
+
.option("-m, --message <html>", "Inline HTML")
|
|
243
|
+
.action(async (idOrPath: string, options: { from?: string; message?: string }) => {
|
|
244
|
+
const content = await readInputContent(options);
|
|
245
|
+
await mutateSpec(idOrPath, (data) => {
|
|
246
|
+
data.summaryHtml = content.trim();
|
|
247
|
+
return touch(data, "summary:updated");
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
program
|
|
252
|
+
.command("state <id-or-path> <status>")
|
|
253
|
+
.description("Set change status: draft, ready, in-progress, done, archived")
|
|
254
|
+
.action(async (idOrPath: string, status: ChangeStatus) => {
|
|
255
|
+
assertChangeStatus(status);
|
|
256
|
+
await mutateSpec(idOrPath, (data) => setChangeStatus(data, status));
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
await program.parseAsync(argv);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function mutateSpec(idOrPath: string, mutate: (data: HtmlSpecData) => HtmlSpecData): Promise<void> {
|
|
263
|
+
const filePath = resolveExistingSpecPath(idOrPath);
|
|
264
|
+
const data = mutate(await readSpecData(filePath));
|
|
265
|
+
await writeSpecData(filePath, data);
|
|
266
|
+
console.log(`Updated ${titleizePath(filePath)}`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function resolvePatchOperations(
|
|
270
|
+
patchFile: string | undefined,
|
|
271
|
+
options: { patch?: string; from?: string; replace?: string; value?: string; valueFrom?: string; stringValue?: string },
|
|
272
|
+
) {
|
|
273
|
+
const sources = [patchFile, options.from, options.patch, options.replace].filter(Boolean);
|
|
274
|
+
if (sources.length !== 1) {
|
|
275
|
+
throw new Error("Provide exactly one of [patch-file], --from, --patch, or --replace");
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (options.replace) {
|
|
279
|
+
const valueOptions = [options.value, options.valueFrom, options.stringValue].filter((value) => value !== undefined);
|
|
280
|
+
if (valueOptions.length !== 1) {
|
|
281
|
+
throw new Error("Use --replace with exactly one of --value, --value-from, or --string-value");
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return assertJsonPatch([
|
|
285
|
+
{
|
|
286
|
+
op: "replace",
|
|
287
|
+
path: options.replace,
|
|
288
|
+
value: await resolveReplacementValue(options),
|
|
289
|
+
},
|
|
290
|
+
]);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const raw = options.patch ?? await readFile(options.from ?? patchFile!, "utf8");
|
|
294
|
+
return assertJsonPatch(JSON.parse(raw));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function resolveReplacementValue(options: { value?: string; valueFrom?: string; stringValue?: string }): Promise<unknown> {
|
|
298
|
+
if (options.stringValue !== undefined) return options.stringValue;
|
|
299
|
+
if (options.valueFrom) return JSON.parse(await readFile(options.valueFrom, "utf8"));
|
|
300
|
+
if (options.value !== undefined) return JSON.parse(options.value);
|
|
301
|
+
throw new Error("Missing replacement value");
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function updateSpecSection(data: HtmlSpecData, content: string): HtmlSpecData {
|
|
305
|
+
const trimmed = content.trim();
|
|
306
|
+
const parsed = tryParseJson(trimmed);
|
|
307
|
+
|
|
308
|
+
if (parsed && Array.isArray(parsed)) {
|
|
309
|
+
data.spec.operations = parsed.every((item) => item && typeof item === "object" && "type" in item)
|
|
310
|
+
? parsed as SpecSection["operations"]
|
|
311
|
+
: [{ type: "ADDED", requirements: parsed as SpecSection["operations"][number]["requirements"] }];
|
|
312
|
+
} else if (parsed && typeof parsed === "object") {
|
|
313
|
+
data.spec = parsed as SpecSection;
|
|
314
|
+
} else {
|
|
315
|
+
data.spec.overviewHtml = trimmed;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return touch(data, "spec:updated");
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function updateDesignSection(data: HtmlSpecData, content: string): HtmlSpecData {
|
|
322
|
+
const trimmed = content.trim();
|
|
323
|
+
const parsed = tryParseJson(trimmed);
|
|
324
|
+
|
|
325
|
+
if (parsed && Array.isArray(parsed)) {
|
|
326
|
+
data.design.decisions = parsed;
|
|
327
|
+
} else if (parsed && typeof parsed === "object") {
|
|
328
|
+
data.design = parsed as Design;
|
|
329
|
+
} else {
|
|
330
|
+
data.design.contextHtml = trimmed;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return touch(data, "design:updated");
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function tryParseJson(content: string): unknown | undefined {
|
|
337
|
+
if (!content.startsWith("{") && !content.startsWith("[")) return undefined;
|
|
338
|
+
return JSON.parse(content);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function listEntries(): Promise<ListEntry[]> {
|
|
342
|
+
const dirs: Array<[ListEntry["kind"], string]> = [
|
|
343
|
+
["change", projectPath("spec", "changes")],
|
|
344
|
+
["spec", projectPath("spec", "specs")],
|
|
345
|
+
["archive", projectPath("spec", "archive")],
|
|
346
|
+
];
|
|
347
|
+
const entries: ListEntry[] = [];
|
|
348
|
+
|
|
349
|
+
for (const [kind, dir] of dirs) {
|
|
350
|
+
if (!existsSync(dir)) continue;
|
|
351
|
+
const files = (await readdir(dir)).filter((file) => file.endsWith(".html")).sort();
|
|
352
|
+
for (const file of files) {
|
|
353
|
+
const filePath = join(dir, file);
|
|
354
|
+
const id = basename(file, ".html");
|
|
355
|
+
try {
|
|
356
|
+
entries.push({ kind, id, path: filePath, data: await readSpecData(filePath) });
|
|
357
|
+
} catch (error) {
|
|
358
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
359
|
+
entries.push({ kind, id, path: filePath, error: message });
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return entries;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function validateFile(path: string): Promise<{ path: string; valid: boolean; issues: string[] }> {
|
|
368
|
+
try {
|
|
369
|
+
const data = await readSpecData(path);
|
|
370
|
+
const issues = validateSpecData(data);
|
|
371
|
+
return { path, valid: issues.length === 0, issues };
|
|
372
|
+
} catch (error) {
|
|
373
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
374
|
+
return { path, valid: false, issues: [message] };
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function printSummary(data: HtmlSpecData, filePath: string): void {
|
|
379
|
+
const counts = countTasks(data);
|
|
380
|
+
console.log(`${data.title} (${data.id})`);
|
|
381
|
+
console.log(`Path: ${titleizePath(filePath)}`);
|
|
382
|
+
console.log(`Status: ${data.status}`);
|
|
383
|
+
console.log(`Tasks: ${counts.done}/${counts.total} done, ${counts.inProgress} in progress`);
|
|
384
|
+
console.log(`Updated: ${data.updatedAt}`);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function printTaskStatus(data: HtmlSpecData): void {
|
|
388
|
+
console.log(`${data.title} (${data.id})`);
|
|
389
|
+
for (const group of data.tasks) {
|
|
390
|
+
console.log(`\n${group.id}. ${group.title}`);
|
|
391
|
+
for (const item of group.items) {
|
|
392
|
+
console.log(`${taskMark(item.status)} ${item.id} ${item.title}`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function taskMark(status: TaskStatus): string {
|
|
398
|
+
if (status === "done") return "[x]";
|
|
399
|
+
if (status === "in-progress") return "[~]";
|
|
400
|
+
return "[ ]";
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function countTasks(data: HtmlSpecData): { total: number; done: number; inProgress: number } {
|
|
404
|
+
const tasks = data.tasks.flatMap((group) => group.items);
|
|
405
|
+
return {
|
|
406
|
+
total: tasks.length,
|
|
407
|
+
done: tasks.filter((task) => task.status === "done").length,
|
|
408
|
+
inProgress: tasks.filter((task) => task.status === "in-progress").length,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function assertTaskStatus(status: string | undefined): asserts status is TaskStatus {
|
|
413
|
+
if (!status || !["todo", "in-progress", "done"].includes(status)) {
|
|
414
|
+
throw new Error("Task status must be todo, in-progress, or done");
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function assertChangeStatus(status: string): asserts status is ChangeStatus {
|
|
419
|
+
if (!["draft", "ready", "in-progress", "done", "archived"].includes(status)) {
|
|
420
|
+
throw new Error("Status must be draft, ready, in-progress, done, or archived");
|
|
421
|
+
}
|
|
422
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { parse } from "parse5";
|
|
3
|
+
import type { HtmlSpecData } from "./types";
|
|
4
|
+
|
|
5
|
+
function findSpecScript(node: any): any | undefined {
|
|
6
|
+
if (node?.tagName === "script") {
|
|
7
|
+
const id = node.attrs?.find((attr: { name: string; value: string }) => attr.name === "id")?.value;
|
|
8
|
+
if (id === "SPEC") return node;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
for (const child of node?.childNodes ?? []) {
|
|
12
|
+
const result = findSpecScript(child);
|
|
13
|
+
if (result) return result;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getSpecJsonRange(html: string): { start: number; end: number } {
|
|
20
|
+
const document = parse(html, { sourceCodeLocationInfo: true });
|
|
21
|
+
const script = findSpecScript(document);
|
|
22
|
+
|
|
23
|
+
if (!script?.sourceCodeLocation?.startTag || !script?.sourceCodeLocation?.endTag) {
|
|
24
|
+
throw new Error('Missing <script id="SPEC" type="application/json"> payload');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const start = script.sourceCodeLocation.startTag.endOffset;
|
|
28
|
+
const end = script.sourceCodeLocation.endTag.startOffset;
|
|
29
|
+
|
|
30
|
+
if (!Number.isInteger(start) || !Number.isInteger(end) || start > end) {
|
|
31
|
+
throw new Error("Could not locate SPEC script content range");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return { start, end };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function readSpecData(filePath: string): Promise<HtmlSpecData> {
|
|
38
|
+
const html = await readFile(filePath, "utf8");
|
|
39
|
+
const { start, end } = getSpecJsonRange(html);
|
|
40
|
+
const rawJson = html.slice(start, end).trim();
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
return JSON.parse(rawJson) as HtmlSpecData;
|
|
44
|
+
} catch (error) {
|
|
45
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
46
|
+
throw new Error(`Invalid SPEC JSON in ${filePath}: ${message}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function writeSpecData(filePath: string, data: HtmlSpecData): Promise<void> {
|
|
51
|
+
const html = await readFile(filePath, "utf8");
|
|
52
|
+
const { start, end } = getSpecJsonRange(html);
|
|
53
|
+
const nextJson = JSON.stringify(data, null, 2).replace(/<\/script/gi, "<\\/script");
|
|
54
|
+
const nextHtml = `${html.slice(0, start)}\n${nextJson}\n${html.slice(end)}`;
|
|
55
|
+
await writeFile(filePath, nextHtml, "utf8");
|
|
56
|
+
}
|