@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
package/src/config.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { dirname, join } from "node:path";
|
|
2
|
+
import type { Config } from "./types.ts";
|
|
3
|
+
import { CONFIG_FILE, SEEDS_DIR_NAME } from "./types.ts";
|
|
4
|
+
import { parseYaml, stringifyYaml } from "./yaml.ts";
|
|
5
|
+
|
|
6
|
+
export async function readConfig(seedsDir: string): Promise<Config> {
|
|
7
|
+
const file = Bun.file(join(seedsDir, CONFIG_FILE));
|
|
8
|
+
const content = await file.text();
|
|
9
|
+
const data = parseYaml(content);
|
|
10
|
+
return {
|
|
11
|
+
project: data.project ?? "seeds",
|
|
12
|
+
version: data.version ?? "1",
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function writeConfig(seedsDir: string, config: Config): Promise<void> {
|
|
17
|
+
const content = stringifyYaml({ project: config.project, version: config.version });
|
|
18
|
+
await Bun.write(join(seedsDir, CONFIG_FILE), content);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function findSeedsDir(startDir?: string): Promise<string> {
|
|
22
|
+
let dir = startDir ?? process.cwd();
|
|
23
|
+
while (true) {
|
|
24
|
+
const configPath = join(dir, SEEDS_DIR_NAME, CONFIG_FILE);
|
|
25
|
+
const file = Bun.file(configPath);
|
|
26
|
+
if (await file.exists()) {
|
|
27
|
+
return join(dir, SEEDS_DIR_NAME);
|
|
28
|
+
}
|
|
29
|
+
const parent = dirname(dir);
|
|
30
|
+
if (parent === dir) {
|
|
31
|
+
throw new Error("Not in a seeds project. Run `sd init` first.");
|
|
32
|
+
}
|
|
33
|
+
dir = parent;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function projectRootFromSeedsDir(seedsDir: string): string {
|
|
38
|
+
return dirname(seedsDir);
|
|
39
|
+
}
|
package/src/id.test.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { generateId } from "./id";
|
|
3
|
+
|
|
4
|
+
describe("generateId", () => {
|
|
5
|
+
test("returns id matching project-{4hex} pattern", () => {
|
|
6
|
+
const id = generateId("myproject", []);
|
|
7
|
+
expect(id).toMatch(/^myproject-[0-9a-f]{4}$/);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("uses project name as prefix", () => {
|
|
11
|
+
const id = generateId("overstory", []);
|
|
12
|
+
expect(id.startsWith("overstory-")).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("generates different ids on repeated calls", () => {
|
|
16
|
+
const ids = new Set<string>();
|
|
17
|
+
for (let i = 0; i < 20; i++) {
|
|
18
|
+
ids.add(generateId("proj", []));
|
|
19
|
+
}
|
|
20
|
+
// With 4 hex chars (65536 possibilities), 20 calls should almost certainly produce multiple unique values
|
|
21
|
+
// This test is probabilistic but reliable in practice
|
|
22
|
+
expect(ids.size).toBeGreaterThan(1);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("avoids collisions with existing ids", () => {
|
|
26
|
+
// Fill all possible 1-char hex suffixes to force collision avoidance
|
|
27
|
+
// In practice, with 4 hex = 65536 options, collisions are extremely rare
|
|
28
|
+
const id1 = generateId("proj", []);
|
|
29
|
+
const id2 = generateId("proj", [id1]);
|
|
30
|
+
expect(id2).not.toBe(id1);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("falls back to 8-char hex after many collisions", () => {
|
|
34
|
+
// Simulate 100 collisions by pre-filling many IDs
|
|
35
|
+
// The function should eventually produce a longer ID or succeed
|
|
36
|
+
// We just verify it doesn't throw and returns a valid id
|
|
37
|
+
const existing: string[] = [];
|
|
38
|
+
// This is a functional test — just ensure it completes
|
|
39
|
+
const id = generateId("p", existing);
|
|
40
|
+
expect(id).toMatch(/^p-[0-9a-f]{4,}$/);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("handles project names with hyphens", () => {
|
|
44
|
+
const id = generateId("my-project", []);
|
|
45
|
+
expect(id).toMatch(/^my-project-[0-9a-f]{4}$/);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("hex suffix uses lowercase letters", () => {
|
|
49
|
+
for (let i = 0; i < 10; i++) {
|
|
50
|
+
const id = generateId("proj", []);
|
|
51
|
+
const suffix = id.replace("proj-", "");
|
|
52
|
+
expect(suffix).toMatch(/^[0-9a-f]+$/);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
});
|
package/src/id.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export function generateId(prefix: string, existingIds: Set<string> | string[]): string {
|
|
4
|
+
const idSet = existingIds instanceof Set ? existingIds : new Set(existingIds);
|
|
5
|
+
const makeId = (hexLen: number) =>
|
|
6
|
+
`${prefix}-${randomBytes(Math.ceil(hexLen / 2))
|
|
7
|
+
.toString("hex")
|
|
8
|
+
.slice(0, hexLen)}`;
|
|
9
|
+
|
|
10
|
+
let attempts = 0;
|
|
11
|
+
while (attempts < 100) {
|
|
12
|
+
const id = makeId(4);
|
|
13
|
+
if (!idSet.has(id)) return id;
|
|
14
|
+
attempts++;
|
|
15
|
+
}
|
|
16
|
+
// Fallback to 8 hex chars after 100 collisions
|
|
17
|
+
let id = makeId(8);
|
|
18
|
+
while (idSet.has(id)) {
|
|
19
|
+
id = makeId(8);
|
|
20
|
+
}
|
|
21
|
+
return id;
|
|
22
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
export const VERSION = "0.2.0";
|
|
3
|
+
|
|
4
|
+
const USAGE = `seeds — git-native issue tracker
|
|
5
|
+
|
|
6
|
+
Usage: sd <command> [options]
|
|
7
|
+
|
|
8
|
+
Issue commands:
|
|
9
|
+
init Initialize .seeds/ in current directory
|
|
10
|
+
create Create a new issue
|
|
11
|
+
show <id> Show issue details
|
|
12
|
+
list List issues with filters
|
|
13
|
+
ready Show open issues with no unresolved blockers
|
|
14
|
+
update <id> Update issue fields
|
|
15
|
+
close <id> [ids...] Close one or more issues
|
|
16
|
+
dep <add|remove|list> Manage dependencies
|
|
17
|
+
blocked Show all blocked issues
|
|
18
|
+
stats Project statistics
|
|
19
|
+
sync Stage and commit .seeds/ changes
|
|
20
|
+
doctor Check project health and data integrity
|
|
21
|
+
|
|
22
|
+
Template commands:
|
|
23
|
+
tpl create Create a template
|
|
24
|
+
tpl step add <id> Add a step to a template
|
|
25
|
+
tpl list List all templates
|
|
26
|
+
tpl show <id> Show template with steps
|
|
27
|
+
tpl pour <id> Instantiate template into issues
|
|
28
|
+
tpl status <id> Show convoy status
|
|
29
|
+
|
|
30
|
+
Migration:
|
|
31
|
+
migrate-from-beads Migrate issues from beads
|
|
32
|
+
|
|
33
|
+
Agent integration:
|
|
34
|
+
prime Output AI agent context
|
|
35
|
+
onboard Add seeds section to CLAUDE.md
|
|
36
|
+
|
|
37
|
+
Global flags:
|
|
38
|
+
--json Output as JSON
|
|
39
|
+
--version Show version
|
|
40
|
+
--help Show this help
|
|
41
|
+
`;
|
|
42
|
+
|
|
43
|
+
const argv = process.argv;
|
|
44
|
+
const cmd = argv[2];
|
|
45
|
+
|
|
46
|
+
if (!cmd || cmd === "--help" || cmd === "-h" || cmd === "help") {
|
|
47
|
+
process.stdout.write(USAGE);
|
|
48
|
+
process.exit(0);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (cmd === "--version" || cmd === "-V") {
|
|
52
|
+
console.log(VERSION);
|
|
53
|
+
process.exit(0);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const args = argv.slice(3);
|
|
57
|
+
const jsonMode = args.includes("--json") || argv.slice(2).includes("--json");
|
|
58
|
+
|
|
59
|
+
async function main(): Promise<void> {
|
|
60
|
+
switch (cmd) {
|
|
61
|
+
case "init":
|
|
62
|
+
await (await import("./commands/init.ts")).run(args);
|
|
63
|
+
break;
|
|
64
|
+
case "create":
|
|
65
|
+
await (await import("./commands/create.ts")).run(args);
|
|
66
|
+
break;
|
|
67
|
+
case "show":
|
|
68
|
+
await (await import("./commands/show.ts")).run(args);
|
|
69
|
+
break;
|
|
70
|
+
case "list":
|
|
71
|
+
await (await import("./commands/list.ts")).run(args);
|
|
72
|
+
break;
|
|
73
|
+
case "ready":
|
|
74
|
+
await (await import("./commands/ready.ts")).run(args);
|
|
75
|
+
break;
|
|
76
|
+
case "update":
|
|
77
|
+
await (await import("./commands/update.ts")).run(args);
|
|
78
|
+
break;
|
|
79
|
+
case "close":
|
|
80
|
+
await (await import("./commands/close.ts")).run(args);
|
|
81
|
+
break;
|
|
82
|
+
case "dep":
|
|
83
|
+
await (await import("./commands/dep.ts")).run(args);
|
|
84
|
+
break;
|
|
85
|
+
case "blocked":
|
|
86
|
+
await (await import("./commands/blocked.ts")).run(args);
|
|
87
|
+
break;
|
|
88
|
+
case "stats":
|
|
89
|
+
await (await import("./commands/stats.ts")).run(args);
|
|
90
|
+
break;
|
|
91
|
+
case "sync":
|
|
92
|
+
await (await import("./commands/sync.ts")).run(args);
|
|
93
|
+
break;
|
|
94
|
+
case "doctor":
|
|
95
|
+
await (await import("./commands/doctor.ts")).run(args);
|
|
96
|
+
break;
|
|
97
|
+
case "tpl":
|
|
98
|
+
await (await import("./commands/tpl.ts")).run(args);
|
|
99
|
+
break;
|
|
100
|
+
case "migrate-from-beads":
|
|
101
|
+
await (await import("./commands/migrate.ts")).run(args);
|
|
102
|
+
break;
|
|
103
|
+
case "prime":
|
|
104
|
+
await (await import("./commands/prime.ts")).run(args);
|
|
105
|
+
break;
|
|
106
|
+
case "onboard":
|
|
107
|
+
await (await import("./commands/onboard.ts")).run(args);
|
|
108
|
+
break;
|
|
109
|
+
default:
|
|
110
|
+
throw new Error(`Unknown command: ${cmd}. Run \`sd --help\` for usage.`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
main().catch((err: unknown) => {
|
|
115
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
116
|
+
if (jsonMode) {
|
|
117
|
+
console.log(JSON.stringify({ success: false, command: cmd, error: msg }));
|
|
118
|
+
} else {
|
|
119
|
+
console.error(`Error: ${msg}`);
|
|
120
|
+
}
|
|
121
|
+
process.exit(1);
|
|
122
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
END_MARKER,
|
|
4
|
+
START_MARKER,
|
|
5
|
+
hasMarkerSection,
|
|
6
|
+
replaceMarkerSection,
|
|
7
|
+
wrapInMarkers,
|
|
8
|
+
} from "./markers.ts";
|
|
9
|
+
|
|
10
|
+
describe("markers", () => {
|
|
11
|
+
test("START_MARKER and END_MARKER are correct", () => {
|
|
12
|
+
expect(START_MARKER).toBe("<!-- seeds:start -->");
|
|
13
|
+
expect(END_MARKER).toBe("<!-- seeds:end -->");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("hasMarkerSection", () => {
|
|
17
|
+
test("returns true when both markers present", () => {
|
|
18
|
+
const content = `before\n${START_MARKER}\nstuff\n${END_MARKER}\nafter`;
|
|
19
|
+
expect(hasMarkerSection(content)).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("returns false when only start marker present", () => {
|
|
23
|
+
const content = `before\n${START_MARKER}\nstuff`;
|
|
24
|
+
expect(hasMarkerSection(content)).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("returns false when only end marker present", () => {
|
|
28
|
+
const content = `stuff\n${END_MARKER}\nafter`;
|
|
29
|
+
expect(hasMarkerSection(content)).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("returns false when no markers present", () => {
|
|
33
|
+
expect(hasMarkerSection("just some text")).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("wrapInMarkers", () => {
|
|
38
|
+
test("wraps content with start and end markers", () => {
|
|
39
|
+
const result = wrapInMarkers("hello world");
|
|
40
|
+
expect(result).toBe(`${START_MARKER}\nhello world\n${END_MARKER}`);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("handles multi-line content", () => {
|
|
44
|
+
const result = wrapInMarkers("line 1\nline 2\nline 3");
|
|
45
|
+
expect(result).toBe(`${START_MARKER}\nline 1\nline 2\nline 3\n${END_MARKER}`);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("replaceMarkerSection", () => {
|
|
50
|
+
test("replaces content between markers", () => {
|
|
51
|
+
const content = `before\n${START_MARKER}\nold stuff\n${END_MARKER}\nafter`;
|
|
52
|
+
const result = replaceMarkerSection(content, "new stuff");
|
|
53
|
+
expect(result).toBe(`before\n${START_MARKER}\nnew stuff\n${END_MARKER}\nafter`);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("preserves surrounding content", () => {
|
|
57
|
+
const content = `# Title\n\nsome text\n${START_MARKER}\nold\n${END_MARKER}\n\n## Footer`;
|
|
58
|
+
const result = replaceMarkerSection(content, "replaced");
|
|
59
|
+
expect(result).toContain("# Title");
|
|
60
|
+
expect(result).toContain("## Footer");
|
|
61
|
+
expect(result).toContain("replaced");
|
|
62
|
+
expect(result).not.toContain("old");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("returns null when no markers present", () => {
|
|
66
|
+
expect(replaceMarkerSection("no markers here", "new")).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("returns null when only start marker present", () => {
|
|
70
|
+
expect(replaceMarkerSection(`${START_MARKER}\nstuff`, "new")).toBeNull();
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
});
|
package/src/markers.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export const START_MARKER = "<!-- seeds:start -->";
|
|
2
|
+
export const END_MARKER = "<!-- seeds:end -->";
|
|
3
|
+
|
|
4
|
+
export function hasMarkerSection(content: string): boolean {
|
|
5
|
+
return content.includes(START_MARKER) && content.includes(END_MARKER);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function replaceMarkerSection(content: string, newSection: string): string | null {
|
|
9
|
+
const startIdx = content.indexOf(START_MARKER);
|
|
10
|
+
const endIdx = content.indexOf(END_MARKER);
|
|
11
|
+
if (startIdx === -1 || endIdx === -1) return null;
|
|
12
|
+
const before = content.slice(0, startIdx);
|
|
13
|
+
const after = content.slice(endIdx + END_MARKER.length);
|
|
14
|
+
return before + wrapInMarkers(newSection) + after;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function wrapInMarkers(section: string): string {
|
|
18
|
+
return `${START_MARKER}\n${section}\n${END_MARKER}`;
|
|
19
|
+
}
|
package/src/output.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { Issue } from "./types.ts";
|
|
2
|
+
import { PRIORITY_LABELS } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
const useColor = !process.env.NO_COLOR && process.stdout.isTTY;
|
|
5
|
+
|
|
6
|
+
export const c = {
|
|
7
|
+
reset: useColor ? "\x1b[0m" : "",
|
|
8
|
+
bold: useColor ? "\x1b[1m" : "",
|
|
9
|
+
dim: useColor ? "\x1b[2m" : "",
|
|
10
|
+
red: useColor ? "\x1b[31m" : "",
|
|
11
|
+
green: useColor ? "\x1b[32m" : "",
|
|
12
|
+
yellow: useColor ? "\x1b[33m" : "",
|
|
13
|
+
blue: useColor ? "\x1b[34m" : "",
|
|
14
|
+
cyan: useColor ? "\x1b[36m" : "",
|
|
15
|
+
magenta: useColor ? "\x1b[35m" : "",
|
|
16
|
+
gray: useColor ? "\x1b[90m" : "",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function outputJson(data: unknown): void {
|
|
20
|
+
console.log(JSON.stringify(data, null, 2));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function printSuccess(msg: string): void {
|
|
24
|
+
console.log(`${c.green}✓${c.reset} ${msg}`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function printError(msg: string): void {
|
|
28
|
+
console.error(`${c.red}✗${c.reset} ${msg}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function printIssueOneLine(issue: Issue): void {
|
|
32
|
+
const statusIcon =
|
|
33
|
+
issue.status === "closed"
|
|
34
|
+
? `${c.gray}●${c.reset}`
|
|
35
|
+
: issue.status === "in_progress"
|
|
36
|
+
? `${c.cyan}◐${c.reset}`
|
|
37
|
+
: `${c.green}○${c.reset}`;
|
|
38
|
+
const priorityLabel = PRIORITY_LABELS[issue.priority] ?? String(issue.priority);
|
|
39
|
+
const assignee = issue.assignee ? ` · ${c.dim}@${issue.assignee}${c.reset}` : "";
|
|
40
|
+
const blocked = (issue.blockedBy?.length ?? 0) > 0 ? ` ${c.yellow}[blocked]${c.reset}` : "";
|
|
41
|
+
console.log(
|
|
42
|
+
`${statusIcon} ${c.bold}${issue.id}${c.reset} · ${issue.title} ${c.gray}[${priorityLabel} · ${issue.type}]${c.reset}${assignee}${blocked}`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function printIssueFull(issue: Issue): void {
|
|
47
|
+
const statusColor =
|
|
48
|
+
issue.status === "closed" ? c.gray : issue.status === "in_progress" ? c.cyan : c.green;
|
|
49
|
+
const priorityLabel = PRIORITY_LABELS[issue.priority] ?? String(issue.priority);
|
|
50
|
+
|
|
51
|
+
console.log(`${c.bold}${issue.id}${c.reset} ${statusColor}${issue.status}${c.reset}`);
|
|
52
|
+
console.log(`Title: ${issue.title}`);
|
|
53
|
+
console.log(`Type: ${issue.type} Priority: ${priorityLabel}`);
|
|
54
|
+
if (issue.assignee) console.log(`Assignee: ${issue.assignee}`);
|
|
55
|
+
if (issue.description) console.log(`\n${issue.description}`);
|
|
56
|
+
if (issue.blockedBy?.length) console.log(`Blocked by: ${issue.blockedBy.join(", ")}`);
|
|
57
|
+
if (issue.blocks?.length) console.log(`Blocks: ${issue.blocks.join(", ")}`);
|
|
58
|
+
if (issue.convoy) console.log(`Convoy: ${issue.convoy}`);
|
|
59
|
+
if (issue.closeReason) console.log(`Reason: ${issue.closeReason}`);
|
|
60
|
+
console.log(`Created: ${issue.createdAt}`);
|
|
61
|
+
console.log(`Updated: ${issue.updatedAt}`);
|
|
62
|
+
if (issue.closedAt) console.log(`Closed: ${issue.closedAt}`);
|
|
63
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
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
|
+
import { appendIssue, readIssues, withLock, writeIssues } from "./store";
|
|
6
|
+
import type { Issue } from "./types";
|
|
7
|
+
|
|
8
|
+
function makeIssue(overrides: Partial<Issue> = {}): Issue {
|
|
9
|
+
const now = new Date().toISOString();
|
|
10
|
+
return {
|
|
11
|
+
id: "test-a1b2",
|
|
12
|
+
title: "Test issue",
|
|
13
|
+
status: "open",
|
|
14
|
+
type: "task",
|
|
15
|
+
priority: 2,
|
|
16
|
+
createdAt: now,
|
|
17
|
+
updatedAt: now,
|
|
18
|
+
...overrides,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let tmpDir: string;
|
|
23
|
+
let seedsDir: string;
|
|
24
|
+
|
|
25
|
+
beforeEach(async () => {
|
|
26
|
+
tmpDir = await mkdtemp(join(tmpdir(), "seeds-store-test-"));
|
|
27
|
+
seedsDir = join(tmpDir, ".seeds");
|
|
28
|
+
await Bun.write(join(seedsDir, ".gitignore"), "*.lock\n");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterEach(async () => {
|
|
32
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("readIssues", () => {
|
|
36
|
+
test("returns empty array when issues.jsonl does not exist", async () => {
|
|
37
|
+
const issues = await readIssues(seedsDir);
|
|
38
|
+
expect(issues).toEqual([]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("returns empty array for empty file", async () => {
|
|
42
|
+
await Bun.write(join(seedsDir, "issues.jsonl"), "");
|
|
43
|
+
const issues = await readIssues(seedsDir);
|
|
44
|
+
expect(issues).toEqual([]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("reads single issue", async () => {
|
|
48
|
+
const issue = makeIssue();
|
|
49
|
+
await Bun.write(join(seedsDir, "issues.jsonl"), `${JSON.stringify(issue)}\n`);
|
|
50
|
+
const issues = await readIssues(seedsDir);
|
|
51
|
+
expect(issues).toHaveLength(1);
|
|
52
|
+
expect(issues[0]).toEqual(issue);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("reads multiple issues", async () => {
|
|
56
|
+
const issue1 = makeIssue({ id: "test-a1b2", title: "First" });
|
|
57
|
+
const issue2 = makeIssue({ id: "test-c3d4", title: "Second" });
|
|
58
|
+
const content = [JSON.stringify(issue1), JSON.stringify(issue2), ""].join("\n");
|
|
59
|
+
await Bun.write(join(seedsDir, "issues.jsonl"), content);
|
|
60
|
+
const issues = await readIssues(seedsDir);
|
|
61
|
+
expect(issues).toHaveLength(2);
|
|
62
|
+
expect(issues[0]?.id).toBe("test-a1b2");
|
|
63
|
+
expect(issues[1]?.id).toBe("test-c3d4");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("deduplicates by id — last occurrence wins", async () => {
|
|
67
|
+
const original = makeIssue({ id: "test-a1b2", title: "Original" });
|
|
68
|
+
const updated = makeIssue({ id: "test-a1b2", title: "Updated" });
|
|
69
|
+
const content = [JSON.stringify(original), JSON.stringify(updated), ""].join("\n");
|
|
70
|
+
await Bun.write(join(seedsDir, "issues.jsonl"), content);
|
|
71
|
+
const issues = await readIssues(seedsDir);
|
|
72
|
+
expect(issues).toHaveLength(1);
|
|
73
|
+
expect(issues[0]?.title).toBe("Updated");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("skips blank lines", async () => {
|
|
77
|
+
const issue = makeIssue();
|
|
78
|
+
const content = `\n${JSON.stringify(issue)}\n\n`;
|
|
79
|
+
await Bun.write(join(seedsDir, "issues.jsonl"), content);
|
|
80
|
+
const issues = await readIssues(seedsDir);
|
|
81
|
+
expect(issues).toHaveLength(1);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("appendIssue", () => {
|
|
86
|
+
test("creates issues.jsonl if it does not exist", async () => {
|
|
87
|
+
const issue = makeIssue();
|
|
88
|
+
await appendIssue(seedsDir, issue);
|
|
89
|
+
const issues = await readIssues(seedsDir);
|
|
90
|
+
expect(issues).toHaveLength(1);
|
|
91
|
+
expect(issues[0]).toEqual(issue);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("appends to existing file", async () => {
|
|
95
|
+
const issue1 = makeIssue({ id: "test-a1b2" });
|
|
96
|
+
const issue2 = makeIssue({ id: "test-c3d4" });
|
|
97
|
+
await appendIssue(seedsDir, issue1);
|
|
98
|
+
await appendIssue(seedsDir, issue2);
|
|
99
|
+
const issues = await readIssues(seedsDir);
|
|
100
|
+
expect(issues).toHaveLength(2);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("each appended issue is on its own line", async () => {
|
|
104
|
+
const issue = makeIssue();
|
|
105
|
+
await appendIssue(seedsDir, issue);
|
|
106
|
+
const content = await Bun.file(join(seedsDir, "issues.jsonl")).text();
|
|
107
|
+
const lines = content.split("\n").filter((l) => l.trim() !== "");
|
|
108
|
+
expect(lines).toHaveLength(1);
|
|
109
|
+
expect(JSON.parse(lines[0] ?? "{}")).toEqual(issue);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("writeIssues", () => {
|
|
114
|
+
test("writes issues atomically (overwrites file)", async () => {
|
|
115
|
+
const original = makeIssue({ id: "test-a1b2", title: "Original" });
|
|
116
|
+
await appendIssue(seedsDir, original);
|
|
117
|
+
|
|
118
|
+
const updated = makeIssue({ id: "test-a1b2", title: "Updated" });
|
|
119
|
+
await writeIssues(seedsDir, [updated]);
|
|
120
|
+
|
|
121
|
+
const issues = await readIssues(seedsDir);
|
|
122
|
+
expect(issues).toHaveLength(1);
|
|
123
|
+
expect(issues[0]?.title).toBe("Updated");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("writes empty array as empty file", async () => {
|
|
127
|
+
const issue = makeIssue();
|
|
128
|
+
await appendIssue(seedsDir, issue);
|
|
129
|
+
await writeIssues(seedsDir, []);
|
|
130
|
+
const issues = await readIssues(seedsDir);
|
|
131
|
+
expect(issues).toHaveLength(0);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("each issue serialized to its own line", async () => {
|
|
135
|
+
const issues = [makeIssue({ id: "test-a1b2" }), makeIssue({ id: "test-c3d4" })];
|
|
136
|
+
await writeIssues(seedsDir, issues);
|
|
137
|
+
const content = await Bun.file(join(seedsDir, "issues.jsonl")).text();
|
|
138
|
+
const lines = content.split("\n").filter((l) => l.trim() !== "");
|
|
139
|
+
expect(lines).toHaveLength(2);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("withLock", () => {
|
|
144
|
+
test("executes function and returns result", async () => {
|
|
145
|
+
const result = await withLock(seedsDir, async () => 42);
|
|
146
|
+
expect(result).toBe(42);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("serializes concurrent operations", async () => {
|
|
150
|
+
// Run multiple concurrent withLock calls and verify they all succeed
|
|
151
|
+
let counter = 0;
|
|
152
|
+
await Promise.all(
|
|
153
|
+
Array.from({ length: 5 }, () =>
|
|
154
|
+
withLock(seedsDir, async () => {
|
|
155
|
+
counter++;
|
|
156
|
+
}),
|
|
157
|
+
),
|
|
158
|
+
);
|
|
159
|
+
expect(counter).toBe(5);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("releases lock even if function throws", async () => {
|
|
163
|
+
await expect(
|
|
164
|
+
withLock(seedsDir, async () => {
|
|
165
|
+
throw new Error("intentional error");
|
|
166
|
+
}),
|
|
167
|
+
).rejects.toThrow("intentional error");
|
|
168
|
+
|
|
169
|
+
// Lock should be released — another withLock should succeed
|
|
170
|
+
const result = await withLock(seedsDir, async () => "ok");
|
|
171
|
+
expect(result).toBe("ok");
|
|
172
|
+
});
|
|
173
|
+
});
|