@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.
@@ -0,0 +1,330 @@
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
+ beforeEach(async () => {
31
+ tmpDir = await mkdtemp(join(tmpdir(), "seeds-tpl-test-"));
32
+ await run(["init"], tmpDir);
33
+ });
34
+
35
+ afterEach(async () => {
36
+ await rm(tmpDir, { recursive: true, force: true });
37
+ });
38
+
39
+ describe("sd tpl create", () => {
40
+ test("creates a template with --name", async () => {
41
+ const result = await runJson<{ success: boolean; id: string }>(
42
+ ["tpl", "create", "--name", "scout-build-review"],
43
+ tmpDir,
44
+ );
45
+ expect(result.success).toBe(true);
46
+ expect(result.id).toMatch(/^tpl-[0-9a-f]{4}$/);
47
+ });
48
+
49
+ test("requires --name", async () => {
50
+ const { exitCode } = await run(["tpl", "create"], tmpDir);
51
+ expect(exitCode).not.toBe(0);
52
+ });
53
+ });
54
+
55
+ describe("sd tpl step add", () => {
56
+ test("adds a step to a template", async () => {
57
+ const create = await runJson<{ success: boolean; id: string }>(
58
+ ["tpl", "create", "--name", "my-template"],
59
+ tmpDir,
60
+ );
61
+ const result = await runJson<{ success: boolean }>(
62
+ ["tpl", "step", "add", create.id, "--title", "Step 1"],
63
+ tmpDir,
64
+ );
65
+ expect(result.success).toBe(true);
66
+ });
67
+
68
+ test("added step appears in tpl show", async () => {
69
+ const create = await runJson<{ success: boolean; id: string }>(
70
+ ["tpl", "create", "--name", "my-template"],
71
+ tmpDir,
72
+ );
73
+ await run(["tpl", "step", "add", create.id, "--title", "Scout: {prefix}"], tmpDir);
74
+ await run(["tpl", "step", "add", create.id, "--title", "Build: {prefix}"], tmpDir);
75
+
76
+ const show = await runJson<{
77
+ success: boolean;
78
+ template: { steps: Array<{ title: string }> };
79
+ }>(["tpl", "show", create.id], tmpDir);
80
+
81
+ expect(show.template.steps).toHaveLength(2);
82
+ expect(show.template.steps[0]?.title).toBe("Scout: {prefix}");
83
+ expect(show.template.steps[1]?.title).toBe("Build: {prefix}");
84
+ });
85
+
86
+ test("accepts --type and --priority for step", async () => {
87
+ const create = await runJson<{ success: boolean; id: string }>(
88
+ ["tpl", "create", "--name", "typed-template"],
89
+ tmpDir,
90
+ );
91
+ await run(
92
+ ["tpl", "step", "add", create.id, "--title", "Bug step", "--type", "bug", "--priority", "1"],
93
+ tmpDir,
94
+ );
95
+ const show = await runJson<{
96
+ success: boolean;
97
+ template: { steps: Array<{ title: string; type?: string; priority?: number }> };
98
+ }>(["tpl", "show", create.id], tmpDir);
99
+
100
+ const step = show.template.steps[0];
101
+ expect(step?.type).toBe("bug");
102
+ expect(step?.priority).toBe(1);
103
+ });
104
+
105
+ test("step defaults to type=task priority=2", async () => {
106
+ const create = await runJson<{ success: boolean; id: string }>(
107
+ ["tpl", "create", "--name", "default-template"],
108
+ tmpDir,
109
+ );
110
+ await run(["tpl", "step", "add", create.id, "--title", "Default step"], tmpDir);
111
+ const show = await runJson<{
112
+ success: boolean;
113
+ template: { steps: Array<{ title: string; type?: string; priority?: number }> };
114
+ }>(["tpl", "show", create.id], tmpDir);
115
+
116
+ const step = show.template.steps[0];
117
+ expect(step?.type ?? "task").toBe("task");
118
+ expect(step?.priority ?? 2).toBe(2);
119
+ });
120
+ });
121
+
122
+ describe("sd tpl list", () => {
123
+ test("lists all templates", async () => {
124
+ await run(["tpl", "create", "--name", "template-1"], tmpDir);
125
+ await run(["tpl", "create", "--name", "template-2"], tmpDir);
126
+
127
+ const result = await runJson<{ success: boolean; templates: unknown[]; count: number }>(
128
+ ["tpl", "list"],
129
+ tmpDir,
130
+ );
131
+ expect(result.success).toBe(true);
132
+ expect(result.count).toBe(2);
133
+ expect(result.templates).toHaveLength(2);
134
+ });
135
+
136
+ test("returns empty list when no templates", async () => {
137
+ const result = await runJson<{ success: boolean; templates: unknown[]; count: number }>(
138
+ ["tpl", "list"],
139
+ tmpDir,
140
+ );
141
+ expect(result.success).toBe(true);
142
+ expect(result.count).toBe(0);
143
+ });
144
+ });
145
+
146
+ describe("sd tpl show", () => {
147
+ test("shows template with its steps", async () => {
148
+ const create = await runJson<{ success: boolean; id: string }>(
149
+ ["tpl", "create", "--name", "show-test"],
150
+ tmpDir,
151
+ );
152
+ await run(["tpl", "step", "add", create.id, "--title", "Step A"], tmpDir);
153
+
154
+ const show = await runJson<{
155
+ success: boolean;
156
+ template: { id: string; name: string; steps: unknown[] };
157
+ }>(["tpl", "show", create.id], tmpDir);
158
+
159
+ expect(show.success).toBe(true);
160
+ expect(show.template.id).toBe(create.id);
161
+ expect(show.template.name).toBe("show-test");
162
+ expect(show.template.steps).toHaveLength(1);
163
+ });
164
+
165
+ test("fails for unknown template id", async () => {
166
+ const result = await runJson<{ success: boolean; error: string }>(
167
+ ["tpl", "show", "tpl-ffff"],
168
+ tmpDir,
169
+ );
170
+ expect(result.success).toBe(false);
171
+ expect(result.error).toBeTruthy();
172
+ });
173
+ });
174
+
175
+ describe("sd tpl pour", () => {
176
+ test("pours template into issues", async () => {
177
+ const create = await runJson<{ success: boolean; id: string }>(
178
+ ["tpl", "create", "--name", "scout-build"],
179
+ tmpDir,
180
+ );
181
+ await run(["tpl", "step", "add", create.id, "--title", "Scout: {prefix}"], tmpDir);
182
+ await run(["tpl", "step", "add", create.id, "--title", "Build: {prefix}"], tmpDir);
183
+
184
+ const result = await runJson<{ success: boolean; ids: string[] }>(
185
+ ["tpl", "pour", create.id, "--prefix", "auth"],
186
+ tmpDir,
187
+ );
188
+ expect(result.success).toBe(true);
189
+ expect(result.ids).toHaveLength(2);
190
+ });
191
+
192
+ test("created issues have interpolated titles", async () => {
193
+ const create = await runJson<{ success: boolean; id: string }>(
194
+ ["tpl", "create", "--name", "single-step"],
195
+ tmpDir,
196
+ );
197
+ await run(["tpl", "step", "add", create.id, "--title", "Build: {prefix}"], tmpDir);
198
+
199
+ const pour = await runJson<{ success: boolean; ids: string[] }>(
200
+ ["tpl", "pour", create.id, "--prefix", "oauth"],
201
+ tmpDir,
202
+ );
203
+
204
+ const show = await runJson<{ success: boolean; issue: { title: string } }>(
205
+ ["show", pour.ids[0] ?? ""],
206
+ tmpDir,
207
+ );
208
+ expect(show.issue.title).toBe("Build: oauth");
209
+ });
210
+
211
+ test("step N+1 is blocked by step N (convoy dependency chain)", async () => {
212
+ const create = await runJson<{ success: boolean; id: string }>(
213
+ ["tpl", "create", "--name", "convoy"],
214
+ tmpDir,
215
+ );
216
+ await run(["tpl", "step", "add", create.id, "--title", "Step 1"], tmpDir);
217
+ await run(["tpl", "step", "add", create.id, "--title", "Step 2"], tmpDir);
218
+ await run(["tpl", "step", "add", create.id, "--title", "Step 3"], tmpDir);
219
+
220
+ const pour = await runJson<{ success: boolean; ids: string[] }>(
221
+ ["tpl", "pour", create.id, "--prefix", "test"],
222
+ tmpDir,
223
+ );
224
+
225
+ // Step 2 blocked by Step 1
226
+ const s2 = await runJson<{ success: boolean; issue: { blockedBy?: string[] } }>(
227
+ ["show", pour.ids[1] ?? ""],
228
+ tmpDir,
229
+ );
230
+ expect(s2.issue.blockedBy).toContain(pour.ids[0]);
231
+
232
+ // Step 3 blocked by Step 2
233
+ const s3 = await runJson<{ success: boolean; issue: { blockedBy?: string[] } }>(
234
+ ["show", pour.ids[2] ?? ""],
235
+ tmpDir,
236
+ );
237
+ expect(s3.issue.blockedBy).toContain(pour.ids[1]);
238
+ });
239
+
240
+ test("first step is ready (not blocked)", async () => {
241
+ const create = await runJson<{ success: boolean; id: string }>(
242
+ ["tpl", "create", "--name", "convoy-ready"],
243
+ tmpDir,
244
+ );
245
+ await run(["tpl", "step", "add", create.id, "--title", "First step"], tmpDir);
246
+ await run(["tpl", "step", "add", create.id, "--title", "Second step"], tmpDir);
247
+
248
+ const pour = await runJson<{ success: boolean; ids: string[] }>(
249
+ ["tpl", "pour", create.id, "--prefix", "x"],
250
+ tmpDir,
251
+ );
252
+
253
+ const ready = await runJson<{ success: boolean; issues: Array<{ id: string }> }>(
254
+ ["ready"],
255
+ tmpDir,
256
+ );
257
+ const ids = ready.issues.map((i) => i.id);
258
+ expect(ids).toContain(pour.ids[0] ?? "");
259
+ expect(ids).not.toContain(pour.ids[1] ?? "");
260
+ });
261
+
262
+ test("requires --prefix", async () => {
263
+ const create = await runJson<{ success: boolean; id: string }>(
264
+ ["tpl", "create", "--name", "prefix-required"],
265
+ tmpDir,
266
+ );
267
+ await run(["tpl", "step", "add", create.id, "--title", "Step"], tmpDir);
268
+
269
+ const { exitCode } = await run(["tpl", "pour", create.id], tmpDir);
270
+ expect(exitCode).not.toBe(0);
271
+ });
272
+ });
273
+
274
+ describe("sd tpl status", () => {
275
+ test("returns convoy completion status", async () => {
276
+ const create = await runJson<{ success: boolean; id: string }>(
277
+ ["tpl", "create", "--name", "status-test"],
278
+ tmpDir,
279
+ );
280
+ await run(["tpl", "step", "add", create.id, "--title", "Step 1"], tmpDir);
281
+ await run(["tpl", "step", "add", create.id, "--title", "Step 2"], tmpDir);
282
+
283
+ const pour = await runJson<{ success: boolean; ids: string[] }>(
284
+ ["tpl", "pour", create.id, "--prefix", "feature"],
285
+ tmpDir,
286
+ );
287
+
288
+ const status = await runJson<{
289
+ success: boolean;
290
+ status: {
291
+ templateId: string;
292
+ total: number;
293
+ completed: number;
294
+ inProgress: number;
295
+ issues: string[];
296
+ };
297
+ }>(["tpl", "status", create.id], tmpDir);
298
+
299
+ expect(status.success).toBe(true);
300
+ expect(status.status.templateId).toBe(create.id);
301
+ expect(status.status.total).toBe(2);
302
+ expect(status.status.completed).toBe(0);
303
+ expect(status.status.issues).toHaveLength(2);
304
+ });
305
+
306
+ test("tracks completion after closing convoy issues", async () => {
307
+ const create = await runJson<{ success: boolean; id: string }>(
308
+ ["tpl", "create", "--name", "track-completion"],
309
+ tmpDir,
310
+ );
311
+ await run(["tpl", "step", "add", create.id, "--title", "Step 1"], tmpDir);
312
+ await run(["tpl", "step", "add", create.id, "--title", "Step 2"], tmpDir);
313
+
314
+ const pour = await runJson<{ success: boolean; ids: string[] }>(
315
+ ["tpl", "pour", create.id, "--prefix", "work"],
316
+ tmpDir,
317
+ );
318
+
319
+ // Close first step
320
+ await run(["close", pour.ids[0] ?? ""], tmpDir);
321
+
322
+ const status = await runJson<{
323
+ success: boolean;
324
+ status: { completed: number; total: number };
325
+ }>(["tpl", "status", create.id], tmpDir);
326
+
327
+ expect(status.status.completed).toBe(1);
328
+ expect(status.status.total).toBe(2);
329
+ });
330
+ });
@@ -0,0 +1,279 @@
1
+ import { findSeedsDir, readConfig } from "../config.ts";
2
+ import { generateId } from "../id.ts";
3
+ import { c, outputJson, printIssueOneLine, printSuccess } from "../output.ts";
4
+ import {
5
+ appendIssue,
6
+ appendTemplate,
7
+ issuesPath,
8
+ readIssues,
9
+ readTemplates,
10
+ templatesPath,
11
+ withLock,
12
+ writeIssues,
13
+ writeTemplates,
14
+ } from "../store.ts";
15
+ import type { Issue, Template, TemplateStep } from "../types.ts";
16
+ import { VALID_TYPES } from "../types.ts";
17
+
18
+ function parseArgs(args: string[]) {
19
+ const flags: Record<string, string | boolean> = {};
20
+ const positional: string[] = [];
21
+ let i = 0;
22
+ while (i < args.length) {
23
+ const arg = args[i];
24
+ if (!arg) {
25
+ i++;
26
+ continue;
27
+ }
28
+ if (arg.startsWith("--")) {
29
+ const key = arg.slice(2);
30
+ const eqIdx = key.indexOf("=");
31
+ if (eqIdx !== -1) {
32
+ flags[key.slice(0, eqIdx)] = key.slice(eqIdx + 1);
33
+ i++;
34
+ } else {
35
+ const next = args[i + 1];
36
+ if (next !== undefined && !next.startsWith("--")) {
37
+ flags[key] = next;
38
+ i += 2;
39
+ } else {
40
+ flags[key] = true;
41
+ i++;
42
+ }
43
+ }
44
+ } else {
45
+ positional.push(arg);
46
+ i++;
47
+ }
48
+ }
49
+ return { flags, positional };
50
+ }
51
+
52
+ export async function run(args: string[], seedsDir?: string): Promise<void> {
53
+ const jsonMode = args.includes("--json");
54
+ const { flags, positional } = parseArgs(args);
55
+ const subcmd = positional[0];
56
+
57
+ if (!subcmd) throw new Error("Usage: sd tpl <create|step|list|show|pour|status>");
58
+
59
+ const dir = seedsDir ?? (await findSeedsDir());
60
+
61
+ // sd tpl create --name "..."
62
+ if (subcmd === "create") {
63
+ const name = flags.name;
64
+ if (!name || typeof name !== "string") throw new Error("--name is required");
65
+
66
+ let createdId: string;
67
+ await withLock(templatesPath(dir), async () => {
68
+ const existing = await readTemplates(dir);
69
+ const existingIds = new Set(existing.map((t) => t.id));
70
+ const id = generateId("tpl", existingIds);
71
+ const template: Template = { id, name, steps: [] };
72
+ await appendTemplate(dir, template);
73
+ createdId = id;
74
+ });
75
+
76
+ if (jsonMode) {
77
+ outputJson({ success: true, command: "tpl create", id: createdId! });
78
+ } else {
79
+ printSuccess(`Created template ${createdId!}: ${name}`);
80
+ }
81
+ return;
82
+ }
83
+
84
+ // sd tpl step add <template-id> --title "..." [--type task] [--priority 2]
85
+ if (subcmd === "step") {
86
+ const stepSubcmd = positional[1];
87
+ if (stepSubcmd !== "add") throw new Error("Usage: sd tpl step add <id> --title ...");
88
+ const templateId = positional[2];
89
+ if (!templateId) throw new Error("Template ID is required");
90
+ const title = flags.title;
91
+ if (!title || typeof title !== "string") throw new Error("--title is required");
92
+
93
+ const typeVal = typeof flags.type === "string" ? flags.type : "task";
94
+ if (!(VALID_TYPES as readonly string[]).includes(typeVal)) {
95
+ throw new Error(`--type must be one of: ${VALID_TYPES.join(", ")}`);
96
+ }
97
+ const priorityStr = typeof flags.priority === "string" ? flags.priority : "2";
98
+ const priority = Number.parseInt(priorityStr, 10);
99
+
100
+ let stepCount = 0;
101
+ await withLock(templatesPath(dir), async () => {
102
+ const templates = await readTemplates(dir);
103
+ const idx = templates.findIndex((t) => t.id === templateId);
104
+ if (idx === -1) throw new Error(`Template not found: ${templateId}`);
105
+ const tpl = templates[idx]!;
106
+ const step: TemplateStep = { title, type: typeVal, priority };
107
+ templates[idx] = { ...tpl, steps: [...tpl.steps, step] };
108
+ stepCount = templates[idx]?.steps.length;
109
+ await writeTemplates(dir, templates);
110
+ });
111
+
112
+ if (jsonMode) {
113
+ outputJson({ success: true, command: "tpl step add", id: templateId, stepCount });
114
+ } else {
115
+ printSuccess(`Added step ${stepCount} to ${templateId}: "${title}"`);
116
+ }
117
+ return;
118
+ }
119
+
120
+ // sd tpl list
121
+ if (subcmd === "list") {
122
+ const templates = await readTemplates(dir);
123
+ if (jsonMode) {
124
+ outputJson({ success: true, command: "tpl list", templates, count: templates.length });
125
+ } else {
126
+ if (templates.length === 0) {
127
+ console.log("No templates.");
128
+ return;
129
+ }
130
+ for (const tpl of templates) {
131
+ console.log(
132
+ `${c.bold}${tpl.id}${c.reset} ${tpl.name} ${c.gray}(${tpl.steps.length} steps)${c.reset}`,
133
+ );
134
+ }
135
+ }
136
+ return;
137
+ }
138
+
139
+ // sd tpl show <id>
140
+ if (subcmd === "show") {
141
+ const templateId = positional[1];
142
+ if (!templateId) throw new Error("Usage: sd tpl show <id>");
143
+ const templates = await readTemplates(dir);
144
+ const tpl = templates.find((t) => t.id === templateId);
145
+ if (!tpl) throw new Error(`Template not found: ${templateId}`);
146
+
147
+ if (jsonMode) {
148
+ outputJson({ success: true, command: "tpl show", template: tpl });
149
+ } else {
150
+ console.log(`${c.bold}${tpl.id}${c.reset} ${tpl.name}`);
151
+ console.log(`Steps (${tpl.steps.length}):`);
152
+ tpl.steps.forEach((step, i) => {
153
+ console.log(
154
+ ` ${i + 1}. ${step.title} ${c.gray}[${step.type ?? "task"} P${step.priority ?? 2}]${c.reset}`,
155
+ );
156
+ });
157
+ }
158
+ return;
159
+ }
160
+
161
+ // sd tpl pour <id> --prefix "..."
162
+ if (subcmd === "pour") {
163
+ const templateId = positional[1];
164
+ if (!templateId) throw new Error("Usage: sd tpl pour <id> --prefix ...");
165
+ if (typeof flags.prefix !== "string") throw new Error("--prefix is required");
166
+ const prefix = flags.prefix;
167
+
168
+ const templates = await readTemplates(dir);
169
+ const tpl = templates.find((t) => t.id === templateId);
170
+ if (!tpl) throw new Error(`Template not found: ${templateId}`);
171
+ if (tpl.steps.length === 0) throw new Error(`Template ${templateId} has no steps`);
172
+
173
+ const config = await readConfig(dir);
174
+ const createdIds: string[] = [];
175
+
176
+ await withLock(issuesPath(dir), async () => {
177
+ const existing = await readIssues(dir);
178
+ const existingIds = new Set(existing.map((i) => i.id));
179
+ const now = new Date().toISOString();
180
+
181
+ // Create all issues first (collect IDs)
182
+ const newIssues: Issue[] = [];
183
+ for (const step of tpl.steps) {
184
+ const id = generateId(
185
+ config.project,
186
+ new Set([...existingIds, ...newIssues.map((i) => i.id)]),
187
+ );
188
+ const title = step.title.replace(/\{prefix\}/g, prefix);
189
+ const issue: Issue = {
190
+ id,
191
+ title,
192
+ status: "open",
193
+ type: (step.type ?? "task") as Issue["type"],
194
+ priority: step.priority ?? 2,
195
+ createdAt: now,
196
+ updatedAt: now,
197
+ convoy: templateId,
198
+ };
199
+ newIssues.push(issue);
200
+ createdIds.push(id);
201
+ }
202
+
203
+ // Wire dependencies: step[i+1] blocked by step[i]
204
+ for (let i = 1; i < newIssues.length; i++) {
205
+ const prev = newIssues[i - 1]!;
206
+ const curr = newIssues[i]!;
207
+ curr.blockedBy = [prev.id];
208
+ prev.blocks = [curr.id];
209
+ }
210
+
211
+ // Append all new issues
212
+ const allIssues = [...existing, ...newIssues];
213
+ await writeIssues(dir, allIssues);
214
+ });
215
+
216
+ if (jsonMode) {
217
+ outputJson({ success: true, command: "tpl pour", ids: createdIds });
218
+ } else {
219
+ printSuccess(`Poured template ${templateId} — created ${createdIds.length} issues`);
220
+ for (const id of createdIds) console.log(` ${id}`);
221
+ }
222
+ return;
223
+ }
224
+
225
+ // sd tpl status <id>
226
+ if (subcmd === "status") {
227
+ const templateId = positional[1];
228
+ if (!templateId) throw new Error("Usage: sd tpl status <id>");
229
+
230
+ const issues = await readIssues(dir);
231
+ const convoyIssues = issues.filter((i: Issue) => i.convoy === templateId);
232
+
233
+ if (convoyIssues.length === 0) {
234
+ if (jsonMode) {
235
+ outputJson({ success: true, command: "tpl status", templateId, total: 0, issues: [] });
236
+ } else {
237
+ console.log(`No issues found for convoy ${templateId}`);
238
+ }
239
+ return;
240
+ }
241
+
242
+ const closedIds = new Set(issues.filter((i: Issue) => i.status === "closed").map((i) => i.id));
243
+ const completed = convoyIssues.filter((i) => i.status === "closed").length;
244
+ const inProgress = convoyIssues.filter((i) => i.status === "in_progress").length;
245
+ const blocked = convoyIssues.filter((i) => {
246
+ if (i.status === "closed") return false;
247
+ return (i.blockedBy ?? []).some((bid) => !closedIds.has(bid));
248
+ }).length;
249
+
250
+ const status = {
251
+ templateId,
252
+ total: convoyIssues.length,
253
+ completed,
254
+ inProgress,
255
+ blocked,
256
+ issues: convoyIssues.map((i) => i.id),
257
+ };
258
+
259
+ if (jsonMode) {
260
+ outputJson({ success: true, command: "tpl status", status });
261
+ } else {
262
+ console.log(`${c.bold}Convoy: ${templateId}${c.reset}`);
263
+ console.log(` Total: ${status.total}`);
264
+ console.log(` Completed: ${completed}`);
265
+ console.log(` In progress: ${inProgress}`);
266
+ console.log(` Blocked: ${blocked}`);
267
+ console.log(" Issues:");
268
+ for (const issue of convoyIssues) {
269
+ process.stdout.write(" ");
270
+ printIssueOneLine(issue);
271
+ }
272
+ }
273
+ return;
274
+ }
275
+
276
+ throw new Error(
277
+ `Unknown tpl subcommand: ${subcmd}. Use create, step, list, show, pour, or status.`,
278
+ );
279
+ }
@@ -0,0 +1,97 @@
1
+ import { findSeedsDir } from "../config.ts";
2
+ import { outputJson, printSuccess } from "../output.ts";
3
+ import { issuesPath, readIssues, withLock, writeIssues } from "../store.ts";
4
+ import type { Issue } from "../types.ts";
5
+ import { VALID_STATUSES, VALID_TYPES } from "../types.ts";
6
+
7
+ function parseArgs(args: string[]) {
8
+ const flags: Record<string, string | boolean> = {};
9
+ let i = 0;
10
+ while (i < args.length) {
11
+ const arg = args[i];
12
+ if (!arg) {
13
+ i++;
14
+ continue;
15
+ }
16
+ if (arg.startsWith("--")) {
17
+ const key = arg.slice(2);
18
+ const eqIdx = key.indexOf("=");
19
+ if (eqIdx !== -1) {
20
+ flags[key.slice(0, eqIdx)] = key.slice(eqIdx + 1);
21
+ i++;
22
+ } else {
23
+ const next = args[i + 1];
24
+ if (next !== undefined && !next.startsWith("--")) {
25
+ flags[key] = next;
26
+ i += 2;
27
+ } else {
28
+ flags[key] = true;
29
+ i++;
30
+ }
31
+ }
32
+ } else {
33
+ i++;
34
+ }
35
+ }
36
+ return flags;
37
+ }
38
+
39
+ function parsePriority(val: string): number {
40
+ if (val.toUpperCase().startsWith("P")) return Number.parseInt(val.slice(1), 10);
41
+ return Number.parseInt(val, 10);
42
+ }
43
+
44
+ export async function run(args: string[], seedsDir?: string): Promise<void> {
45
+ const jsonMode = args.includes("--json");
46
+ const id = args.find((a) => !a.startsWith("--"));
47
+ if (!id) throw new Error("Usage: sd update <id> [flags]");
48
+
49
+ const flags = parseArgs(args);
50
+
51
+ const dir = seedsDir ?? (await findSeedsDir());
52
+ let updated: Issue | undefined;
53
+
54
+ await withLock(issuesPath(dir), async () => {
55
+ const issues = await readIssues(dir);
56
+ const idx = issues.findIndex((i) => i.id === id);
57
+ if (idx === -1) throw new Error(`Issue not found: ${id}`);
58
+
59
+ const issue = issues[idx]!;
60
+ const now = new Date().toISOString();
61
+ const patch: Partial<Issue> = { updatedAt: now };
62
+
63
+ if (typeof flags.status === "string") {
64
+ const s = flags.status;
65
+ if (!(VALID_STATUSES as readonly string[]).includes(s)) {
66
+ throw new Error(`--status must be one of: ${VALID_STATUSES.join(", ")}`);
67
+ }
68
+ patch.status = s as Issue["status"];
69
+ }
70
+ if (typeof flags.title === "string") patch.title = flags.title;
71
+ if (typeof flags.assignee === "string") patch.assignee = flags.assignee;
72
+ const desc = typeof flags.description === "string" ? flags.description : flags.desc;
73
+ if (typeof desc === "string") patch.description = desc;
74
+ if (typeof flags.type === "string") {
75
+ const t = flags.type;
76
+ if (!(VALID_TYPES as readonly string[]).includes(t)) {
77
+ throw new Error(`--type must be one of: ${VALID_TYPES.join(", ")}`);
78
+ }
79
+ patch.type = t as Issue["type"];
80
+ }
81
+ if (typeof flags.priority === "string") {
82
+ const p = parsePriority(flags.priority);
83
+ if (Number.isNaN(p) || p < 0 || p > 4) throw new Error("--priority must be 0-4 or P0-P4");
84
+ patch.priority = p;
85
+ }
86
+
87
+ issues[idx] = { ...issue, ...patch };
88
+ updated = issues[idx];
89
+ await writeIssues(dir, issues);
90
+ });
91
+
92
+ if (jsonMode) {
93
+ outputJson({ success: true, command: "update", issue: updated });
94
+ } else {
95
+ printSuccess(`Updated ${id}`);
96
+ }
97
+ }