@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/store.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { closeSync, openSync, renameSync, statSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { Issue, Template } from "./types.ts";
|
|
5
|
+
import {
|
|
6
|
+
ISSUES_FILE,
|
|
7
|
+
LOCK_RETRY_MS,
|
|
8
|
+
LOCK_STALE_MS,
|
|
9
|
+
LOCK_TIMEOUT_MS,
|
|
10
|
+
TEMPLATES_FILE,
|
|
11
|
+
} from "./types.ts";
|
|
12
|
+
|
|
13
|
+
function lockFilePath(dataFilePath: string): string {
|
|
14
|
+
return `${dataFilePath}.lock`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function acquireLock(dataFilePath: string): Promise<void> {
|
|
18
|
+
const lock = lockFilePath(dataFilePath);
|
|
19
|
+
const start = Date.now();
|
|
20
|
+
while (true) {
|
|
21
|
+
try {
|
|
22
|
+
const fd = openSync(lock, "wx");
|
|
23
|
+
closeSync(fd);
|
|
24
|
+
return;
|
|
25
|
+
} catch (err: unknown) {
|
|
26
|
+
const nodeErr = err as NodeJS.ErrnoException;
|
|
27
|
+
if (nodeErr.code !== "EEXIST") throw err;
|
|
28
|
+
// Check if stale
|
|
29
|
+
try {
|
|
30
|
+
const st = statSync(lock);
|
|
31
|
+
if (Date.now() - st.mtimeMs > LOCK_STALE_MS) {
|
|
32
|
+
unlinkSync(lock);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
} catch {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (Date.now() - start > LOCK_TIMEOUT_MS) {
|
|
39
|
+
throw new Error(`Timeout acquiring lock for ${dataFilePath}`);
|
|
40
|
+
}
|
|
41
|
+
await sleep(LOCK_RETRY_MS);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function releaseLock(dataFilePath: string): void {
|
|
47
|
+
try {
|
|
48
|
+
unlinkSync(lockFilePath(dataFilePath));
|
|
49
|
+
} catch {
|
|
50
|
+
// best-effort
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function sleep(ms: number): Promise<void> {
|
|
55
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function withLock<T>(dataFilePath: string, fn: () => Promise<T>): Promise<T> {
|
|
59
|
+
await acquireLock(dataFilePath);
|
|
60
|
+
try {
|
|
61
|
+
return await fn();
|
|
62
|
+
} finally {
|
|
63
|
+
releaseLock(dataFilePath);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function parseJsonl<T>(content: string): T[] {
|
|
68
|
+
const results: T[] = [];
|
|
69
|
+
for (const line of content.split("\n")) {
|
|
70
|
+
const trimmed = line.trim();
|
|
71
|
+
if (!trimmed) continue;
|
|
72
|
+
try {
|
|
73
|
+
results.push(JSON.parse(trimmed) as T);
|
|
74
|
+
} catch {
|
|
75
|
+
// skip malformed lines
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return results;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function deduplicateById<T extends { id: string }>(items: T[]): T[] {
|
|
82
|
+
const map = new Map<string, T>();
|
|
83
|
+
for (const item of items) {
|
|
84
|
+
map.set(item.id, item); // last occurrence wins
|
|
85
|
+
}
|
|
86
|
+
return Array.from(map.values());
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function readIssues(seedsDir: string): Promise<Issue[]> {
|
|
90
|
+
const file = Bun.file(join(seedsDir, ISSUES_FILE));
|
|
91
|
+
if (!(await file.exists())) return [];
|
|
92
|
+
const content = await file.text();
|
|
93
|
+
return deduplicateById(parseJsonl<Issue>(content));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function writeIssues(seedsDir: string, issues: Issue[]): Promise<void> {
|
|
97
|
+
const filePath = join(seedsDir, ISSUES_FILE);
|
|
98
|
+
const tmpPath = `${filePath}.tmp.${randomBytes(4).toString("hex")}`;
|
|
99
|
+
const content = `${issues.map((i) => JSON.stringify(i)).join("\n")}\n`;
|
|
100
|
+
await Bun.write(tmpPath, content);
|
|
101
|
+
renameSync(tmpPath, filePath);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function appendIssue(seedsDir: string, issue: Issue): Promise<void> {
|
|
105
|
+
const filePath = join(seedsDir, ISSUES_FILE);
|
|
106
|
+
const tmpPath = `${filePath}.tmp.${randomBytes(4).toString("hex")}`;
|
|
107
|
+
const file = Bun.file(filePath);
|
|
108
|
+
const existing = (await file.exists()) ? await file.text() : "";
|
|
109
|
+
await Bun.write(tmpPath, `${existing + JSON.stringify(issue)}\n`);
|
|
110
|
+
renameSync(tmpPath, filePath);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function readTemplates(seedsDir: string): Promise<Template[]> {
|
|
114
|
+
const file = Bun.file(join(seedsDir, TEMPLATES_FILE));
|
|
115
|
+
if (!(await file.exists())) return [];
|
|
116
|
+
const content = await file.text();
|
|
117
|
+
return deduplicateById(parseJsonl<Template>(content));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function writeTemplates(seedsDir: string, templates: Template[]): Promise<void> {
|
|
121
|
+
const filePath = join(seedsDir, TEMPLATES_FILE);
|
|
122
|
+
const tmpPath = `${filePath}.tmp.${randomBytes(4).toString("hex")}`;
|
|
123
|
+
const content = `${templates.map((t) => JSON.stringify(t)).join("\n")}\n`;
|
|
124
|
+
await Bun.write(tmpPath, content);
|
|
125
|
+
renameSync(tmpPath, filePath);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function appendTemplate(seedsDir: string, template: Template): Promise<void> {
|
|
129
|
+
const filePath = join(seedsDir, TEMPLATES_FILE);
|
|
130
|
+
const tmpPath = `${filePath}.tmp.${randomBytes(4).toString("hex")}`;
|
|
131
|
+
const file = Bun.file(filePath);
|
|
132
|
+
const existing = (await file.exists()) ? await file.text() : "";
|
|
133
|
+
await Bun.write(tmpPath, `${existing + JSON.stringify(template)}\n`);
|
|
134
|
+
renameSync(tmpPath, filePath);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function issuesPath(seedsDir: string): string {
|
|
138
|
+
return join(seedsDir, ISSUES_FILE);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function templatesPath(seedsDir: string): string {
|
|
142
|
+
return join(seedsDir, TEMPLATES_FILE);
|
|
143
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export interface Issue {
|
|
2
|
+
id: string;
|
|
3
|
+
title: string;
|
|
4
|
+
status: "open" | "in_progress" | "closed";
|
|
5
|
+
type: "task" | "bug" | "feature" | "epic";
|
|
6
|
+
priority: number;
|
|
7
|
+
assignee?: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
closeReason?: string;
|
|
10
|
+
blocks?: string[];
|
|
11
|
+
blockedBy?: string[];
|
|
12
|
+
convoy?: string;
|
|
13
|
+
createdAt: string;
|
|
14
|
+
updatedAt: string;
|
|
15
|
+
closedAt?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface TemplateStep {
|
|
19
|
+
title: string;
|
|
20
|
+
type?: string;
|
|
21
|
+
priority?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface Template {
|
|
25
|
+
id: string;
|
|
26
|
+
name: string;
|
|
27
|
+
steps: TemplateStep[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface Config {
|
|
31
|
+
project: string;
|
|
32
|
+
version: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ConvoyStatus {
|
|
36
|
+
templateId: string;
|
|
37
|
+
total: number;
|
|
38
|
+
completed: number;
|
|
39
|
+
inProgress: number;
|
|
40
|
+
blocked: number;
|
|
41
|
+
issues: string[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const SEEDS_DIR_NAME = ".seeds";
|
|
45
|
+
export const ISSUES_FILE = "issues.jsonl";
|
|
46
|
+
export const TEMPLATES_FILE = "templates.jsonl";
|
|
47
|
+
export const CONFIG_FILE = "config.yaml";
|
|
48
|
+
export const LOCK_STALE_MS = 30_000;
|
|
49
|
+
export const LOCK_RETRY_MS = 50;
|
|
50
|
+
export const LOCK_TIMEOUT_MS = 5_000;
|
|
51
|
+
|
|
52
|
+
export const VALID_TYPES = ["task", "bug", "feature", "epic"] as const;
|
|
53
|
+
export const VALID_STATUSES = ["open", "in_progress", "closed"] as const;
|
|
54
|
+
|
|
55
|
+
export const PRIORITY_LABELS: Record<number, string> = {
|
|
56
|
+
0: "Critical",
|
|
57
|
+
1: "High",
|
|
58
|
+
2: "Medium",
|
|
59
|
+
3: "Low",
|
|
60
|
+
4: "Backlog",
|
|
61
|
+
};
|
package/src/yaml.test.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { parseYaml, stringifyYaml } from "./yaml";
|
|
3
|
+
|
|
4
|
+
describe("parseYaml", () => {
|
|
5
|
+
test("parses simple key-value pairs", () => {
|
|
6
|
+
const result = parseYaml('project: overstory\nversion: "1"');
|
|
7
|
+
expect(result.project).toBe("overstory");
|
|
8
|
+
expect(result.version).toBe("1");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("parses unquoted string values", () => {
|
|
12
|
+
const result = parseYaml("name: myapp");
|
|
13
|
+
expect(result.name).toBe("myapp");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("parses double-quoted string values", () => {
|
|
17
|
+
const result = parseYaml('version: "1.0.0"');
|
|
18
|
+
expect(result.version).toBe("1.0.0");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("parses single-quoted string values", () => {
|
|
22
|
+
const result = parseYaml("name: 'myapp'");
|
|
23
|
+
expect(result.name).toBe("myapp");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("ignores blank lines", () => {
|
|
27
|
+
const result = parseYaml("project: seeds\n\nversion: 1");
|
|
28
|
+
expect(result.project).toBe("seeds");
|
|
29
|
+
expect(result.version).toBe("1");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("ignores comment lines", () => {
|
|
33
|
+
const result = parseYaml("# This is a comment\nproject: seeds");
|
|
34
|
+
expect(result.project).toBe("seeds");
|
|
35
|
+
expect(Object.keys(result)).not.toContain("# This is a comment");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("returns empty object for empty string", () => {
|
|
39
|
+
const result = parseYaml("");
|
|
40
|
+
expect(Object.keys(result)).toHaveLength(0);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("parses config.yaml format used by seeds", () => {
|
|
44
|
+
const yaml = 'project: overstory\nversion: "1"';
|
|
45
|
+
const result = parseYaml(yaml);
|
|
46
|
+
expect(result).toEqual({ project: "overstory", version: "1" });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("handles values with colons in quoted strings", () => {
|
|
50
|
+
const result = parseYaml('url: "http://example.com"');
|
|
51
|
+
expect(result.url).toBe("http://example.com");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("trims whitespace from keys and values", () => {
|
|
55
|
+
const result = parseYaml(" project : seeds ");
|
|
56
|
+
expect(result.project).toBe("seeds");
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("stringifyYaml", () => {
|
|
61
|
+
test("serializes simple key-value pairs", () => {
|
|
62
|
+
const yaml = stringifyYaml({ project: "seeds", version: "1" });
|
|
63
|
+
const parsed = parseYaml(yaml);
|
|
64
|
+
expect(parsed.project).toBe("seeds");
|
|
65
|
+
expect(parsed.version).toBe("1");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("round-trips flat objects", () => {
|
|
69
|
+
const original = { project: "overstory", version: "1" };
|
|
70
|
+
const yaml = stringifyYaml(original);
|
|
71
|
+
const parsed = parseYaml(yaml);
|
|
72
|
+
expect(parsed).toEqual(original);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("produces newline-terminated output", () => {
|
|
76
|
+
const yaml = stringifyYaml({ key: "value" });
|
|
77
|
+
expect(yaml.endsWith("\n")).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
});
|
package/src/yaml.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal YAML parser for flat key-value format.
|
|
3
|
+
* Only handles: key: value (string values, no nesting or arrays).
|
|
4
|
+
*/
|
|
5
|
+
export function parseYaml(content: string): Record<string, string> {
|
|
6
|
+
const result: Record<string, string> = {};
|
|
7
|
+
for (const line of content.split("\n")) {
|
|
8
|
+
const trimmed = line.trim();
|
|
9
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
10
|
+
const colonIdx = trimmed.indexOf(":");
|
|
11
|
+
if (colonIdx === -1) continue;
|
|
12
|
+
const key = trimmed.slice(0, colonIdx).trim();
|
|
13
|
+
const raw = trimmed.slice(colonIdx + 1).trim();
|
|
14
|
+
if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) {
|
|
15
|
+
result[key] = raw.slice(1, -1);
|
|
16
|
+
} else {
|
|
17
|
+
result[key] = raw;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return result;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function stringifyYaml(data: Record<string, string>): string {
|
|
24
|
+
return `${Object.entries(data)
|
|
25
|
+
.map(([k, v]) => `${k}: "${v}"`)
|
|
26
|
+
.join("\n")}\n`;
|
|
27
|
+
}
|