schub 0.1.2 → 0.1.4
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 +27 -0
- package/dist/index.js +12830 -3057
- package/package.json +5 -2
- package/skills/create-proposal/SKILL.md +5 -1
- package/skills/create-tasks/SKILL.md +5 -4
- package/skills/implement-task/SKILL.md +6 -1
- package/skills/review-proposal/SKILL.md +3 -2
- package/skills/update-roadmap/SKILL.md +23 -0
- package/src/changes.test.ts +166 -0
- package/src/changes.ts +159 -54
- package/src/commands/adr.test.ts +6 -5
- package/src/commands/changes.test.ts +136 -14
- package/src/commands/changes.ts +102 -1
- package/src/commands/cookbook.test.ts +6 -5
- package/src/commands/init.test.ts +69 -2
- package/src/commands/init.ts +48 -5
- package/src/commands/review.test.ts +7 -6
- package/src/commands/review.ts +1 -1
- package/src/commands/roadmap.test.ts +84 -0
- package/src/commands/roadmap.ts +84 -0
- package/src/commands/tasks-create.test.ts +22 -22
- package/src/commands/tasks-implement.test.ts +253 -0
- package/src/commands/tasks-implement.ts +121 -0
- package/src/commands/tasks-list.test.ts +27 -27
- package/src/commands/tasks-update.test.ts +92 -0
- package/src/commands/tasks.ts +98 -1
- package/src/features/roadmap/index.ts +230 -0
- package/src/features/roadmap/roadmap.test.ts +77 -0
- package/src/features/tasks/constants.ts +1 -0
- package/src/features/tasks/create.ts +10 -8
- package/src/features/tasks/filesystem.test.ts +285 -18
- package/src/features/tasks/filesystem.ts +152 -39
- package/src/features/tasks/graph.ts +18 -3
- package/src/features/tasks/index.ts +10 -1
- package/src/features/tasks/worktree.ts +48 -0
- package/src/frontmatter.ts +115 -0
- package/src/index.test.ts +42 -6
- package/src/index.ts +226 -109
- package/src/opencode.test.ts +53 -0
- package/src/opencode.ts +74 -0
- package/src/tasks.ts +2 -0
- package/src/tui/App.test.tsx +418 -0
- package/src/tui/App.tsx +343 -0
- package/src/tui/components/PlanView.test.tsx +101 -0
- package/src/tui/components/PlanView.tsx +89 -0
- package/src/tui/components/PreviewPage.test.tsx +69 -0
- package/src/tui/components/PreviewPage.tsx +87 -0
- package/src/tui/components/ProposalDetailView.test.tsx +169 -0
- package/src/tui/components/ProposalDetailView.tsx +166 -0
- package/src/tui/components/RoadmapView.test.tsx +85 -0
- package/src/tui/components/RoadmapView.tsx +369 -0
- package/src/tui/components/StatusView.test.tsx +1351 -0
- package/src/tui/components/StatusView.tsx +519 -0
- package/src/tui/components/markdown-renderer.test.ts +46 -0
- package/src/tui/components/markdown-renderer.ts +89 -0
- package/src/tui/components/status-view-data.ts +322 -0
- package/src/tui/components/status-view-render.tsx +329 -0
- package/src/tui/index.ts +16 -0
- package/templates/create-proposal/adr-template.md +6 -4
- package/templates/create-proposal/cookbook-template.md +5 -3
- package/templates/create-proposal/proposal-template.md +8 -6
- package/templates/create-roadmap/roadmap.md +5 -0
- package/templates/create-tasks/task-template.md +9 -4
- package/templates/review-proposal/q&a-template.md +8 -3
- package/templates/review-proposal/review-me-template.md +6 -4
- package/templates/setup-project/project-overview-template.md +5 -0
- package/templates/setup-project/project-setup-template.md +5 -0
- package/templates/setup-project/project-wow-template.md +5 -0
- package/src/App.test.tsx +0 -93
- package/src/App.tsx +0 -155
- package/src/components/PlanView.test.tsx +0 -113
- package/src/components/PlanView.tsx +0 -160
- package/src/components/StatusView.test.tsx +0 -380
- package/src/components/StatusView.tsx +0 -367
- package/src/ide.ts +0 -7
- package/templates/templates-parity.test.ts +0 -45
- /package/src/{clipboard.ts → tui/clipboard.ts} +0 -0
- /package/src/{components → tui/components}/statusColor.ts +0 -0
- /package/src/{terminal.test.ts → tui/terminal.test.ts} +0 -0
- /package/src/{terminal.ts → tui/terminal.ts} +0 -0
package/src/commands/changes.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { createChange, resolveChangeRoot, updateChangeStatus } from "../changes";
|
|
1
|
+
import { archiveChange, createChange, listChanges, resolveChangeRoot, updateChangeStatus } from "../changes";
|
|
2
|
+
import { archiveTasksForChange } from "../tasks";
|
|
2
3
|
|
|
3
4
|
type ChangeCreateOptions = {
|
|
4
5
|
changeId?: string;
|
|
@@ -12,6 +13,11 @@ type ChangeStatusOptions = {
|
|
|
12
13
|
status: string;
|
|
13
14
|
};
|
|
14
15
|
|
|
16
|
+
type ChangeArchiveOptions = {
|
|
17
|
+
changeId: string;
|
|
18
|
+
skipTasks: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
15
21
|
const parseChangeCreateOptions = (args: string[]) => {
|
|
16
22
|
let changeId: string | undefined;
|
|
17
23
|
let title: string | undefined;
|
|
@@ -148,6 +154,82 @@ const parseChangeStatusOptions = (args: string[]) => {
|
|
|
148
154
|
return options;
|
|
149
155
|
};
|
|
150
156
|
|
|
157
|
+
const parseChangeArchiveOptions = (args: string[]) => {
|
|
158
|
+
let changeId: string | undefined;
|
|
159
|
+
let skipTasks = false;
|
|
160
|
+
const unknown: string[] = [];
|
|
161
|
+
|
|
162
|
+
const rejectUnsupported = (flag: string) => {
|
|
163
|
+
throw new Error(`Unsupported option: ${flag}.`);
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
167
|
+
const arg = args[index];
|
|
168
|
+
if (arg === "--skip-tasks") {
|
|
169
|
+
skipTasks = true;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (arg === "--change-id") {
|
|
173
|
+
changeId = args[index + 1];
|
|
174
|
+
if (changeId === undefined) {
|
|
175
|
+
throw new Error("Missing value for --change-id.");
|
|
176
|
+
}
|
|
177
|
+
index += 1;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (arg.startsWith("--change-id=")) {
|
|
181
|
+
changeId = arg.slice("--change-id=".length);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (arg === "--schub-root" || arg === "--agent-root") {
|
|
185
|
+
rejectUnsupported(arg);
|
|
186
|
+
}
|
|
187
|
+
if (arg.startsWith("--schub-root=")) {
|
|
188
|
+
rejectUnsupported("--schub-root");
|
|
189
|
+
}
|
|
190
|
+
if (arg.startsWith("--agent-root=")) {
|
|
191
|
+
rejectUnsupported("--agent-root");
|
|
192
|
+
}
|
|
193
|
+
unknown.push(arg);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (unknown.length > 0) {
|
|
197
|
+
throw new Error(`Unknown option(s): ${unknown.join(", ")}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (!changeId) {
|
|
201
|
+
throw new Error("Provide --change-id.");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const options: ChangeArchiveOptions = { changeId, skipTasks };
|
|
205
|
+
return options;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const parseChangeListOptions = (args: string[]) => {
|
|
209
|
+
const unknown: string[] = [];
|
|
210
|
+
|
|
211
|
+
const rejectUnsupported = (flag: string) => {
|
|
212
|
+
throw new Error(`Unsupported option: ${flag}.`);
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
for (const arg of args) {
|
|
216
|
+
if (arg === "--schub-root" || arg === "--agent-root") {
|
|
217
|
+
rejectUnsupported(arg);
|
|
218
|
+
}
|
|
219
|
+
if (arg.startsWith("--schub-root=")) {
|
|
220
|
+
rejectUnsupported("--schub-root");
|
|
221
|
+
}
|
|
222
|
+
if (arg.startsWith("--agent-root=")) {
|
|
223
|
+
rejectUnsupported("--agent-root");
|
|
224
|
+
}
|
|
225
|
+
unknown.push(arg);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (unknown.length > 0) {
|
|
229
|
+
throw new Error(`Unknown option(s): ${unknown.join(", ")}`);
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
|
|
151
233
|
export const runChangesCreate = (args: string[], startDir: string) => {
|
|
152
234
|
const options = parseChangeCreateOptions(args);
|
|
153
235
|
const schubDir = resolveChangeRoot(startDir);
|
|
@@ -161,3 +243,22 @@ export const runChangesStatus = (args: string[], startDir: string) => {
|
|
|
161
243
|
const updated = updateChangeStatus(schubDir, options.changeId, options.status);
|
|
162
244
|
process.stdout.write(`[OK] Updated status for ${updated.changeId}: ${updated.previousStatus} -> ${updated.status}\n`);
|
|
163
245
|
};
|
|
246
|
+
|
|
247
|
+
export const runChangesArchive = (args: string[], startDir: string) => {
|
|
248
|
+
const options = parseChangeArchiveOptions(args);
|
|
249
|
+
const schubDir = resolveChangeRoot(startDir);
|
|
250
|
+
const archived = archiveChange(schubDir, options.changeId);
|
|
251
|
+
const archivedTasks = options.skipTasks ? [] : archiveTasksForChange(schubDir, archived.changeId);
|
|
252
|
+
const taskLabel = options.skipTasks
|
|
253
|
+
? "tasks kept"
|
|
254
|
+
: `${archivedTasks.length} task${archivedTasks.length === 1 ? "" : "s"} archived`;
|
|
255
|
+
process.stdout.write(`[OK] Archived change ${archived.changeId} (${taskLabel})\n`);
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
export const runChangesList = (args: string[], startDir: string) => {
|
|
259
|
+
parseChangeListOptions(args);
|
|
260
|
+
const schubDir = resolveChangeRoot(startDir);
|
|
261
|
+
const changes = listChanges(schubDir);
|
|
262
|
+
const lines = changes.map((change) => `${change.id} ${change.title} (${change.status})`);
|
|
263
|
+
process.stdout.write(`${lines.join("\n")}\n`);
|
|
264
|
+
};
|
|
@@ -37,19 +37,20 @@ const seedChange = (schubRoot: string, changeId: string, title: string) => {
|
|
|
37
37
|
const changeDir = join(schubRoot, "changes", changeId);
|
|
38
38
|
mkdirSync(changeDir, { recursive: true });
|
|
39
39
|
const proposal = [
|
|
40
|
+
"---",
|
|
41
|
+
`change_id: ${changeId}`,
|
|
42
|
+
"created: 2024-01-01",
|
|
43
|
+
"status: Draft",
|
|
44
|
+
"---",
|
|
40
45
|
`# Proposal - ${title}`,
|
|
41
46
|
"",
|
|
42
|
-
`**Change ID**: \`${changeId}\``,
|
|
43
|
-
"**Created**: 2024-01-01",
|
|
44
|
-
"**Status**: Draft",
|
|
45
|
-
"",
|
|
46
47
|
].join("\n");
|
|
47
48
|
writeFileSync(join(changeDir, "proposal.md"), proposal, "utf8");
|
|
48
49
|
};
|
|
49
50
|
|
|
50
51
|
test("cookbook create scaffolds cookbook file", () => {
|
|
51
52
|
const { cwd, schubRoot } = createRepo();
|
|
52
|
-
const changeId = "
|
|
53
|
+
const changeId = "C0004_new-cookbook";
|
|
53
54
|
const changeTitle = "New Cookbook";
|
|
54
55
|
seedChange(schubRoot, changeId, changeTitle);
|
|
55
56
|
|
|
@@ -3,7 +3,13 @@ import { spawnSync } from "node:child_process";
|
|
|
3
3
|
import { existsSync, mkdirSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
installCodexSkills,
|
|
8
|
+
installOpencodeSkills,
|
|
9
|
+
resolveCodexSkillsRoot,
|
|
10
|
+
resolveOpencodeSkillsRoot,
|
|
11
|
+
selectProviders,
|
|
12
|
+
} from "./init";
|
|
7
13
|
|
|
8
14
|
const createRepoFixture = () => {
|
|
9
15
|
const base = mkdtempSync(join(tmpdir(), "schub-init-codex-"));
|
|
@@ -15,7 +21,9 @@ const createRepoFixture = () => {
|
|
|
15
21
|
|
|
16
22
|
test("selectProviders handles numeric and named inputs", () => {
|
|
17
23
|
expect(selectProviders("1")).toEqual(["codex"]);
|
|
18
|
-
expect(selectProviders("
|
|
24
|
+
expect(selectProviders("2")).toEqual(["opencode"]);
|
|
25
|
+
expect(selectProviders("codex, opencode")).toEqual(["codex", "opencode"]);
|
|
26
|
+
expect(selectProviders("Opencode, 1")).toEqual(["opencode", "codex"]);
|
|
19
27
|
});
|
|
20
28
|
|
|
21
29
|
test("resolveCodexSkillsRoot prefers worktree .codex when present", () => {
|
|
@@ -63,6 +71,51 @@ test("resolveCodexSkillsRoot uses cwd when git is unavailable", () => {
|
|
|
63
71
|
}
|
|
64
72
|
});
|
|
65
73
|
|
|
74
|
+
test("resolveOpencodeSkillsRoot prefers worktree .opencode when present", () => {
|
|
75
|
+
const { repoRoot, startDir } = createRepoFixture();
|
|
76
|
+
const gitInit = spawnSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
|
|
77
|
+
|
|
78
|
+
expect(gitInit.status).toBe(0);
|
|
79
|
+
|
|
80
|
+
mkdirSync(join(repoRoot, ".opencode"), { recursive: true });
|
|
81
|
+
|
|
82
|
+
const destination = resolveOpencodeSkillsRoot(startDir);
|
|
83
|
+
const expectedRoot = realpathSync(repoRoot);
|
|
84
|
+
|
|
85
|
+
expect(destination).toBe(join(expectedRoot, ".opencode", "skills"));
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("resolveOpencodeSkillsRoot falls back to home when worktree .opencode is missing", () => {
|
|
89
|
+
const { repoRoot, startDir } = createRepoFixture();
|
|
90
|
+
const gitInit = spawnSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
|
|
91
|
+
|
|
92
|
+
expect(gitInit.status).toBe(0);
|
|
93
|
+
|
|
94
|
+
const originalHome = process.env.HOME;
|
|
95
|
+
const homeDir = mkdtempSync(join(tmpdir(), "schub-home-"));
|
|
96
|
+
process.env.HOME = homeDir;
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const destination = resolveOpencodeSkillsRoot(startDir);
|
|
100
|
+
expect(destination).toBe(join(homeDir, ".opencode", "skills"));
|
|
101
|
+
} finally {
|
|
102
|
+
process.env.HOME = originalHome;
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("resolveOpencodeSkillsRoot uses cwd when git is unavailable", () => {
|
|
107
|
+
const { startDir } = createRepoFixture();
|
|
108
|
+
const originalPath = process.env.PATH;
|
|
109
|
+
process.env.PATH = "";
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const destination = resolveOpencodeSkillsRoot(startDir);
|
|
113
|
+
expect(destination).toBe(join(startDir, ".opencode", "skills"));
|
|
114
|
+
} finally {
|
|
115
|
+
process.env.PATH = originalPath;
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
66
119
|
test("installCodexSkills skips existing skill directories", () => {
|
|
67
120
|
const base = mkdtempSync(join(tmpdir(), "schub-init-install-"));
|
|
68
121
|
const destination = join(base, "skills");
|
|
@@ -76,3 +129,17 @@ test("installCodexSkills skips existing skill directories", () => {
|
|
|
76
129
|
expect(installed.length).toBeGreaterThan(0);
|
|
77
130
|
expect(existsSync(join(destination, "create-tasks", "SKILL.md"))).toBe(true);
|
|
78
131
|
});
|
|
132
|
+
|
|
133
|
+
test("installOpencodeSkills skips existing skill directories", () => {
|
|
134
|
+
const base = mkdtempSync(join(tmpdir(), "schub-init-opencode-install-"));
|
|
135
|
+
const destination = join(base, "skills");
|
|
136
|
+
const existingSkill = join(destination, "review-proposal");
|
|
137
|
+
mkdirSync(existingSkill, { recursive: true });
|
|
138
|
+
writeFileSync(join(existingSkill, "SKILL.md"), "existing", "utf8");
|
|
139
|
+
|
|
140
|
+
const { installed, skipped } = installOpencodeSkills(destination);
|
|
141
|
+
|
|
142
|
+
expect(skipped).toContain("review-proposal");
|
|
143
|
+
expect(installed.length).toBeGreaterThan(0);
|
|
144
|
+
expect(existsSync(join(destination, "create-tasks", "SKILL.md"))).toBe(true);
|
|
145
|
+
});
|
package/src/commands/init.ts
CHANGED
|
@@ -3,16 +3,20 @@ import { homedir } from "node:os";
|
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
4
|
import { createInterface } from "node:readline";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { ensureRoadmapFile } from "../features/roadmap";
|
|
6
7
|
import { initSchubRoot, resolveGitRoot } from "../init";
|
|
7
8
|
|
|
8
|
-
type ProviderId = "codex";
|
|
9
|
+
type ProviderId = "codex" | "opencode";
|
|
9
10
|
|
|
10
11
|
type ProviderOption = {
|
|
11
12
|
id: ProviderId;
|
|
12
13
|
label: string;
|
|
13
14
|
};
|
|
14
15
|
|
|
15
|
-
const PROVIDERS: ProviderOption[] = [
|
|
16
|
+
const PROVIDERS: ProviderOption[] = [
|
|
17
|
+
{ id: "codex", label: "Codex" },
|
|
18
|
+
{ id: "opencode", label: "Opencode" },
|
|
19
|
+
];
|
|
16
20
|
const BUNDLED_SKILLS_ROOT = fileURLToPath(new URL("../../skills", import.meta.url));
|
|
17
21
|
|
|
18
22
|
const isDirectory = (path: string) => {
|
|
@@ -82,7 +86,24 @@ export const resolveCodexSkillsRoot = (startDir: string) => {
|
|
|
82
86
|
return join(home, ".codex", "skills");
|
|
83
87
|
};
|
|
84
88
|
|
|
85
|
-
export const
|
|
89
|
+
export const resolveOpencodeSkillsRoot = (startDir: string) => {
|
|
90
|
+
const resolvedStart = resolve(startDir);
|
|
91
|
+
const gitRoot = resolveGitRoot(resolvedStart);
|
|
92
|
+
|
|
93
|
+
if (!gitRoot) {
|
|
94
|
+
return join(resolvedStart, ".opencode", "skills");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const localOpencodeRoot = join(gitRoot, ".opencode");
|
|
98
|
+
if (isDirectory(localOpencodeRoot)) {
|
|
99
|
+
return join(localOpencodeRoot, "skills");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const home = process.env.HOME ?? homedir();
|
|
103
|
+
return join(home, ".opencode", "skills");
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const installBundledSkills = (destination: string) => {
|
|
86
107
|
mkdirSync(destination, { recursive: true });
|
|
87
108
|
|
|
88
109
|
const entries = readdirSync(BUNDLED_SKILLS_ROOT, { withFileTypes: true });
|
|
@@ -109,8 +130,12 @@ export const installCodexSkills = (destination: string) => {
|
|
|
109
130
|
return { installed, skipped };
|
|
110
131
|
};
|
|
111
132
|
|
|
112
|
-
const
|
|
113
|
-
|
|
133
|
+
export const installCodexSkills = (destination: string) => installBundledSkills(destination);
|
|
134
|
+
|
|
135
|
+
export const installOpencodeSkills = (destination: string) => installBundledSkills(destination);
|
|
136
|
+
|
|
137
|
+
const reportSkillsInstall = (label: string, destination: string, installed: string[], skipped: string[]) => {
|
|
138
|
+
process.stdout.write(`[OK] ${label} skills: ${destination}\n`);
|
|
114
139
|
|
|
115
140
|
for (const skill of installed) {
|
|
116
141
|
process.stdout.write(`[OK] Installed ${skill}\n`);
|
|
@@ -121,6 +146,14 @@ const reportCodexInstall = (destination: string, installed: string[], skipped: s
|
|
|
121
146
|
}
|
|
122
147
|
};
|
|
123
148
|
|
|
149
|
+
const reportCodexInstall = (destination: string, installed: string[], skipped: string[]) => {
|
|
150
|
+
reportSkillsInstall("Codex", destination, installed, skipped);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const reportOpencodeInstall = (destination: string, installed: string[], skipped: string[]) => {
|
|
154
|
+
reportSkillsInstall("Opencode", destination, installed, skipped);
|
|
155
|
+
};
|
|
156
|
+
|
|
124
157
|
const parseInitOptions = (args: string[]) => {
|
|
125
158
|
if (args.length === 0) {
|
|
126
159
|
return;
|
|
@@ -135,10 +168,20 @@ export const runInit = async (args: string[], startDir: string) => {
|
|
|
135
168
|
const schubRoot = initSchubRoot(startDir);
|
|
136
169
|
process.stdout.write(`[OK] Wrote ${schubRoot}\n`);
|
|
137
170
|
|
|
171
|
+
const roadmapResult = ensureRoadmapFile(schubRoot);
|
|
172
|
+
const roadmapStatus = roadmapResult.created ? "OK" : "SKIP";
|
|
173
|
+
process.stdout.write(`[${roadmapStatus}] Roadmap ${roadmapResult.path}\n`);
|
|
174
|
+
|
|
138
175
|
const providers = await promptForProviders();
|
|
139
176
|
if (providers.includes("codex")) {
|
|
140
177
|
const destination = resolveCodexSkillsRoot(startDir);
|
|
141
178
|
const { installed, skipped } = installCodexSkills(destination);
|
|
142
179
|
reportCodexInstall(destination, installed, skipped);
|
|
143
180
|
}
|
|
181
|
+
|
|
182
|
+
if (providers.includes("opencode")) {
|
|
183
|
+
const destination = resolveOpencodeSkillsRoot(startDir);
|
|
184
|
+
const { installed, skipped } = installOpencodeSkills(destination);
|
|
185
|
+
reportOpencodeInstall(destination, installed, skipped);
|
|
186
|
+
}
|
|
144
187
|
};
|
|
@@ -37,19 +37,20 @@ const seedChange = (schubRoot: string, changeId: string, title: string) => {
|
|
|
37
37
|
const changeDir = join(schubRoot, "changes", changeId);
|
|
38
38
|
mkdirSync(changeDir, { recursive: true });
|
|
39
39
|
const proposal = [
|
|
40
|
+
"---",
|
|
41
|
+
`change_id: ${changeId}`,
|
|
42
|
+
"created: 2024-01-01",
|
|
43
|
+
"status: Draft",
|
|
44
|
+
"---",
|
|
40
45
|
`# Proposal - ${title}`,
|
|
41
46
|
"",
|
|
42
|
-
`**Change ID**: \`${changeId}\``,
|
|
43
|
-
"**Created**: 2024-01-01",
|
|
44
|
-
"**Status**: Draft",
|
|
45
|
-
"",
|
|
46
47
|
].join("\n");
|
|
47
48
|
writeFileSync(join(changeDir, "proposal.md"), proposal, "utf8");
|
|
48
49
|
};
|
|
49
50
|
|
|
50
51
|
test("review create scaffolds REVIEW_ME from template", () => {
|
|
51
52
|
const { cwd, schubRoot } = createRepo();
|
|
52
|
-
const changeId = "
|
|
53
|
+
const changeId = "C0001_sample-change";
|
|
53
54
|
const changeTitle = "Sample Change";
|
|
54
55
|
seedChange(schubRoot, changeId, changeTitle);
|
|
55
56
|
|
|
@@ -72,7 +73,7 @@ test("review create scaffolds REVIEW_ME from template", () => {
|
|
|
72
73
|
|
|
73
74
|
test("review complete creates Q&A with review content", () => {
|
|
74
75
|
const { cwd, schubRoot } = createRepo();
|
|
75
|
-
const changeId = "
|
|
76
|
+
const changeId = "C0002_review-complete";
|
|
76
77
|
const changeTitle = "Review Complete";
|
|
77
78
|
seedChange(schubRoot, changeId, changeTitle);
|
|
78
79
|
|
package/src/commands/review.ts
CHANGED
|
@@ -171,7 +171,7 @@ export const runReviewCreate = (args: string[], startDir: string) => {
|
|
|
171
171
|
|
|
172
172
|
if (!isValidChangeId(trimmedId)) {
|
|
173
173
|
throw new Error(
|
|
174
|
-
`Invalid change-id '${options.changeId}'. Use kebab-case or a C-prefixed id (e.g.,
|
|
174
|
+
`Invalid change-id '${options.changeId}'. Use kebab-case or a C-prefixed id (e.g., C1_add-user-auth).`,
|
|
175
175
|
);
|
|
176
176
|
}
|
|
177
177
|
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { spawnSync } from "bun";
|
|
6
|
+
|
|
7
|
+
const testDir = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const cliDir = resolve(testDir, "..", "..");
|
|
9
|
+
const decoder = new TextDecoder();
|
|
10
|
+
|
|
11
|
+
const runRoadmap = (schubCwd: string, args: string[] = []) => {
|
|
12
|
+
const result = spawnSync({
|
|
13
|
+
cmd: ["bun", "run", "schub", "roadmap", ...args],
|
|
14
|
+
cwd: cliDir,
|
|
15
|
+
env: { ...process.env, FORCE_COLOR: "0", SCHUB_CWD: schubCwd },
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
result,
|
|
20
|
+
stdout: decoder.decode(result.stdout ?? new Uint8Array()),
|
|
21
|
+
stderr: decoder.decode(result.stderr ?? new Uint8Array()),
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const createRoadmapRepo = (items: string[] = []) => {
|
|
26
|
+
const base = mkdtempSync(join(tmpdir(), "schub-roadmap-cli-"));
|
|
27
|
+
const repoRoot = join(base, "repo");
|
|
28
|
+
const cwd = join(repoRoot, "nested", "dir");
|
|
29
|
+
mkdirSync(cwd, { recursive: true });
|
|
30
|
+
|
|
31
|
+
const schubDir = join(repoRoot, ".schub");
|
|
32
|
+
mkdirSync(schubDir, { recursive: true });
|
|
33
|
+
const roadmapPath = join(schubDir, "roadmap.md");
|
|
34
|
+
const content = ["## Roadmap", "", ...items, ""].join("\n");
|
|
35
|
+
writeFileSync(roadmapPath, content, "utf8");
|
|
36
|
+
|
|
37
|
+
return { cwd, roadmapPath, schubDir };
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
test("roadmap list prints index and proposal refs", () => {
|
|
41
|
+
const { cwd } = createRoadmapRepo([
|
|
42
|
+
"- **<PROPOSAL_REF>**: As a user, I want to sign in.",
|
|
43
|
+
"- **C0001**: As an admin, I want to manage accounts.",
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
const { result, stdout } = runRoadmap(cwd, ["list"]);
|
|
47
|
+
|
|
48
|
+
expect(result.exitCode).toBe(0);
|
|
49
|
+
const lines = stdout.trim().split("\n").filter(Boolean);
|
|
50
|
+
expect(lines).toEqual(["1 [--] As a user, I want to sign in.", "2 [C0001] As an admin, I want to manage accounts."]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("roadmap add appends a new story", () => {
|
|
54
|
+
const { cwd, roadmapPath } = createRoadmapRepo(["- **<PROPOSAL_REF>**: As a user, I want to sign in."]);
|
|
55
|
+
|
|
56
|
+
const { result } = runRoadmap(cwd, ["add", "As a guest, I want to browse."]);
|
|
57
|
+
|
|
58
|
+
expect(result.exitCode).toBe(0);
|
|
59
|
+
const content = readFileSync(roadmapPath, "utf8");
|
|
60
|
+
expect(content).toContain("- **<PROPOSAL_REF>**: As a guest, I want to browse.");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("roadmap propose creates proposal and updates roadmap", () => {
|
|
64
|
+
const { cwd, roadmapPath, schubDir } = createRoadmapRepo(["- **<PROPOSAL_REF>**: As a user, I want to sign in."]);
|
|
65
|
+
|
|
66
|
+
const { result } = runRoadmap(cwd, ["propose", "1"]);
|
|
67
|
+
|
|
68
|
+
expect(result.exitCode).toBe(0);
|
|
69
|
+
const content = readFileSync(roadmapPath, "utf8");
|
|
70
|
+
expect(content).toContain("- **C0001**: As a user, I want to sign in.");
|
|
71
|
+
|
|
72
|
+
const changeDirs = readdirSync(join(schubDir, "changes"));
|
|
73
|
+
expect(changeDirs).toHaveLength(1);
|
|
74
|
+
expect(changeDirs[0]).toMatch(/^C0001_/);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("roadmap propose errors when item already linked", () => {
|
|
78
|
+
const { cwd } = createRoadmapRepo(["- **C0003**: As a user, I want to sign in."]);
|
|
79
|
+
|
|
80
|
+
const { result, stderr } = runRoadmap(cwd, ["propose", "1"]);
|
|
81
|
+
|
|
82
|
+
expect(result.exitCode).not.toBe(0);
|
|
83
|
+
expect(stderr).toContain("already has a proposal ref");
|
|
84
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { addRoadmapItem, listRoadmapItems, proposeRoadmapItem } from "../features/roadmap";
|
|
2
|
+
import { findSchubRoot } from "../features/tasks";
|
|
3
|
+
|
|
4
|
+
const resolveRoadmapRoot = (startDir: string) => {
|
|
5
|
+
const schubDir = findSchubRoot(startDir);
|
|
6
|
+
if (!schubDir) {
|
|
7
|
+
throw new Error("No .schub directory found.");
|
|
8
|
+
}
|
|
9
|
+
return schubDir;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const parseRoadmapAddOptions = (args: string[]) => {
|
|
13
|
+
const storyParts: string[] = [];
|
|
14
|
+
for (const arg of args) {
|
|
15
|
+
if (arg.startsWith("--")) {
|
|
16
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
17
|
+
}
|
|
18
|
+
storyParts.push(arg);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const story = storyParts.join(" ").trim();
|
|
22
|
+
if (!story) {
|
|
23
|
+
throw new Error("Provide a roadmap story.");
|
|
24
|
+
}
|
|
25
|
+
return { story };
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const parseRoadmapListOptions = (args: string[]) => {
|
|
29
|
+
if (args.length > 0) {
|
|
30
|
+
throw new Error(`Unknown option(s): ${args.join(", ")}`);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const parseRoadmapProposeOptions = (args: string[]) => {
|
|
35
|
+
if (args.length === 0) {
|
|
36
|
+
throw new Error("Provide a roadmap item index.");
|
|
37
|
+
}
|
|
38
|
+
if (args.length > 1) {
|
|
39
|
+
throw new Error(`Unknown option(s): ${args.slice(1).join(", ")}`);
|
|
40
|
+
}
|
|
41
|
+
const rawIndex = args[0];
|
|
42
|
+
if (!rawIndex || rawIndex.startsWith("--")) {
|
|
43
|
+
throw new Error("Provide a roadmap item index.");
|
|
44
|
+
}
|
|
45
|
+
const index = Number.parseInt(rawIndex, 10);
|
|
46
|
+
if (Number.isNaN(index) || index <= 0) {
|
|
47
|
+
throw new Error(`Invalid roadmap index '${rawIndex}'. Use a positive number.`);
|
|
48
|
+
}
|
|
49
|
+
return { index };
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const formatRoadmapLine = (index: number, proposalRef: string | null, story: string) => {
|
|
53
|
+
const ref = proposalRef ?? "--";
|
|
54
|
+
return `${index} [${ref}] ${story}`;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const runRoadmapList = (startDir: string, args: string[]) => {
|
|
58
|
+
parseRoadmapListOptions(args);
|
|
59
|
+
const schubDir = resolveRoadmapRoot(startDir);
|
|
60
|
+
const items = listRoadmapItems(schubDir);
|
|
61
|
+
|
|
62
|
+
if (items.length === 0) {
|
|
63
|
+
process.stdout.write("No roadmap items found.\n");
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const lines = items.map((item) => formatRoadmapLine(item.index, item.proposalRef, item.story));
|
|
68
|
+
process.stdout.write(`${lines.join("\n")}\n`);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const runRoadmapAdd = (startDir: string, args: string[]) => {
|
|
72
|
+
const options = parseRoadmapAddOptions(args);
|
|
73
|
+
const schubDir = resolveRoadmapRoot(startDir);
|
|
74
|
+
addRoadmapItem(schubDir, options.story);
|
|
75
|
+
process.stdout.write("[OK] Added roadmap item.\n");
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export const runRoadmapPropose = (startDir: string, args: string[]) => {
|
|
79
|
+
const options = parseRoadmapProposeOptions(args);
|
|
80
|
+
const schubDir = resolveRoadmapRoot(startDir);
|
|
81
|
+
const result = proposeRoadmapItem(schubDir, options.index);
|
|
82
|
+
process.stdout.write(`[OK] Wrote proposal: ${result.proposalPath}\n`);
|
|
83
|
+
process.stdout.write(`[OK] Updated roadmap item ${options.index} -> ${result.proposalRef}\n`);
|
|
84
|
+
};
|