schub 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -0
- package/dist/index.js +1573 -597
- package/package.json +3 -1
- package/skills/create-proposal/SKILL.md +33 -0
- package/skills/create-tasks/SKILL.md +40 -0
- package/skills/implement-task/SKILL.md +84 -0
- package/skills/review-proposal/SKILL.md +37 -0
- package/skills/setup-project/SKILL.md +29 -0
- package/src/App.test.tsx +93 -0
- package/src/App.tsx +62 -10
- package/src/changes.ts +86 -28
- package/src/clipboard.ts +5 -0
- package/src/commands/adr.test.ts +69 -0
- package/src/commands/adr.ts +107 -0
- package/src/commands/changes.test.ts +171 -0
- package/src/commands/changes.ts +163 -0
- package/src/commands/cookbook.test.ts +71 -0
- package/src/commands/cookbook.ts +95 -0
- package/src/commands/eject.test.ts +74 -0
- package/src/commands/eject.ts +100 -0
- package/src/commands/init.test.ts +78 -0
- package/src/commands/init.ts +144 -0
- package/src/commands/project.test.ts +113 -0
- package/src/commands/project.ts +75 -0
- package/src/commands/review.test.ts +100 -0
- package/src/commands/review.ts +231 -0
- package/src/commands/tasks-create.test.ts +172 -0
- package/src/commands/tasks-list.test.ts +177 -0
- package/src/commands/tasks.ts +172 -0
- package/src/components/PlanView.test.tsx +113 -0
- package/src/components/PlanView.tsx +95 -26
- package/src/components/StatusView.test.tsx +380 -0
- package/src/components/StatusView.tsx +233 -83
- package/src/features/tasks/constants.ts +2 -0
- package/src/features/tasks/create.ts +15 -7
- package/src/features/tasks/filesystem.test.ts +78 -0
- package/src/features/tasks/filesystem.ts +61 -7
- package/src/ide.ts +7 -0
- package/src/index.test.ts +23 -0
- package/src/index.ts +60 -383
- package/src/init.test.ts +43 -0
- package/src/init.ts +27 -0
- package/src/project.ts +5 -32
- package/src/schub-root.ts +33 -0
- package/src/templates.ts +18 -0
- package/src/terminal.test.ts +46 -0
- package/templates/create-proposal/cookbook-template.md +37 -0
- package/templates/review-proposal/q&a-template.md +5 -1
- package/templates/templates-parity.test.ts +45 -0
- package/templates/setup-project/review-me-template.md +0 -18
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { spawnSync } from "bun";
|
|
7
|
+
|
|
8
|
+
const testDir = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const cliDir = resolve(testDir, "..", "..");
|
|
10
|
+
const decoder = new TextDecoder();
|
|
11
|
+
|
|
12
|
+
const runCli = (schubCwd: string, args: string[]) => {
|
|
13
|
+
const result = spawnSync({
|
|
14
|
+
cmd: ["bun", "run", "schub", ...args],
|
|
15
|
+
cwd: cliDir,
|
|
16
|
+
env: { ...process.env, FORCE_COLOR: "0", SCHUB_CWD: schubCwd },
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
result,
|
|
21
|
+
stdout: decoder.decode(result.stdout ?? new Uint8Array()),
|
|
22
|
+
stderr: decoder.decode(result.stderr ?? new Uint8Array()),
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const createRepo = () => {
|
|
27
|
+
const base = mkdtempSync(join(tmpdir(), "schub-adr-"));
|
|
28
|
+
const repoRoot = join(base, "repo");
|
|
29
|
+
const cwd = join(repoRoot, "nested", "dir");
|
|
30
|
+
const schubRoot = join(repoRoot, ".schub");
|
|
31
|
+
mkdirSync(cwd, { recursive: true });
|
|
32
|
+
mkdirSync(schubRoot, { recursive: true });
|
|
33
|
+
return { cwd, schubRoot };
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const seedChange = (schubRoot: string, changeId: string, title: string) => {
|
|
37
|
+
const changeDir = join(schubRoot, "changes", changeId);
|
|
38
|
+
mkdirSync(changeDir, { recursive: true });
|
|
39
|
+
const proposal = [
|
|
40
|
+
`# Proposal - ${title}`,
|
|
41
|
+
"",
|
|
42
|
+
`**Change ID**: \`${changeId}\``,
|
|
43
|
+
"**Created**: 2024-01-01",
|
|
44
|
+
"**Status**: Draft",
|
|
45
|
+
"",
|
|
46
|
+
].join("\n");
|
|
47
|
+
writeFileSync(join(changeDir, "proposal.md"), proposal, "utf8");
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
test("adr create scaffolds ADR file", () => {
|
|
51
|
+
const { cwd, schubRoot } = createRepo();
|
|
52
|
+
const changeId = "C003_new-adr";
|
|
53
|
+
const changeTitle = "New ADR";
|
|
54
|
+
seedChange(schubRoot, changeId, changeTitle);
|
|
55
|
+
|
|
56
|
+
const adrTitle = "Decision Record";
|
|
57
|
+
const { result } = runCli(cwd, ["adr", "create", "--change-id", changeId, "--title", adrTitle]);
|
|
58
|
+
expect(result.exitCode).toBe(0);
|
|
59
|
+
|
|
60
|
+
const adrPath = join(schubRoot, "changes", changeId, "adr.md");
|
|
61
|
+
expect(existsSync(adrPath)).toBe(true);
|
|
62
|
+
|
|
63
|
+
const templatePath = join(cliDir, "templates", "create-proposal", "adr-template.md");
|
|
64
|
+
const template = readFileSync(templatePath, "utf8");
|
|
65
|
+
const today = new Date().toISOString().split("T")[0];
|
|
66
|
+
const expected = template.split("[TITLE]").join(adrTitle).split("[YYYY-MM-DD]").join(today);
|
|
67
|
+
|
|
68
|
+
expect(readFileSync(adrPath, "utf8")).toBe(expected);
|
|
69
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { readChangeSummary, resolveChangeRoot } from "../changes";
|
|
5
|
+
import { resolveTemplatePath } from "../templates";
|
|
6
|
+
|
|
7
|
+
const BUNDLED_ADR_TEMPLATE_PATH = fileURLToPath(
|
|
8
|
+
new URL("../../templates/create-proposal/adr-template.md", import.meta.url),
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
type AdrCreateOptions = {
|
|
12
|
+
changeId: string;
|
|
13
|
+
title?: string;
|
|
14
|
+
overwrite: boolean;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const parseAdrCreateOptions = (args: string[]) => {
|
|
18
|
+
let changeId: string | undefined;
|
|
19
|
+
let title: string | undefined;
|
|
20
|
+
let overwrite = false;
|
|
21
|
+
const unknown: string[] = [];
|
|
22
|
+
|
|
23
|
+
const rejectUnsupported = (flag: string) => {
|
|
24
|
+
throw new Error(`Unsupported option: ${flag}.`);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
28
|
+
const arg = args[index];
|
|
29
|
+
if (arg === "--overwrite") {
|
|
30
|
+
overwrite = true;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (arg === "--change-id") {
|
|
34
|
+
changeId = args[index + 1];
|
|
35
|
+
if (changeId === undefined) {
|
|
36
|
+
throw new Error("Missing value for --change-id.");
|
|
37
|
+
}
|
|
38
|
+
index += 1;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (arg.startsWith("--change-id=")) {
|
|
42
|
+
changeId = arg.slice("--change-id=".length);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (arg === "--title") {
|
|
46
|
+
title = args[index + 1];
|
|
47
|
+
if (title === undefined) {
|
|
48
|
+
throw new Error("Missing value for --title.");
|
|
49
|
+
}
|
|
50
|
+
index += 1;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (arg.startsWith("--title=")) {
|
|
54
|
+
title = arg.slice("--title=".length);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (arg === "--schub-root" || arg === "--agent-root") {
|
|
58
|
+
rejectUnsupported(arg);
|
|
59
|
+
}
|
|
60
|
+
if (arg.startsWith("--schub-root=")) {
|
|
61
|
+
rejectUnsupported("--schub-root");
|
|
62
|
+
}
|
|
63
|
+
if (arg.startsWith("--agent-root=")) {
|
|
64
|
+
rejectUnsupported("--agent-root");
|
|
65
|
+
}
|
|
66
|
+
unknown.push(arg);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (unknown.length > 0) {
|
|
70
|
+
throw new Error(`Unknown option(s): ${unknown.join(", ")}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!changeId) {
|
|
74
|
+
throw new Error("Provide --change-id.");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { changeId, title, overwrite };
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const renderAdrTemplate = (template: string, title: string) => {
|
|
81
|
+
const today = new Date().toISOString().split("T")[0];
|
|
82
|
+
return template.split("[TITLE]").join(title).split("[YYYY-MM-DD]").join(today);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export const runAdrCreate = (args: string[], startDir: string) => {
|
|
86
|
+
const options: AdrCreateOptions = parseAdrCreateOptions(args);
|
|
87
|
+
const schubDir = resolveChangeRoot(startDir);
|
|
88
|
+
const summary = readChangeSummary(schubDir, options.changeId);
|
|
89
|
+
const adrTitle = options.title?.trim() || summary.changeTitle;
|
|
90
|
+
const outputPath = join(summary.changeDir, "adr.md");
|
|
91
|
+
|
|
92
|
+
if (existsSync(outputPath) && !options.overwrite) {
|
|
93
|
+
throw new Error(`Refusing to overwrite existing file: ${outputPath}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const templatePath = resolveTemplatePath(
|
|
97
|
+
schubDir,
|
|
98
|
+
join("create-proposal", "adr-template.md"),
|
|
99
|
+
BUNDLED_ADR_TEMPLATE_PATH,
|
|
100
|
+
);
|
|
101
|
+
const template = readFileSync(templatePath, "utf8");
|
|
102
|
+
const rendered = renderAdrTemplate(template, adrTitle || summary.changeId);
|
|
103
|
+
mkdirSync(dirname(outputPath), { recursive: true });
|
|
104
|
+
writeFileSync(outputPath, rendered, "utf8");
|
|
105
|
+
|
|
106
|
+
process.stdout.write(`[OK] Wrote ADR: ${outputPath}\n`);
|
|
107
|
+
};
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { spawnSync } from "bun";
|
|
7
|
+
|
|
8
|
+
const testDir = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const cliDir = resolve(testDir, "..", "..");
|
|
10
|
+
const proposalTemplatePath = join(cliDir, "templates", "create-proposal", "proposal-template.md");
|
|
11
|
+
const decoder = new TextDecoder();
|
|
12
|
+
|
|
13
|
+
const runChangesCreate = (schubCwd: string, args: string[] = []) => {
|
|
14
|
+
const result = spawnSync({
|
|
15
|
+
cmd: ["bun", "run", "schub", "changes", "create", ...args],
|
|
16
|
+
cwd: cliDir,
|
|
17
|
+
env: { ...process.env, FORCE_COLOR: "0", SCHUB_CWD: schubCwd },
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
result,
|
|
22
|
+
stdout: decoder.decode(result.stdout ?? new Uint8Array()),
|
|
23
|
+
stderr: decoder.decode(result.stderr ?? new Uint8Array()),
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const runChangesStatus = (schubCwd: string, args: string[] = []) => {
|
|
28
|
+
const result = spawnSync({
|
|
29
|
+
cmd: ["bun", "run", "schub", "changes", "status", ...args],
|
|
30
|
+
cwd: cliDir,
|
|
31
|
+
env: { ...process.env, FORCE_COLOR: "0", SCHUB_CWD: schubCwd },
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
result,
|
|
36
|
+
stdout: decoder.decode(result.stdout ?? new Uint8Array()),
|
|
37
|
+
stderr: decoder.decode(result.stderr ?? new Uint8Array()),
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const createRepo = () => {
|
|
42
|
+
const base = mkdtempSync(join(tmpdir(), "schub-changes-"));
|
|
43
|
+
const repoRoot = join(base, "repo");
|
|
44
|
+
const cwd = join(repoRoot, "nested", "dir");
|
|
45
|
+
mkdirSync(cwd, { recursive: true });
|
|
46
|
+
return { repoRoot, cwd };
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const seedChange = (schubRoot: string, changeId: string) => {
|
|
50
|
+
const changeDir = join(schubRoot, "changes", changeId);
|
|
51
|
+
mkdirSync(changeDir, { recursive: true });
|
|
52
|
+
writeFileSync(join(changeDir, "proposal.md"), "# Proposal - Seed\n", "utf8");
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const seedProposal = (schubRoot: string, changeId: string, status: string) => {
|
|
56
|
+
const changeDir = join(schubRoot, "changes", changeId);
|
|
57
|
+
mkdirSync(changeDir, { recursive: true });
|
|
58
|
+
const proposal = `# Proposal - Seed\n**Status**: ${status}\n`;
|
|
59
|
+
writeFileSync(join(changeDir, "proposal.md"), proposal, "utf8");
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
test("changes create scaffolds proposal with prefixed id", () => {
|
|
63
|
+
const { repoRoot, cwd } = createRepo();
|
|
64
|
+
const schubRoot = join(repoRoot, ".schub");
|
|
65
|
+
seedChange(schubRoot, "C002_existing-change");
|
|
66
|
+
|
|
67
|
+
const title = "Update CLI scaffolding";
|
|
68
|
+
const input = "user prompt";
|
|
69
|
+
const { result } = runChangesCreate(cwd, [
|
|
70
|
+
"--change-id",
|
|
71
|
+
"update-cli-scaffolding",
|
|
72
|
+
"--title",
|
|
73
|
+
title,
|
|
74
|
+
"--input",
|
|
75
|
+
input,
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
expect(result.exitCode).toBe(0);
|
|
79
|
+
|
|
80
|
+
const changeId = "C003_update-cli-scaffolding";
|
|
81
|
+
const proposalPath = join(schubRoot, "changes", changeId, "proposal.md");
|
|
82
|
+
expect(existsSync(proposalPath)).toBe(true);
|
|
83
|
+
|
|
84
|
+
const content = readFileSync(proposalPath, "utf8");
|
|
85
|
+
const template = readFileSync(proposalTemplatePath, "utf8");
|
|
86
|
+
const today = new Date().toISOString().split("T")[0];
|
|
87
|
+
const expected = template
|
|
88
|
+
.replace("{{CHANGE_TITLE}}", title)
|
|
89
|
+
.replace("{{CHANGE_ID}}", changeId)
|
|
90
|
+
.replace("{{DATE}}", today)
|
|
91
|
+
.replace("{{INPUT}}", input)
|
|
92
|
+
.replace("{{AGENT_ROOT}}", schubRoot);
|
|
93
|
+
|
|
94
|
+
expect(content).toBe(expected);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("changes create rejects invalid change id", () => {
|
|
98
|
+
const { cwd } = createRepo();
|
|
99
|
+
const { result, stderr } = runChangesCreate(cwd, ["--change-id", "bad id"]);
|
|
100
|
+
|
|
101
|
+
expect(result.exitCode).not.toBe(0);
|
|
102
|
+
expect(stderr).toContain("Invalid change-id");
|
|
103
|
+
expect(existsSync(join(cwd, ".schub"))).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("changes create requires change id or title", () => {
|
|
107
|
+
const { cwd } = createRepo();
|
|
108
|
+
const { result, stderr } = runChangesCreate(cwd);
|
|
109
|
+
|
|
110
|
+
expect(result.exitCode).not.toBe(0);
|
|
111
|
+
expect(stderr).toContain("Provide --change-id or --title.");
|
|
112
|
+
expect(existsSync(join(cwd, ".schub"))).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("changes create respects overwrite", () => {
|
|
116
|
+
const { cwd } = createRepo();
|
|
117
|
+
const schubRoot = join(cwd, ".schub");
|
|
118
|
+
const changeId = "C001_repeatable-change";
|
|
119
|
+
|
|
120
|
+
const first = runChangesCreate(cwd, ["--change-id", changeId, "--title", "First"]);
|
|
121
|
+
expect(first.result.exitCode).toBe(0);
|
|
122
|
+
|
|
123
|
+
const proposalPath = join(schubRoot, "changes", changeId, "proposal.md");
|
|
124
|
+
expect(readFileSync(proposalPath, "utf8")).toContain("# Proposal - First");
|
|
125
|
+
|
|
126
|
+
const second = runChangesCreate(cwd, ["--change-id", changeId, "--title", "Second"]);
|
|
127
|
+
expect(second.result.exitCode).not.toBe(0);
|
|
128
|
+
expect(second.stderr).toContain("already exists");
|
|
129
|
+
|
|
130
|
+
const third = runChangesCreate(cwd, ["--change-id", changeId, "--title", "Overwrite", "--overwrite"]);
|
|
131
|
+
expect(third.result.exitCode).toBe(0);
|
|
132
|
+
expect(readFileSync(proposalPath, "utf8")).toContain("# Proposal - Overwrite");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("changes create rejects schub root flags", () => {
|
|
136
|
+
const { cwd } = createRepo();
|
|
137
|
+
const { result, stderr } = runChangesCreate(cwd, ["--schub-root", "/tmp", "--change-id", "valid-slug"]);
|
|
138
|
+
|
|
139
|
+
expect(result.exitCode).not.toBe(0);
|
|
140
|
+
expect(stderr).toContain("Unsupported option: --schub-root.");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("changes status updates accepted proposals", () => {
|
|
144
|
+
const { repoRoot, cwd } = createRepo();
|
|
145
|
+
const schubRoot = join(repoRoot, ".schub");
|
|
146
|
+
seedProposal(schubRoot, "C001_update-cli", "Accepted");
|
|
147
|
+
|
|
148
|
+
const { result, stdout } = runChangesStatus(cwd, ["--change-id", "C001_update-cli", "--status", "Done"]);
|
|
149
|
+
|
|
150
|
+
expect(result.exitCode).toBe(0);
|
|
151
|
+
|
|
152
|
+
const proposalPath = join(schubRoot, "changes", "C001_update-cli", "proposal.md");
|
|
153
|
+
const updated = readFileSync(proposalPath, "utf8");
|
|
154
|
+
expect(updated).toContain("**Status**: Done");
|
|
155
|
+
expect(stdout).toContain("[OK] Updated status");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("changes status updates WIP proposals", () => {
|
|
159
|
+
const { repoRoot, cwd } = createRepo();
|
|
160
|
+
const schubRoot = join(repoRoot, ".schub");
|
|
161
|
+
seedProposal(schubRoot, "C001_update-cli", "WIP");
|
|
162
|
+
|
|
163
|
+
const { result, stdout } = runChangesStatus(cwd, ["--change-id", "C001_update-cli", "--status", "Done"]);
|
|
164
|
+
|
|
165
|
+
expect(result.exitCode).toBe(0);
|
|
166
|
+
|
|
167
|
+
const proposalPath = join(schubRoot, "changes", "C001_update-cli", "proposal.md");
|
|
168
|
+
const updated = readFileSync(proposalPath, "utf8");
|
|
169
|
+
expect(updated).toContain("**Status**: Done");
|
|
170
|
+
expect(stdout).toContain("[OK] Updated status");
|
|
171
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { createChange, resolveChangeRoot, updateChangeStatus } from "../changes";
|
|
2
|
+
|
|
3
|
+
type ChangeCreateOptions = {
|
|
4
|
+
changeId?: string;
|
|
5
|
+
title?: string;
|
|
6
|
+
input?: string;
|
|
7
|
+
overwrite: boolean;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type ChangeStatusOptions = {
|
|
11
|
+
changeId: string;
|
|
12
|
+
status: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const parseChangeCreateOptions = (args: string[]) => {
|
|
16
|
+
let changeId: string | undefined;
|
|
17
|
+
let title: string | undefined;
|
|
18
|
+
let input: string | undefined;
|
|
19
|
+
let overwrite = false;
|
|
20
|
+
const unknown: string[] = [];
|
|
21
|
+
|
|
22
|
+
const rejectUnsupported = (flag: string) => {
|
|
23
|
+
throw new Error(`Unsupported option: ${flag}.`);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
27
|
+
const arg = args[index];
|
|
28
|
+
if (arg === "--overwrite") {
|
|
29
|
+
overwrite = true;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (arg === "--change-id") {
|
|
33
|
+
changeId = args[index + 1];
|
|
34
|
+
if (changeId === undefined) {
|
|
35
|
+
throw new Error("Missing value for --change-id.");
|
|
36
|
+
}
|
|
37
|
+
index += 1;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (arg.startsWith("--change-id=")) {
|
|
41
|
+
changeId = arg.slice("--change-id=".length);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (arg === "--title") {
|
|
45
|
+
title = args[index + 1];
|
|
46
|
+
if (title === undefined) {
|
|
47
|
+
throw new Error("Missing value for --title.");
|
|
48
|
+
}
|
|
49
|
+
index += 1;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (arg.startsWith("--title=")) {
|
|
53
|
+
title = arg.slice("--title=".length);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (arg === "--input") {
|
|
57
|
+
input = args[index + 1];
|
|
58
|
+
if (input === undefined) {
|
|
59
|
+
throw new Error("Missing value for --input.");
|
|
60
|
+
}
|
|
61
|
+
index += 1;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (arg.startsWith("--input=")) {
|
|
65
|
+
input = arg.slice("--input=".length);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (arg === "--schub-root" || arg === "--agent-root") {
|
|
69
|
+
rejectUnsupported(arg);
|
|
70
|
+
}
|
|
71
|
+
if (arg.startsWith("--schub-root=")) {
|
|
72
|
+
rejectUnsupported("--schub-root");
|
|
73
|
+
}
|
|
74
|
+
if (arg.startsWith("--agent-root=")) {
|
|
75
|
+
rejectUnsupported("--agent-root");
|
|
76
|
+
}
|
|
77
|
+
unknown.push(arg);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (unknown.length > 0) {
|
|
81
|
+
throw new Error(`Unknown option(s): ${unknown.join(", ")}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const options: ChangeCreateOptions = { changeId, title, input, overwrite };
|
|
85
|
+
return options;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const parseChangeStatusOptions = (args: string[]) => {
|
|
89
|
+
let changeId: string | undefined;
|
|
90
|
+
let status: string | undefined;
|
|
91
|
+
const unknown: string[] = [];
|
|
92
|
+
|
|
93
|
+
const rejectUnsupported = (flag: string) => {
|
|
94
|
+
throw new Error(`Unsupported option: ${flag}.`);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
98
|
+
const arg = args[index];
|
|
99
|
+
if (arg === "--change-id") {
|
|
100
|
+
changeId = args[index + 1];
|
|
101
|
+
if (changeId === undefined) {
|
|
102
|
+
throw new Error("Missing value for --change-id.");
|
|
103
|
+
}
|
|
104
|
+
index += 1;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (arg.startsWith("--change-id=")) {
|
|
108
|
+
changeId = arg.slice("--change-id=".length);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (arg === "--status") {
|
|
112
|
+
status = args[index + 1];
|
|
113
|
+
if (status === undefined) {
|
|
114
|
+
throw new Error("Missing value for --status.");
|
|
115
|
+
}
|
|
116
|
+
index += 1;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (arg.startsWith("--status=")) {
|
|
120
|
+
status = arg.slice("--status=".length);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (arg === "--schub-root" || arg === "--agent-root") {
|
|
124
|
+
rejectUnsupported(arg);
|
|
125
|
+
}
|
|
126
|
+
if (arg.startsWith("--schub-root=")) {
|
|
127
|
+
rejectUnsupported("--schub-root");
|
|
128
|
+
}
|
|
129
|
+
if (arg.startsWith("--agent-root=")) {
|
|
130
|
+
rejectUnsupported("--agent-root");
|
|
131
|
+
}
|
|
132
|
+
unknown.push(arg);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (unknown.length > 0) {
|
|
136
|
+
throw new Error(`Unknown option(s): ${unknown.join(", ")}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!changeId) {
|
|
140
|
+
throw new Error("Provide --change-id.");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!status || !status.trim()) {
|
|
144
|
+
throw new Error("Provide --status.");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const options: ChangeStatusOptions = { changeId, status };
|
|
148
|
+
return options;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
export const runChangesCreate = (args: string[], startDir: string) => {
|
|
152
|
+
const options = parseChangeCreateOptions(args);
|
|
153
|
+
const schubDir = resolveChangeRoot(startDir);
|
|
154
|
+
const proposalPath = createChange(schubDir, options);
|
|
155
|
+
process.stdout.write(`[OK] Wrote proposal: ${proposalPath}\n`);
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
export const runChangesStatus = (args: string[], startDir: string) => {
|
|
159
|
+
const options = parseChangeStatusOptions(args);
|
|
160
|
+
const schubDir = resolveChangeRoot(startDir);
|
|
161
|
+
const updated = updateChangeStatus(schubDir, options.changeId, options.status);
|
|
162
|
+
process.stdout.write(`[OK] Updated status for ${updated.changeId}: ${updated.previousStatus} -> ${updated.status}\n`);
|
|
163
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { spawnSync } from "bun";
|
|
7
|
+
|
|
8
|
+
const testDir = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const cliDir = resolve(testDir, "..", "..");
|
|
10
|
+
const decoder = new TextDecoder();
|
|
11
|
+
|
|
12
|
+
const runCli = (schubCwd: string, args: string[]) => {
|
|
13
|
+
const result = spawnSync({
|
|
14
|
+
cmd: ["bun", "run", "schub", ...args],
|
|
15
|
+
cwd: cliDir,
|
|
16
|
+
env: { ...process.env, FORCE_COLOR: "0", SCHUB_CWD: schubCwd },
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
result,
|
|
21
|
+
stdout: decoder.decode(result.stdout ?? new Uint8Array()),
|
|
22
|
+
stderr: decoder.decode(result.stderr ?? new Uint8Array()),
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const createRepo = () => {
|
|
27
|
+
const base = mkdtempSync(join(tmpdir(), "schub-cookbook-"));
|
|
28
|
+
const repoRoot = join(base, "repo");
|
|
29
|
+
const cwd = join(repoRoot, "nested", "dir");
|
|
30
|
+
const schubRoot = join(repoRoot, ".schub");
|
|
31
|
+
mkdirSync(cwd, { recursive: true });
|
|
32
|
+
mkdirSync(schubRoot, { recursive: true });
|
|
33
|
+
return { cwd, schubRoot };
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const seedChange = (schubRoot: string, changeId: string, title: string) => {
|
|
37
|
+
const changeDir = join(schubRoot, "changes", changeId);
|
|
38
|
+
mkdirSync(changeDir, { recursive: true });
|
|
39
|
+
const proposal = [
|
|
40
|
+
`# Proposal - ${title}`,
|
|
41
|
+
"",
|
|
42
|
+
`**Change ID**: \`${changeId}\``,
|
|
43
|
+
"**Created**: 2024-01-01",
|
|
44
|
+
"**Status**: Draft",
|
|
45
|
+
"",
|
|
46
|
+
].join("\n");
|
|
47
|
+
writeFileSync(join(changeDir, "proposal.md"), proposal, "utf8");
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
test("cookbook create scaffolds cookbook file", () => {
|
|
51
|
+
const { cwd, schubRoot } = createRepo();
|
|
52
|
+
const changeId = "C004_new-cookbook";
|
|
53
|
+
const changeTitle = "New Cookbook";
|
|
54
|
+
seedChange(schubRoot, changeId, changeTitle);
|
|
55
|
+
|
|
56
|
+
const { result } = runCli(cwd, ["cookbook", "create", "--change-id", changeId]);
|
|
57
|
+
expect(result.exitCode).toBe(0);
|
|
58
|
+
|
|
59
|
+
const cookbookPath = join(schubRoot, "changes", changeId, "cookbook.md");
|
|
60
|
+
expect(existsSync(cookbookPath)).toBe(true);
|
|
61
|
+
|
|
62
|
+
const templatePath = join(cliDir, "templates", "create-proposal", "cookbook-template.md");
|
|
63
|
+
const template = readFileSync(templatePath, "utf8");
|
|
64
|
+
const today = new Date().toISOString().split("T")[0];
|
|
65
|
+
const expected = template
|
|
66
|
+
.replace("{{CHANGE_TITLE}}", changeTitle)
|
|
67
|
+
.replace("{{CHANGE_ID}}", changeId)
|
|
68
|
+
.replace("{{DATE}}", today);
|
|
69
|
+
|
|
70
|
+
expect(readFileSync(cookbookPath, "utf8")).toBe(expected);
|
|
71
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { readChangeSummary, resolveChangeRoot } from "../changes";
|
|
5
|
+
import { resolveTemplatePath } from "../templates";
|
|
6
|
+
|
|
7
|
+
const BUNDLED_COOKBOOK_TEMPLATE_PATH = fileURLToPath(
|
|
8
|
+
new URL("../../templates/create-proposal/cookbook-template.md", import.meta.url),
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
type CookbookCreateOptions = {
|
|
12
|
+
changeId: string;
|
|
13
|
+
overwrite: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const parseCookbookCreateOptions = (args: string[]) => {
|
|
17
|
+
let changeId: string | undefined;
|
|
18
|
+
let overwrite = false;
|
|
19
|
+
const unknown: string[] = [];
|
|
20
|
+
|
|
21
|
+
const rejectUnsupported = (flag: string) => {
|
|
22
|
+
throw new Error(`Unsupported option: ${flag}.`);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
26
|
+
const arg = args[index];
|
|
27
|
+
if (arg === "--overwrite") {
|
|
28
|
+
overwrite = true;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (arg === "--change-id") {
|
|
32
|
+
changeId = args[index + 1];
|
|
33
|
+
if (changeId === undefined) {
|
|
34
|
+
throw new Error("Missing value for --change-id.");
|
|
35
|
+
}
|
|
36
|
+
index += 1;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (arg.startsWith("--change-id=")) {
|
|
40
|
+
changeId = arg.slice("--change-id=".length);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (arg === "--schub-root" || arg === "--agent-root") {
|
|
44
|
+
rejectUnsupported(arg);
|
|
45
|
+
}
|
|
46
|
+
if (arg.startsWith("--schub-root=")) {
|
|
47
|
+
rejectUnsupported("--schub-root");
|
|
48
|
+
}
|
|
49
|
+
if (arg.startsWith("--agent-root=")) {
|
|
50
|
+
rejectUnsupported("--agent-root");
|
|
51
|
+
}
|
|
52
|
+
unknown.push(arg);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (unknown.length > 0) {
|
|
56
|
+
throw new Error(`Unknown option(s): ${unknown.join(", ")}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!changeId) {
|
|
60
|
+
throw new Error("Provide --change-id.");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { changeId, overwrite };
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const renderCookbookTemplate = (template: string, changeTitle: string, changeId: string) => {
|
|
67
|
+
const today = new Date().toISOString().split("T")[0];
|
|
68
|
+
return template
|
|
69
|
+
.replace("{{CHANGE_TITLE}}", changeTitle)
|
|
70
|
+
.replace("{{CHANGE_ID}}", changeId)
|
|
71
|
+
.replace("{{DATE}}", today);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const runCookbookCreate = (args: string[], startDir: string) => {
|
|
75
|
+
const options: CookbookCreateOptions = parseCookbookCreateOptions(args);
|
|
76
|
+
const schubDir = resolveChangeRoot(startDir);
|
|
77
|
+
const summary = readChangeSummary(schubDir, options.changeId);
|
|
78
|
+
const outputPath = join(summary.changeDir, "cookbook.md");
|
|
79
|
+
|
|
80
|
+
if (existsSync(outputPath) && !options.overwrite) {
|
|
81
|
+
throw new Error(`Refusing to overwrite existing file: ${outputPath}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const templatePath = resolveTemplatePath(
|
|
85
|
+
schubDir,
|
|
86
|
+
join("create-proposal", "cookbook-template.md"),
|
|
87
|
+
BUNDLED_COOKBOOK_TEMPLATE_PATH,
|
|
88
|
+
);
|
|
89
|
+
const template = readFileSync(templatePath, "utf8");
|
|
90
|
+
const rendered = renderCookbookTemplate(template, summary.changeTitle, summary.changeId);
|
|
91
|
+
mkdirSync(dirname(outputPath), { recursive: true });
|
|
92
|
+
writeFileSync(outputPath, rendered, "utf8");
|
|
93
|
+
|
|
94
|
+
process.stdout.write(`[OK] Wrote cookbook: ${outputPath}\n`);
|
|
95
|
+
};
|