@os-eco/seeds-cli 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 +226 -0
- package/package.json +42 -0
- package/src/commands/blocked.ts +31 -0
- package/src/commands/close.ts +80 -0
- package/src/commands/create.test.ts +507 -0
- package/src/commands/create.ts +103 -0
- package/src/commands/dep.test.ts +215 -0
- package/src/commands/dep.ts +108 -0
- package/src/commands/doctor.test.ts +524 -0
- package/src/commands/doctor.ts +732 -0
- package/src/commands/init.test.ts +86 -0
- package/src/commands/init.ts +49 -0
- package/src/commands/list.ts +69 -0
- package/src/commands/migrate.ts +117 -0
- package/src/commands/onboard.test.ts +155 -0
- package/src/commands/onboard.ts +140 -0
- package/src/commands/prime.test.ts +94 -0
- package/src/commands/prime.ts +146 -0
- package/src/commands/ready.ts +31 -0
- package/src/commands/show.ts +20 -0
- package/src/commands/stats.ts +58 -0
- package/src/commands/sync.ts +76 -0
- package/src/commands/tpl.test.ts +330 -0
- package/src/commands/tpl.ts +279 -0
- package/src/commands/update.ts +97 -0
- package/src/config.ts +39 -0
- package/src/id.test.ts +55 -0
- package/src/id.ts +22 -0
- package/src/index.ts +122 -0
- package/src/markers.test.ts +73 -0
- package/src/markers.ts +19 -0
- package/src/output.ts +63 -0
- package/src/store.test.ts +173 -0
- package/src/store.ts +143 -0
- package/src/types.ts +61 -0
- package/src/yaml.test.ts +79 -0
- package/src/yaml.ts +27 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
let tmpDir: string;
|
|
7
|
+
|
|
8
|
+
const CLI = join(import.meta.dir, "../../src/index.ts");
|
|
9
|
+
|
|
10
|
+
async function run(
|
|
11
|
+
args: string[],
|
|
12
|
+
cwd: string,
|
|
13
|
+
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
|
14
|
+
const proc = Bun.spawn(["bun", "run", CLI, ...args], {
|
|
15
|
+
cwd,
|
|
16
|
+
stdout: "pipe",
|
|
17
|
+
stderr: "pipe",
|
|
18
|
+
});
|
|
19
|
+
const stdout = await new Response(proc.stdout).text();
|
|
20
|
+
const stderr = await new Response(proc.stderr).text();
|
|
21
|
+
const exitCode = await proc.exited;
|
|
22
|
+
return { stdout, stderr, exitCode };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function runJson<T = unknown>(args: string[], cwd: string): Promise<T> {
|
|
26
|
+
const { stdout } = await run([...args, "--json"], cwd);
|
|
27
|
+
return JSON.parse(stdout) as T;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let id1: string;
|
|
31
|
+
let id2: string;
|
|
32
|
+
let id3: string;
|
|
33
|
+
|
|
34
|
+
beforeEach(async () => {
|
|
35
|
+
tmpDir = await mkdtemp(join(tmpdir(), "seeds-dep-test-"));
|
|
36
|
+
await run(["init"], tmpDir);
|
|
37
|
+
|
|
38
|
+
const c1 = await runJson<{ success: boolean; id: string }>(
|
|
39
|
+
["create", "--title", "Issue A"],
|
|
40
|
+
tmpDir,
|
|
41
|
+
);
|
|
42
|
+
const c2 = await runJson<{ success: boolean; id: string }>(
|
|
43
|
+
["create", "--title", "Issue B"],
|
|
44
|
+
tmpDir,
|
|
45
|
+
);
|
|
46
|
+
const c3 = await runJson<{ success: boolean; id: string }>(
|
|
47
|
+
["create", "--title", "Issue C"],
|
|
48
|
+
tmpDir,
|
|
49
|
+
);
|
|
50
|
+
id1 = c1.id;
|
|
51
|
+
id2 = c2.id;
|
|
52
|
+
id3 = c3.id;
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
afterEach(async () => {
|
|
56
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("sd dep add", () => {
|
|
60
|
+
test("adds dependency between two issues", async () => {
|
|
61
|
+
const result = await runJson<{ success: boolean }>(["dep", "add", id2, id1], tmpDir);
|
|
62
|
+
expect(result.success).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("blocked issue has blocker in blockedBy", async () => {
|
|
66
|
+
await run(["dep", "add", id2, id1], tmpDir);
|
|
67
|
+
const show = await runJson<{ success: boolean; issue: { blockedBy?: string[] } }>(
|
|
68
|
+
["show", id2],
|
|
69
|
+
tmpDir,
|
|
70
|
+
);
|
|
71
|
+
expect(show.issue.blockedBy).toContain(id1);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("blocker issue has blocked in blocks", async () => {
|
|
75
|
+
await run(["dep", "add", id2, id1], tmpDir);
|
|
76
|
+
const show = await runJson<{ success: boolean; issue: { blocks?: string[] } }>(
|
|
77
|
+
["show", id1],
|
|
78
|
+
tmpDir,
|
|
79
|
+
);
|
|
80
|
+
expect(show.issue.blocks).toContain(id2);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("fails if issue does not exist", async () => {
|
|
84
|
+
const { exitCode } = await run(["dep", "add", "proj-ffff", id1], tmpDir);
|
|
85
|
+
expect(exitCode).not.toBe(0);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("fails if dependency target does not exist", async () => {
|
|
89
|
+
const { exitCode } = await run(["dep", "add", id1, "proj-ffff"], tmpDir);
|
|
90
|
+
expect(exitCode).not.toBe(0);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("supports multiple dependencies on one issue", async () => {
|
|
94
|
+
await run(["dep", "add", id3, id1], tmpDir);
|
|
95
|
+
await run(["dep", "add", id3, id2], tmpDir);
|
|
96
|
+
const show = await runJson<{ success: boolean; issue: { blockedBy?: string[] } }>(
|
|
97
|
+
["show", id3],
|
|
98
|
+
tmpDir,
|
|
99
|
+
);
|
|
100
|
+
expect(show.issue.blockedBy).toContain(id1);
|
|
101
|
+
expect(show.issue.blockedBy).toContain(id2);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("adding duplicate dependency is idempotent", async () => {
|
|
105
|
+
await run(["dep", "add", id2, id1], tmpDir);
|
|
106
|
+
await run(["dep", "add", id2, id1], tmpDir);
|
|
107
|
+
const show = await runJson<{ success: boolean; issue: { blockedBy?: string[] } }>(
|
|
108
|
+
["show", id2],
|
|
109
|
+
tmpDir,
|
|
110
|
+
);
|
|
111
|
+
// Should not have duplicate entries
|
|
112
|
+
const count = show.issue.blockedBy?.filter((id) => id === id1).length ?? 0;
|
|
113
|
+
expect(count).toBe(1);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("sd dep remove", () => {
|
|
118
|
+
beforeEach(async () => {
|
|
119
|
+
await run(["dep", "add", id2, id1], tmpDir);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("removes dependency", async () => {
|
|
123
|
+
const result = await runJson<{ success: boolean }>(["dep", "remove", id2, id1], tmpDir);
|
|
124
|
+
expect(result.success).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("blockedBy no longer contains removed dep", async () => {
|
|
128
|
+
await run(["dep", "remove", id2, id1], tmpDir);
|
|
129
|
+
const show = await runJson<{ success: boolean; issue: { blockedBy?: string[] } }>(
|
|
130
|
+
["show", id2],
|
|
131
|
+
tmpDir,
|
|
132
|
+
);
|
|
133
|
+
expect(show.issue.blockedBy ?? []).not.toContain(id1);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("blocks no longer contains removed dep", async () => {
|
|
137
|
+
await run(["dep", "remove", id2, id1], tmpDir);
|
|
138
|
+
const show = await runJson<{ success: boolean; issue: { blocks?: string[] } }>(
|
|
139
|
+
["show", id1],
|
|
140
|
+
tmpDir,
|
|
141
|
+
);
|
|
142
|
+
expect(show.issue.blocks ?? []).not.toContain(id2);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("sd dep list", () => {
|
|
147
|
+
test("lists dependencies for an issue", async () => {
|
|
148
|
+
await run(["dep", "add", id2, id1], tmpDir);
|
|
149
|
+
const result = await runJson<{ success: boolean; blockedBy: string[]; blocks: string[] }>(
|
|
150
|
+
["dep", "list", id2],
|
|
151
|
+
tmpDir,
|
|
152
|
+
);
|
|
153
|
+
expect(result.success).toBe(true);
|
|
154
|
+
expect(result.blockedBy).toContain(id1);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("shows empty dependencies for issue with no deps", async () => {
|
|
158
|
+
const result = await runJson<{ success: boolean; blockedBy: string[]; blocks: string[] }>(
|
|
159
|
+
["dep", "list", id1],
|
|
160
|
+
tmpDir,
|
|
161
|
+
);
|
|
162
|
+
expect(result.success).toBe(true);
|
|
163
|
+
expect(result.blockedBy).toHaveLength(0);
|
|
164
|
+
expect(result.blocks).toHaveLength(0);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("shows blocks for blocking issue", async () => {
|
|
168
|
+
await run(["dep", "add", id2, id1], tmpDir);
|
|
169
|
+
const result = await runJson<{ success: boolean; blockedBy: string[]; blocks: string[] }>(
|
|
170
|
+
["dep", "list", id1],
|
|
171
|
+
tmpDir,
|
|
172
|
+
);
|
|
173
|
+
expect(result.blocks).toContain(id2);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("sd ready (dependency aware)", () => {
|
|
178
|
+
test("blocked issue is not ready", async () => {
|
|
179
|
+
await run(["dep", "add", id2, id1], tmpDir);
|
|
180
|
+
const result = await runJson<{ success: boolean; issues: Array<{ id: string }> }>(
|
|
181
|
+
["ready"],
|
|
182
|
+
tmpDir,
|
|
183
|
+
);
|
|
184
|
+
const ids = result.issues.map((i) => i.id);
|
|
185
|
+
expect(ids).not.toContain(id2);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("issue becomes ready after blocker is closed", async () => {
|
|
189
|
+
await run(["dep", "add", id2, id1], tmpDir);
|
|
190
|
+
await run(["close", id1], tmpDir);
|
|
191
|
+
|
|
192
|
+
const result = await runJson<{ success: boolean; issues: Array<{ id: string }> }>(
|
|
193
|
+
["ready"],
|
|
194
|
+
tmpDir,
|
|
195
|
+
);
|
|
196
|
+
const ids = result.issues.map((i) => i.id);
|
|
197
|
+
expect(ids).toContain(id2);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("issue with partial blockers still blocked", async () => {
|
|
201
|
+
// id3 blocked by both id1 and id2
|
|
202
|
+
await run(["dep", "add", id3, id1], tmpDir);
|
|
203
|
+
await run(["dep", "add", id3, id2], tmpDir);
|
|
204
|
+
// Close only id1
|
|
205
|
+
await run(["close", id1], tmpDir);
|
|
206
|
+
|
|
207
|
+
const result = await runJson<{ success: boolean; issues: Array<{ id: string }> }>(
|
|
208
|
+
["ready"],
|
|
209
|
+
tmpDir,
|
|
210
|
+
);
|
|
211
|
+
const ids = result.issues.map((i) => i.id);
|
|
212
|
+
// id3 still blocked by id2
|
|
213
|
+
expect(ids).not.toContain(id3);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { findSeedsDir } from "../config.ts";
|
|
2
|
+
import { c, outputJson, printIssueOneLine } from "../output.ts";
|
|
3
|
+
import { issuesPath, readIssues, withLock, writeIssues } from "../store.ts";
|
|
4
|
+
import type { Issue } from "../types.ts";
|
|
5
|
+
|
|
6
|
+
export async function run(args: string[], seedsDir?: string): Promise<void> {
|
|
7
|
+
const jsonMode = args.includes("--json");
|
|
8
|
+
const positional = args.filter((a) => !a.startsWith("--"));
|
|
9
|
+
|
|
10
|
+
const subcmd = positional[0];
|
|
11
|
+
if (!subcmd) throw new Error("Usage: sd dep <add|remove|list> <issue> [depends-on]");
|
|
12
|
+
|
|
13
|
+
const dir = seedsDir ?? (await findSeedsDir());
|
|
14
|
+
|
|
15
|
+
if (subcmd === "list") {
|
|
16
|
+
const issueId = positional[1];
|
|
17
|
+
if (!issueId) throw new Error("Usage: sd dep list <issue>");
|
|
18
|
+
const issues = await readIssues(dir);
|
|
19
|
+
const issue = issues.find((i) => i.id === issueId);
|
|
20
|
+
if (!issue) throw new Error(`Issue not found: ${issueId}`);
|
|
21
|
+
|
|
22
|
+
const blockedBy = issue.blockedBy ?? [];
|
|
23
|
+
const blocks = issue.blocks ?? [];
|
|
24
|
+
|
|
25
|
+
if (jsonMode) {
|
|
26
|
+
outputJson({ success: true, command: "dep list", issueId, blockedBy, blocks });
|
|
27
|
+
} else {
|
|
28
|
+
console.log(`${c.bold}${issueId}${c.reset} dependencies:`);
|
|
29
|
+
if (blockedBy.length > 0) {
|
|
30
|
+
console.log(" Blocked by:");
|
|
31
|
+
for (const bid of blockedBy) {
|
|
32
|
+
const b = issues.find((i) => i.id === bid);
|
|
33
|
+
if (b) {
|
|
34
|
+
process.stdout.write(" ");
|
|
35
|
+
printIssueOneLine(b);
|
|
36
|
+
} else {
|
|
37
|
+
console.log(` ${bid} (not found)`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (blocks.length > 0) {
|
|
42
|
+
console.log(" Blocks:");
|
|
43
|
+
for (const bid of blocks) {
|
|
44
|
+
const b = issues.find((i) => i.id === bid);
|
|
45
|
+
if (b) {
|
|
46
|
+
process.stdout.write(" ");
|
|
47
|
+
printIssueOneLine(b);
|
|
48
|
+
} else {
|
|
49
|
+
console.log(` ${bid} (not found)`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (blockedBy.length === 0 && blocks.length === 0) {
|
|
54
|
+
console.log(" No dependencies.");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (subcmd === "add" || subcmd === "remove") {
|
|
61
|
+
const issueId = positional[1];
|
|
62
|
+
const dependsOnId = positional[2];
|
|
63
|
+
if (!issueId || !dependsOnId) {
|
|
64
|
+
throw new Error(`Usage: sd dep ${subcmd} <issue> <depends-on>`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
await withLock(issuesPath(dir), async () => {
|
|
68
|
+
const issues = await readIssues(dir);
|
|
69
|
+
const issueIdx = issues.findIndex((i) => i.id === issueId);
|
|
70
|
+
const depIdx = issues.findIndex((i) => i.id === dependsOnId);
|
|
71
|
+
|
|
72
|
+
if (issueIdx === -1) throw new Error(`Issue not found: ${issueId}`);
|
|
73
|
+
if (depIdx === -1) throw new Error(`Issue not found: ${dependsOnId}`);
|
|
74
|
+
|
|
75
|
+
const issue = issues[issueIdx]!;
|
|
76
|
+
const dep = issues[depIdx]!;
|
|
77
|
+
|
|
78
|
+
if (subcmd === "add") {
|
|
79
|
+
const blockedBy = Array.from(new Set([...(issue.blockedBy ?? []), dependsOnId]));
|
|
80
|
+
const depBlocks = Array.from(new Set([...(dep.blocks ?? []), issueId]));
|
|
81
|
+
issues[issueIdx] = { ...issue, blockedBy, updatedAt: new Date().toISOString() };
|
|
82
|
+
issues[depIdx] = { ...dep, blocks: depBlocks, updatedAt: new Date().toISOString() };
|
|
83
|
+
} else {
|
|
84
|
+
const blockedBy = (issue.blockedBy ?? []).filter((id: string) => id !== dependsOnId);
|
|
85
|
+
const depBlocks = (dep.blocks ?? []).filter((id: string) => id !== issueId);
|
|
86
|
+
const updatedIssue: Issue = { ...issue, updatedAt: new Date().toISOString() };
|
|
87
|
+
if (blockedBy.length > 0) updatedIssue.blockedBy = blockedBy;
|
|
88
|
+
else updatedIssue.blockedBy = undefined;
|
|
89
|
+
const updatedDep: Issue = { ...dep, updatedAt: new Date().toISOString() };
|
|
90
|
+
if (depBlocks.length > 0) updatedDep.blocks = depBlocks;
|
|
91
|
+
else updatedDep.blocks = undefined;
|
|
92
|
+
issues[issueIdx] = updatedIssue;
|
|
93
|
+
issues[depIdx] = updatedDep;
|
|
94
|
+
}
|
|
95
|
+
await writeIssues(dir, issues);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
if (jsonMode) {
|
|
99
|
+
outputJson({ success: true, command: `dep ${subcmd}`, issueId, dependsOnId });
|
|
100
|
+
} else {
|
|
101
|
+
const verb = subcmd === "add" ? "Added" : "Removed";
|
|
102
|
+
console.log(`${verb} dependency: ${issueId} → ${dependsOnId}`);
|
|
103
|
+
}
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
throw new Error(`Unknown dep subcommand: ${subcmd}. Use add, remove, or list.`);
|
|
108
|
+
}
|