trekoon 0.3.8 → 0.4.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/.agents/skills/trekoon/SKILL.md +1 -1
- package/README.md +1 -0
- package/docs/ai-agents.md +1 -0
- package/docs/commands.md +18 -0
- package/docs/quickstart.md +14 -0
- package/package.json +2 -2
- package/src/commands/epic.ts +84 -1
- package/src/commands/help.ts +19 -1
- package/src/commands/quickstart.ts +3 -0
- package/src/export/build-epic-export-bundle.ts +178 -0
- package/src/export/path.ts +48 -0
- package/src/export/render-markdown.ts +256 -0
- package/src/export/types.ts +61 -0
- package/src/export/write.ts +97 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: trekoon
|
|
3
|
-
description: Use for Trekoon-based planning and execution: creating epics, tasks, and subtasks; breaking work into dependency-aware graphs; checking status and progress; planning backlog or sprint work; and coordinating agent execution from Trekoon. Prefer this whenever the user wants tracked implementation planning or Trekoon entity management, even if they do not explicitly say "Trekoon."
|
|
3
|
+
description: "Use for Trekoon-based planning and execution: creating epics, tasks, and subtasks; breaking work into dependency-aware graphs; checking status and progress; planning backlog or sprint work; and coordinating agent execution from Trekoon. Prefer this whenever the user wants tracked implementation planning or Trekoon entity management, even if they do not explicitly say \"Trekoon\"."
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Trekoon Skill
|
package/README.md
CHANGED
|
@@ -211,6 +211,7 @@ overview, kanban workspace per epic, task detail modals, and search.
|
|
|
211
211
|
| Start an agent session | `trekoon session --epic <id>` |
|
|
212
212
|
| Get next-action suggestions | `trekoon suggest --epic <id>` |
|
|
213
213
|
| Check epic progress | `trekoon epic progress <id>` |
|
|
214
|
+
| Export epic to Markdown | `trekoon epic export <id>` |
|
|
214
215
|
| Mark a task done | `trekoon task done <id>` |
|
|
215
216
|
| Sync across worktrees | `trekoon sync pull --from main` |
|
|
216
217
|
| Get help | `trekoon [command] -h` |
|
package/docs/ai-agents.md
CHANGED
|
@@ -200,6 +200,7 @@ Use the narrowest command that answers the question:
|
|
|
200
200
|
| A few ready options | `trekoon --toon task ready --limit 5` |
|
|
201
201
|
| One task with subtasks | `trekoon --toon task show <task-id> --all` |
|
|
202
202
|
| One epic tree | `trekoon --toon epic show <epic-id> --all` |
|
|
203
|
+
| Export epic to Markdown | `trekoon --toon epic export <epic-id>` |
|
|
203
204
|
| Repeated text in one scope | `trekoon --toon epic|task|subtask search ...` |
|
|
204
205
|
|
|
205
206
|
For repeated text changes, use the safe replace loop:
|
package/docs/commands.md
CHANGED
|
@@ -202,6 +202,24 @@ trekoon epic progress <epic-id>
|
|
|
202
202
|
Returns status counts (`total`, `doneCount`, `inProgressCount`, `blockedCount`,
|
|
203
203
|
`todoCount`), `readyCount`, and `nextCandidate`.
|
|
204
204
|
|
|
205
|
+
## Epic export
|
|
206
|
+
|
|
207
|
+
```bash
|
|
208
|
+
trekoon epic export <epic-id> [--path <path>] [--overwrite]
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Writes a Markdown snapshot of the epic including tasks, subtasks, dependencies,
|
|
212
|
+
external node stubs, and warnings. The output is a point-in-time artifact; the
|
|
213
|
+
database remains the source of truth.
|
|
214
|
+
|
|
215
|
+
- Default path: `<worktree-root>/plans/<slugified-title>.md`
|
|
216
|
+
- `--path` with a file extension (e.g. `docs/plan.md`) creates that exact file
|
|
217
|
+
- `--path` without an extension (e.g. `docs/plans`) creates the default-named file inside that directory
|
|
218
|
+
- `--overwrite` resaves when the file already exists
|
|
219
|
+
|
|
220
|
+
Returns `epicId`, `path`, `overwritten`, and `summary` counts in structured
|
|
221
|
+
output.
|
|
222
|
+
|
|
205
223
|
## Session scoping
|
|
206
224
|
|
|
207
225
|
```bash
|
package/docs/quickstart.md
CHANGED
|
@@ -134,6 +134,20 @@ Cascades atomically through all descendants. If any descendant has an unresolved
|
|
|
134
134
|
external dependency, the whole update fails with no partial writes. Works with
|
|
135
135
|
`--status done` and `--status todo` only.
|
|
136
136
|
|
|
137
|
+
## Export an epic to Markdown
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
trekoon epic export <epic-id>
|
|
141
|
+
trekoon epic export <epic-id> --path docs/plan.md # exact file
|
|
142
|
+
trekoon epic export <epic-id> --path docs/plans # default name inside dir
|
|
143
|
+
trekoon epic export <epic-id> --overwrite
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Writes a readable Markdown snapshot under `plans/` by default. With `--path`,
|
|
147
|
+
a file extension means "write this file"; no extension means "put the default-
|
|
148
|
+
named file in this directory". Use `--overwrite` to resave after the plan state
|
|
149
|
+
changes.
|
|
150
|
+
|
|
137
151
|
## Check progress
|
|
138
152
|
|
|
139
153
|
```bash
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trekoon",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "AI-first
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "AI-first task tracking that lives in your repo. You describe what to build, your agent plans it as a dependency graph, then executes it task by task",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
7
7
|
"agent",
|
package/src/commands/epic.ts
CHANGED
|
@@ -36,7 +36,12 @@ import {
|
|
|
36
36
|
import { formatHumanTable } from "../io/human-table";
|
|
37
37
|
import { failResult, okResult } from "../io/output";
|
|
38
38
|
import { type CliContext, type CliResult } from "../runtime/command-types";
|
|
39
|
+
import { buildEpicExportBundle } from "../export/build-epic-export-bundle";
|
|
40
|
+
import { resolveExportPath } from "../export/path";
|
|
41
|
+
import { renderMarkdown } from "../export/render-markdown";
|
|
42
|
+
import { atomicWrite, ExportWriteError } from "../export/write";
|
|
39
43
|
import { openTrekoonDatabase, type TrekoonDatabase } from "../storage/database";
|
|
44
|
+
import { resolveStoragePaths } from "../storage/path";
|
|
40
45
|
|
|
41
46
|
function formatEpic(epic: EpicRecord): string {
|
|
42
47
|
return `${epic.id} | ${epic.title} | ${epic.status}`;
|
|
@@ -53,6 +58,7 @@ const SEARCH_OPTIONS = ["fields", "preview"] as const;
|
|
|
53
58
|
const REPLACE_OPTIONS = ["search", "replace", "fields", "preview", "apply"] as const;
|
|
54
59
|
const EXPAND_OPTIONS = ["task", "subtask", "dep"] as const;
|
|
55
60
|
const UPDATE_OPTIONS = ["all", "ids", "append", "description", "d", "status", "s", "title", "t"] as const;
|
|
61
|
+
const EXPORT_OPTIONS = ["path", "overwrite"] as const;
|
|
56
62
|
const STATUS_CASCADE_UPDATE_STATUSES = ["done", "todo"] as const;
|
|
57
63
|
|
|
58
64
|
function parseStatusCsv(rawStatuses: string | undefined): string[] | undefined {
|
|
@@ -1472,10 +1478,87 @@ export async function runEpic(context: CliContext): Promise<CliResult> {
|
|
|
1472
1478
|
data: { id: epicId },
|
|
1473
1479
|
});
|
|
1474
1480
|
}
|
|
1481
|
+
case "export": {
|
|
1482
|
+
const epicId: string = parsed.positional[1] ?? "";
|
|
1483
|
+
const exportUnknown = findUnknownOption(parsed, EXPORT_OPTIONS);
|
|
1484
|
+
if (exportUnknown !== undefined) {
|
|
1485
|
+
return unknownOption("epic.export", exportUnknown, EXPORT_OPTIONS);
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
const missingExportOption = readMissingOptionValue(parsed.missingOptionValues, "path");
|
|
1489
|
+
if (missingExportOption !== undefined) {
|
|
1490
|
+
return failMissingOptionValue("epic.export", missingExportOption);
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
if (!epicId) {
|
|
1494
|
+
return failResult({
|
|
1495
|
+
command: "epic.export",
|
|
1496
|
+
human: "Usage: trekoon epic export <epic-id> [--path <path>] [--overwrite]",
|
|
1497
|
+
data: {},
|
|
1498
|
+
error: { code: "invalid_input", message: "Missing epic ID" },
|
|
1499
|
+
});
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
const bundle = buildEpicExportBundle(domain, epicId);
|
|
1503
|
+
const markdown = renderMarkdown(bundle);
|
|
1504
|
+
const storagePaths = resolveStoragePaths(context.cwd);
|
|
1505
|
+
const customPath = readOption(parsed.options, "path");
|
|
1506
|
+
const overwrite = hasFlag(parsed.flags, "overwrite");
|
|
1507
|
+
|
|
1508
|
+
const exportPath = resolveExportPath({
|
|
1509
|
+
customPath,
|
|
1510
|
+
epicId: bundle.epic.id,
|
|
1511
|
+
epicTitle: bundle.epic.title,
|
|
1512
|
+
worktreeRoot: storagePaths.worktreeRoot,
|
|
1513
|
+
cwd: context.cwd,
|
|
1514
|
+
});
|
|
1515
|
+
|
|
1516
|
+
try {
|
|
1517
|
+
const result = atomicWrite({ path: exportPath, content: markdown, overwrite });
|
|
1518
|
+
const action = result.overwritten ? "Updated" : "Exported";
|
|
1519
|
+
return okResult({
|
|
1520
|
+
command: "epic.export",
|
|
1521
|
+
human: `${action} epic to ${result.path}`,
|
|
1522
|
+
data: {
|
|
1523
|
+
epicId: bundle.epic.id,
|
|
1524
|
+
path: result.path,
|
|
1525
|
+
overwritten: result.overwritten,
|
|
1526
|
+
summary: bundle.summary,
|
|
1527
|
+
},
|
|
1528
|
+
});
|
|
1529
|
+
} catch (error: unknown) {
|
|
1530
|
+
if (error instanceof ExportWriteError) {
|
|
1531
|
+
return failResult({
|
|
1532
|
+
command: "epic.export",
|
|
1533
|
+
human: error.message,
|
|
1534
|
+
data: { path: exportPath, epicId: bundle.epic.id },
|
|
1535
|
+
error: { code: error.code, message: error.message },
|
|
1536
|
+
});
|
|
1537
|
+
}
|
|
1538
|
+
const fsCode = typeof error === "object" && error !== null && "code" in error ? (error as { code: string }).code : null;
|
|
1539
|
+
if (fsCode === "EACCES" || fsCode === "EPERM" || fsCode === "EROFS") {
|
|
1540
|
+
return failResult({
|
|
1541
|
+
command: "epic.export",
|
|
1542
|
+
human: `Permission denied: cannot write to ${exportPath}`,
|
|
1543
|
+
data: { path: exportPath, epicId: bundle.epic.id, fsError: fsCode },
|
|
1544
|
+
error: { code: "permission_denied", message: `Permission denied: ${exportPath}` },
|
|
1545
|
+
});
|
|
1546
|
+
}
|
|
1547
|
+
if (fsCode === "EISDIR") {
|
|
1548
|
+
return failResult({
|
|
1549
|
+
command: "epic.export",
|
|
1550
|
+
human: `Path is a directory, not a file: ${exportPath}`,
|
|
1551
|
+
data: { path: exportPath, epicId: bundle.epic.id, fsError: fsCode },
|
|
1552
|
+
error: { code: "invalid_path", message: `Path is a directory: ${exportPath}` },
|
|
1553
|
+
});
|
|
1554
|
+
}
|
|
1555
|
+
throw error;
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1475
1558
|
default:
|
|
1476
1559
|
return failResult({
|
|
1477
1560
|
command: "epic",
|
|
1478
|
-
human: "Usage: trekoon epic <create|expand|list|show|search|replace|update|delete|progress>",
|
|
1561
|
+
human: "Usage: trekoon epic <create|expand|list|show|search|replace|update|delete|progress|export>",
|
|
1479
1562
|
data: {
|
|
1480
1563
|
args: context.args,
|
|
1481
1564
|
},
|
package/src/commands/help.ts
CHANGED
|
@@ -103,7 +103,7 @@ const BOARD_HELP = [
|
|
|
103
103
|
].join("\n");
|
|
104
104
|
|
|
105
105
|
const EPIC_HELP = [
|
|
106
|
-
"Usage: trekoon epic <create|expand|list|show|search|replace|update|delete|progress> [options]",
|
|
106
|
+
"Usage: trekoon epic <create|expand|list|show|search|replace|update|delete|progress|export> [options]",
|
|
107
107
|
"",
|
|
108
108
|
"Create:",
|
|
109
109
|
" trekoon epic create --title \"...\" --description \"...\" [--status <status>]",
|
|
@@ -149,6 +149,24 @@ const EPIC_HELP = [
|
|
|
149
149
|
"Progress:",
|
|
150
150
|
" trekoon epic progress <epic-id>",
|
|
151
151
|
" Returns done/in_progress/blocked/todo counts, readyCount, and nextCandidate.",
|
|
152
|
+
"",
|
|
153
|
+
"Export:",
|
|
154
|
+
" trekoon epic export <epic-id> [--path <path>] [--overwrite]",
|
|
155
|
+
" Writes a Markdown snapshot of the epic including tasks, subtasks, dependencies,",
|
|
156
|
+
" external node stubs, and warnings. The Markdown is a point-in-time artifact;",
|
|
157
|
+
" the database remains the source of truth.",
|
|
158
|
+
"",
|
|
159
|
+
" Default path: <worktree-root>/plans/<slugified-title>.md",
|
|
160
|
+
" --path <path> Custom output path (relative or absolute).",
|
|
161
|
+
" With extension (e.g. docs/plan.md): creates that file.",
|
|
162
|
+
" Without extension (e.g. docs/plans): creates the default-named file inside.",
|
|
163
|
+
" --overwrite Resave if the file already exists.",
|
|
164
|
+
"",
|
|
165
|
+
" Examples:",
|
|
166
|
+
" trekoon epic export abc-123",
|
|
167
|
+
" trekoon epic export abc-123 --path docs/plan.md # writes docs/plan.md",
|
|
168
|
+
" trekoon epic export abc-123 --path docs/plans # writes docs/plans/<title>.md",
|
|
169
|
+
" trekoon epic export abc-123 --overwrite",
|
|
152
170
|
].join("\n");
|
|
153
171
|
|
|
154
172
|
const TASK_HELP = [
|
|
@@ -59,6 +59,7 @@ const QUICKSTART_TEXT = [
|
|
|
59
59
|
" Bulk update: trekoon --toon task update --ids id1,id2 --append \"...\" --status in_progress",
|
|
60
60
|
" Ready queue: trekoon --toon task ready [--limit <n>] [--epic <id>]",
|
|
61
61
|
" Next candidate: trekoon --toon task next [--epic <id>]",
|
|
62
|
+
" Export epic to MD: trekoon --toon epic export <epic-id> [--path <path>] [--overwrite]",
|
|
62
63
|
"",
|
|
63
64
|
"6) List and view defaults",
|
|
64
65
|
" Default scope: open work (in_progress, todo), limit 10.",
|
|
@@ -152,6 +153,7 @@ export async function runQuickstart(_: CliContext): Promise<CliResult> {
|
|
|
152
153
|
"trekoon --toon task list --status in_progress,todo --limit 20",
|
|
153
154
|
"trekoon --toon task list --cursor <n>",
|
|
154
155
|
"trekoon --toon task update --ids id1,id2 --append \"...\" --status in_progress",
|
|
156
|
+
"trekoon --toon epic export <epic-id>",
|
|
155
157
|
],
|
|
156
158
|
machineExamples: [
|
|
157
159
|
"trekoon --toon quickstart",
|
|
@@ -166,6 +168,7 @@ export async function runQuickstart(_: CliContext): Promise<CliResult> {
|
|
|
166
168
|
"trekoon --toon task ready --limit 5",
|
|
167
169
|
"trekoon --toon task next",
|
|
168
170
|
"trekoon --toon dep reverse <task-or-subtask-id>",
|
|
171
|
+
"trekoon --toon epic export <epic-id>",
|
|
169
172
|
],
|
|
170
173
|
wipeWarning: {
|
|
171
174
|
command: "trekoon wipe --yes",
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import type { TrackerDomain } from "../domain/tracker-domain";
|
|
2
|
+
import type { DependencyRecord, SubtaskRecord, TaskRecord } from "../domain/types";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
EXPORT_SCHEMA_VERSION,
|
|
6
|
+
type ExportBundle,
|
|
7
|
+
type ExportDependencyEdge,
|
|
8
|
+
type ExportExternalNode,
|
|
9
|
+
type ExportStatusCounts,
|
|
10
|
+
type ExportSummary,
|
|
11
|
+
type ExportWarning,
|
|
12
|
+
} from "./types";
|
|
13
|
+
|
|
14
|
+
function countStatuses(records: readonly { readonly status: string }[]): ExportStatusCounts {
|
|
15
|
+
const counts = { total: records.length, todo: 0, inProgress: 0, done: 0, blocked: 0, other: 0 };
|
|
16
|
+
for (const record of records) {
|
|
17
|
+
if (record.status === "todo") counts.todo += 1;
|
|
18
|
+
else if (record.status === "in_progress") counts.inProgress += 1;
|
|
19
|
+
else if (record.status === "done") counts.done += 1;
|
|
20
|
+
else if (record.status === "blocked") counts.blocked += 1;
|
|
21
|
+
else counts.other += 1;
|
|
22
|
+
}
|
|
23
|
+
return counts;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function buildEpicExportBundle(domain: TrackerDomain, epicId: string): ExportBundle {
|
|
27
|
+
const epic = domain.getEpicOrThrow(epicId);
|
|
28
|
+
const tasks: readonly TaskRecord[] = domain.listTasks(epicId);
|
|
29
|
+
const taskIds = new Set(tasks.map((t) => t.id));
|
|
30
|
+
|
|
31
|
+
const subtasksByTaskId = domain.listSubtasksByTaskIds(tasks.map((t) => t.id));
|
|
32
|
+
const allSubtasks: SubtaskRecord[] = [];
|
|
33
|
+
for (const task of tasks) {
|
|
34
|
+
for (const subtask of subtasksByTaskId.get(task.id) ?? []) {
|
|
35
|
+
allSubtasks.push(subtask);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const subtaskIds = new Set(allSubtasks.map((s) => s.id));
|
|
39
|
+
|
|
40
|
+
const inScopeIds = new Set([...taskIds, ...subtaskIds]);
|
|
41
|
+
|
|
42
|
+
// Gather all dependencies touching any in-scope node
|
|
43
|
+
const sourceIds = [...inScopeIds];
|
|
44
|
+
const dependenciesBySourceId = domain.listDependenciesBySourceIds(sourceIds);
|
|
45
|
+
const allRawDeps: DependencyRecord[] = [];
|
|
46
|
+
const seenDepIds = new Set<string>();
|
|
47
|
+
for (const deps of dependenciesBySourceId.values()) {
|
|
48
|
+
for (const dep of deps) {
|
|
49
|
+
if (!seenDepIds.has(dep.id)) {
|
|
50
|
+
seenDepIds.add(dep.id);
|
|
51
|
+
allRawDeps.push(dep);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Also find dependencies where in-scope nodes are the target (dependsOnId)
|
|
57
|
+
// by checking each in-scope node with listDependenciesTouchingNode
|
|
58
|
+
// We use a more efficient approach: query dependencies where dependsOnId is in scope
|
|
59
|
+
for (const nodeId of inScopeIds) {
|
|
60
|
+
const touching = domain.listDependenciesTouchingNode(nodeId);
|
|
61
|
+
for (const dep of touching) {
|
|
62
|
+
if (!seenDepIds.has(dep.id)) {
|
|
63
|
+
seenDepIds.add(dep.id);
|
|
64
|
+
allRawDeps.push(dep);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Sort for stable ordering
|
|
70
|
+
allRawDeps.sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id));
|
|
71
|
+
|
|
72
|
+
// Classify edges and build dependency indexes
|
|
73
|
+
const blockedByMap = new Map<string, string[]>();
|
|
74
|
+
const blocksMap = new Map<string, string[]>();
|
|
75
|
+
const externalNodeMap = new Map<string, ExportExternalNode>();
|
|
76
|
+
const warnings: ExportWarning[] = [];
|
|
77
|
+
|
|
78
|
+
const edges: ExportDependencyEdge[] = allRawDeps.map((dep) => {
|
|
79
|
+
const sourceInternal = inScopeIds.has(dep.sourceId);
|
|
80
|
+
const targetInternal = inScopeIds.has(dep.dependsOnId);
|
|
81
|
+
const internal = sourceInternal && targetInternal;
|
|
82
|
+
|
|
83
|
+
// Build blockedBy: source is blocked by dependsOn
|
|
84
|
+
if (sourceInternal) {
|
|
85
|
+
const existing = blockedByMap.get(dep.sourceId) ?? [];
|
|
86
|
+
existing.push(dep.dependsOnId);
|
|
87
|
+
blockedByMap.set(dep.sourceId, existing);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Build blocks: dependsOn blocks source
|
|
91
|
+
if (targetInternal) {
|
|
92
|
+
const existing = blocksMap.get(dep.dependsOnId) ?? [];
|
|
93
|
+
existing.push(dep.sourceId);
|
|
94
|
+
blocksMap.set(dep.dependsOnId, existing);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Resolve external nodes
|
|
98
|
+
if (!sourceInternal && !externalNodeMap.has(dep.sourceId)) {
|
|
99
|
+
externalNodeMap.set(dep.sourceId, resolveExternalNode(domain, dep.sourceId, dep.sourceKind));
|
|
100
|
+
}
|
|
101
|
+
if (!targetInternal && !externalNodeMap.has(dep.dependsOnId)) {
|
|
102
|
+
externalNodeMap.set(dep.dependsOnId, resolveExternalNode(domain, dep.dependsOnId, dep.dependsOnKind));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
id: dep.id,
|
|
107
|
+
sourceId: dep.sourceId,
|
|
108
|
+
sourceKind: dep.sourceKind,
|
|
109
|
+
dependsOnId: dep.dependsOnId,
|
|
110
|
+
dependsOnKind: dep.dependsOnKind,
|
|
111
|
+
internal,
|
|
112
|
+
};
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const externalNodes = [...externalNodeMap.values()].sort((a, b) => a.id.localeCompare(b.id));
|
|
116
|
+
|
|
117
|
+
// Check for orphaned dependency references
|
|
118
|
+
for (const node of externalNodes) {
|
|
119
|
+
if (node.title === null) {
|
|
120
|
+
warnings.push({
|
|
121
|
+
code: "orphaned_external_node",
|
|
122
|
+
message: `External ${node.kind} ${node.id} referenced by a dependency but not found in the database`,
|
|
123
|
+
entityId: node.id,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const summary: ExportSummary = {
|
|
129
|
+
taskCount: tasks.length,
|
|
130
|
+
subtaskCount: allSubtasks.length,
|
|
131
|
+
dependencyCount: edges.length,
|
|
132
|
+
externalNodeCount: externalNodes.length,
|
|
133
|
+
warningCount: warnings.length,
|
|
134
|
+
taskStatuses: countStatuses(tasks),
|
|
135
|
+
subtaskStatuses: countStatuses(allSubtasks),
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
schemaVersion: EXPORT_SCHEMA_VERSION,
|
|
140
|
+
exportedAt: Date.now(),
|
|
141
|
+
epic,
|
|
142
|
+
tasks,
|
|
143
|
+
subtasks: allSubtasks,
|
|
144
|
+
dependencies: edges,
|
|
145
|
+
externalNodes,
|
|
146
|
+
blockedBy: blockedByMap,
|
|
147
|
+
blocks: blocksMap,
|
|
148
|
+
warnings,
|
|
149
|
+
summary,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function resolveExternalNode(
|
|
154
|
+
domain: TrackerDomain,
|
|
155
|
+
id: string,
|
|
156
|
+
kind: "task" | "subtask",
|
|
157
|
+
): ExportExternalNode {
|
|
158
|
+
if (kind === "task") {
|
|
159
|
+
const task = domain.getTask(id);
|
|
160
|
+
if (task) {
|
|
161
|
+
return { id, kind: "task", title: task.title, status: task.status, epicId: task.epicId };
|
|
162
|
+
}
|
|
163
|
+
} else {
|
|
164
|
+
const subtask = domain.getSubtask(id);
|
|
165
|
+
if (subtask) {
|
|
166
|
+
const task = domain.getTask(subtask.taskId);
|
|
167
|
+
return {
|
|
168
|
+
id,
|
|
169
|
+
kind: "subtask",
|
|
170
|
+
title: subtask.title,
|
|
171
|
+
status: subtask.status,
|
|
172
|
+
epicId: task?.epicId ?? null,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return { id, kind, title: null, status: null, epicId: null };
|
|
178
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { extname, isAbsolute, resolve } from "node:path";
|
|
2
|
+
|
|
3
|
+
const PLANS_DIRNAME = "plans";
|
|
4
|
+
|
|
5
|
+
function slugify(text: string): string {
|
|
6
|
+
return text
|
|
7
|
+
.toLowerCase()
|
|
8
|
+
.replace(/[^a-z0-9\s-]/g, "")
|
|
9
|
+
.replace(/\s+/g, "-")
|
|
10
|
+
.replace(/-+/g, "-")
|
|
11
|
+
.replace(/^-|-$/g, "")
|
|
12
|
+
.slice(0, 80);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function defaultFilename(epicTitle: string, epicId: string): string {
|
|
16
|
+
const slug = slugify(epicTitle) || epicId;
|
|
17
|
+
return `${slug}.md`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function looksLikeFilePath(path: string): boolean {
|
|
21
|
+
return extname(path) !== "";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function resolveExportPath(options: {
|
|
25
|
+
readonly customPath: string | undefined;
|
|
26
|
+
readonly epicId: string;
|
|
27
|
+
readonly epicTitle: string;
|
|
28
|
+
readonly worktreeRoot: string;
|
|
29
|
+
readonly cwd: string;
|
|
30
|
+
}): string {
|
|
31
|
+
const filename = defaultFilename(options.epicTitle, options.epicId);
|
|
32
|
+
|
|
33
|
+
if (!options.customPath) {
|
|
34
|
+
return resolve(options.worktreeRoot, PLANS_DIRNAME, filename);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const resolved = isAbsolute(options.customPath)
|
|
38
|
+
? options.customPath
|
|
39
|
+
: resolve(options.cwd, options.customPath);
|
|
40
|
+
|
|
41
|
+
// If the path has a file extension, treat it as a file path.
|
|
42
|
+
// Otherwise treat it as a directory and place the default-named file inside.
|
|
43
|
+
if (looksLikeFilePath(resolved)) {
|
|
44
|
+
return resolved;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return resolve(resolved, filename);
|
|
48
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import type { ExportBundle, ExportExternalNode, ExportStatusCounts, ExportWarning } from "./types";
|
|
2
|
+
import type { SubtaskRecord, TaskRecord } from "../domain/types";
|
|
3
|
+
|
|
4
|
+
export function renderMarkdown(bundle: ExportBundle): string {
|
|
5
|
+
const lines: string[] = [];
|
|
6
|
+
|
|
7
|
+
renderFrontmatter(lines, bundle);
|
|
8
|
+
renderTitle(lines, bundle);
|
|
9
|
+
renderSummaryTable(lines, bundle);
|
|
10
|
+
renderDescription(lines, bundle);
|
|
11
|
+
renderTaskIndex(lines, bundle);
|
|
12
|
+
renderTaskDetails(lines, bundle);
|
|
13
|
+
renderDependencies(lines, bundle);
|
|
14
|
+
renderExternalNodes(lines, bundle);
|
|
15
|
+
renderWarnings(lines, bundle);
|
|
16
|
+
renderFooter(lines, bundle);
|
|
17
|
+
|
|
18
|
+
return lines.join("\n") + "\n";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// --- Markdown escaping helpers ---
|
|
22
|
+
|
|
23
|
+
function escapeTableCell(text: string): string {
|
|
24
|
+
return text.replace(/\|/g, "\\|").replace(/\n/g, " ");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function escapeHeading(text: string): string {
|
|
28
|
+
return text.replace(/\n/g, " ").replace(/#+\s*/g, "");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function escapeInlineText(text: string): string {
|
|
32
|
+
return text.replace(/([[\]`*_~])/g, "\\$1").replace(/\n/g, " ");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function escapeBlockText(text: string): string {
|
|
36
|
+
// Descriptions are rendered as block content; preserve newlines but
|
|
37
|
+
// ensure lines that start with Markdown structural characters are safe.
|
|
38
|
+
return text
|
|
39
|
+
.split("\n")
|
|
40
|
+
.map((line) => {
|
|
41
|
+
if (/^#{1,6}\s/.test(line)) return `\\${line}`;
|
|
42
|
+
if (/^(\s*[-*+]|\s*\d+\.)\s/.test(line)) return line;
|
|
43
|
+
if (/^\s*\|/.test(line)) return `\\${line}`;
|
|
44
|
+
return line;
|
|
45
|
+
})
|
|
46
|
+
.join("\n");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function formatDescriptionIndented(text: string, indent: string): string {
|
|
50
|
+
return text
|
|
51
|
+
.split("\n")
|
|
52
|
+
.map((line) => `${indent}${line}`)
|
|
53
|
+
.join("\n");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// --- Render sections ---
|
|
57
|
+
|
|
58
|
+
function renderFrontmatter(lines: string[], bundle: ExportBundle): void {
|
|
59
|
+
lines.push("---");
|
|
60
|
+
lines.push(`epic_id: ${bundle.epic.id}`);
|
|
61
|
+
lines.push(`schema_version: ${bundle.schemaVersion}`);
|
|
62
|
+
lines.push(`exported_at: ${new Date(bundle.exportedAt).toISOString()}`);
|
|
63
|
+
lines.push(`status: ${bundle.epic.status}`);
|
|
64
|
+
lines.push("---");
|
|
65
|
+
lines.push("");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function renderTitle(lines: string[], bundle: ExportBundle): void {
|
|
69
|
+
lines.push(`# ${escapeHeading(bundle.epic.title)}`);
|
|
70
|
+
lines.push("");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function renderSummaryTable(lines: string[], bundle: ExportBundle): void {
|
|
74
|
+
lines.push("## Summary");
|
|
75
|
+
lines.push("");
|
|
76
|
+
lines.push("| Metric | Count |");
|
|
77
|
+
lines.push("|--------|-------|");
|
|
78
|
+
lines.push(`| Tasks | ${bundle.summary.taskCount} |`);
|
|
79
|
+
lines.push(`| Subtasks | ${bundle.summary.subtaskCount} |`);
|
|
80
|
+
lines.push(`| Dependencies | ${bundle.summary.dependencyCount} |`);
|
|
81
|
+
lines.push(`| External nodes | ${bundle.summary.externalNodeCount} |`);
|
|
82
|
+
lines.push(`| Warnings | ${bundle.summary.warningCount} |`);
|
|
83
|
+
lines.push("");
|
|
84
|
+
|
|
85
|
+
if (bundle.summary.taskCount > 0) {
|
|
86
|
+
lines.push("### Task status breakdown");
|
|
87
|
+
lines.push("");
|
|
88
|
+
renderStatusTable(lines, bundle.summary.taskStatuses);
|
|
89
|
+
lines.push("");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (bundle.summary.subtaskCount > 0) {
|
|
93
|
+
lines.push("### Subtask status breakdown");
|
|
94
|
+
lines.push("");
|
|
95
|
+
renderStatusTable(lines, bundle.summary.subtaskStatuses);
|
|
96
|
+
lines.push("");
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function renderStatusTable(lines: string[], counts: ExportStatusCounts): void {
|
|
101
|
+
lines.push("| Status | Count |");
|
|
102
|
+
lines.push("|--------|-------|");
|
|
103
|
+
if (counts.todo > 0) lines.push(`| todo | ${counts.todo} |`);
|
|
104
|
+
if (counts.inProgress > 0) lines.push(`| in_progress | ${counts.inProgress} |`);
|
|
105
|
+
if (counts.done > 0) lines.push(`| done | ${counts.done} |`);
|
|
106
|
+
if (counts.blocked > 0) lines.push(`| blocked | ${counts.blocked} |`);
|
|
107
|
+
if (counts.other > 0) lines.push(`| other | ${counts.other} |`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function renderDescription(lines: string[], bundle: ExportBundle): void {
|
|
111
|
+
if (!bundle.epic.description) return;
|
|
112
|
+
|
|
113
|
+
lines.push("## Description");
|
|
114
|
+
lines.push("");
|
|
115
|
+
lines.push(escapeBlockText(bundle.epic.description));
|
|
116
|
+
lines.push("");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function renderTaskIndex(lines: string[], bundle: ExportBundle): void {
|
|
120
|
+
if (bundle.tasks.length === 0) return;
|
|
121
|
+
|
|
122
|
+
lines.push("## Task index");
|
|
123
|
+
lines.push("");
|
|
124
|
+
lines.push("| # | Title | Status | Subtasks |");
|
|
125
|
+
lines.push("|---|-------|--------|----------|");
|
|
126
|
+
|
|
127
|
+
for (let i = 0; i < bundle.tasks.length; i++) {
|
|
128
|
+
const task = bundle.tasks[i];
|
|
129
|
+
const subtaskCount = bundle.subtasks.filter((s) => s.taskId === task.id).length;
|
|
130
|
+
const anchor = taskAnchor(task);
|
|
131
|
+
lines.push(`| ${i + 1} | [${escapeTableCell(escapeInlineText(task.title))}](#${anchor}) | ${task.status} | ${subtaskCount} |`);
|
|
132
|
+
}
|
|
133
|
+
lines.push("");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function renderTaskDetails(lines: string[], bundle: ExportBundle): void {
|
|
137
|
+
if (bundle.tasks.length === 0) return;
|
|
138
|
+
|
|
139
|
+
lines.push("## Tasks");
|
|
140
|
+
lines.push("");
|
|
141
|
+
|
|
142
|
+
for (const task of bundle.tasks) {
|
|
143
|
+
renderSingleTask(lines, task, bundle);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function renderSingleTask(lines: string[], task: TaskRecord, bundle: ExportBundle): void {
|
|
148
|
+
lines.push(`### ${escapeHeading(task.title)}`);
|
|
149
|
+
lines.push("");
|
|
150
|
+
lines.push(`**ID:** \`${task.id}\` `);
|
|
151
|
+
lines.push(`**Status:** ${task.status} `);
|
|
152
|
+
if (task.owner) {
|
|
153
|
+
lines.push(`**Owner:** ${escapeInlineText(task.owner)} `);
|
|
154
|
+
}
|
|
155
|
+
lines.push("");
|
|
156
|
+
|
|
157
|
+
if (task.description) {
|
|
158
|
+
lines.push(escapeBlockText(task.description));
|
|
159
|
+
lines.push("");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const blockedBy = bundle.blockedBy.get(task.id) ?? [];
|
|
163
|
+
if (blockedBy.length > 0) {
|
|
164
|
+
lines.push("**Blocked by:**");
|
|
165
|
+
for (const depId of blockedBy) {
|
|
166
|
+
lines.push(`- \`${depId}\``);
|
|
167
|
+
}
|
|
168
|
+
lines.push("");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const blocks = bundle.blocks.get(task.id) ?? [];
|
|
172
|
+
if (blocks.length > 0) {
|
|
173
|
+
lines.push("**Blocks:**");
|
|
174
|
+
for (const depId of blocks) {
|
|
175
|
+
lines.push(`- \`${depId}\``);
|
|
176
|
+
}
|
|
177
|
+
lines.push("");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const subtasks = bundle.subtasks.filter((s) => s.taskId === task.id);
|
|
181
|
+
if (subtasks.length > 0) {
|
|
182
|
+
lines.push("#### Subtasks");
|
|
183
|
+
lines.push("");
|
|
184
|
+
for (const subtask of subtasks) {
|
|
185
|
+
renderSingleSubtask(lines, subtask, bundle);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function renderSingleSubtask(lines: string[], subtask: SubtaskRecord, bundle: ExportBundle): void {
|
|
191
|
+
const statusIcon = subtask.status === "done" ? "x" : " ";
|
|
192
|
+
lines.push(`- [${statusIcon}] **${escapeInlineText(subtask.title)}** — \`${subtask.id}\` (${subtask.status})`);
|
|
193
|
+
|
|
194
|
+
if (subtask.description) {
|
|
195
|
+
lines.push(formatDescriptionIndented(escapeBlockText(subtask.description), " "));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const blockedBy = bundle.blockedBy.get(subtask.id) ?? [];
|
|
199
|
+
if (blockedBy.length > 0) {
|
|
200
|
+
lines.push(` Blocked by: ${blockedBy.map((id) => `\`${id}\``).join(", ")}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function renderDependencies(lines: string[], bundle: ExportBundle): void {
|
|
205
|
+
if (bundle.dependencies.length === 0) return;
|
|
206
|
+
|
|
207
|
+
lines.push("");
|
|
208
|
+
lines.push("## Dependencies");
|
|
209
|
+
lines.push("");
|
|
210
|
+
lines.push("| Source | Depends on | Type |");
|
|
211
|
+
lines.push("|--------|------------|------|");
|
|
212
|
+
|
|
213
|
+
for (const dep of bundle.dependencies) {
|
|
214
|
+
const type = dep.internal ? "internal" : "external";
|
|
215
|
+
lines.push(`| \`${dep.sourceId}\` (${dep.sourceKind}) | \`${dep.dependsOnId}\` (${dep.dependsOnKind}) | ${type} |`);
|
|
216
|
+
}
|
|
217
|
+
lines.push("");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function renderExternalNodes(lines: string[], bundle: ExportBundle): void {
|
|
221
|
+
if (bundle.externalNodes.length === 0) return;
|
|
222
|
+
|
|
223
|
+
lines.push("## External nodes");
|
|
224
|
+
lines.push("");
|
|
225
|
+
lines.push("These nodes belong to other epics but are referenced by dependencies in this epic.");
|
|
226
|
+
lines.push("");
|
|
227
|
+
lines.push("| ID | Kind | Title | Status | Epic ID |");
|
|
228
|
+
lines.push("|----|------|-------|--------|---------|");
|
|
229
|
+
|
|
230
|
+
for (const node of bundle.externalNodes) {
|
|
231
|
+
const title = node.title !== null ? escapeTableCell(node.title) : "—";
|
|
232
|
+
lines.push(`| \`${node.id}\` | ${node.kind} | ${title} | ${node.status ?? "—"} | ${node.epicId ?? "—"} |`);
|
|
233
|
+
}
|
|
234
|
+
lines.push("");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function renderWarnings(lines: string[], bundle: ExportBundle): void {
|
|
238
|
+
if (bundle.warnings.length === 0) return;
|
|
239
|
+
|
|
240
|
+
lines.push("## Warnings");
|
|
241
|
+
lines.push("");
|
|
242
|
+
for (const warning of bundle.warnings) {
|
|
243
|
+
lines.push(`- **${escapeInlineText(warning.code)}**: ${escapeInlineText(warning.message)}`);
|
|
244
|
+
}
|
|
245
|
+
lines.push("");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function renderFooter(lines: string[], bundle: ExportBundle): void {
|
|
249
|
+
lines.push("---");
|
|
250
|
+
lines.push("");
|
|
251
|
+
lines.push(`*Exported from Trekoon on ${new Date(bundle.exportedAt).toISOString()}. This is a snapshot — the database is the source of truth.*`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function taskAnchor(task: TaskRecord): string {
|
|
255
|
+
return `task-${task.id}`;
|
|
256
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { DependencyRecord, EpicRecord, SubtaskRecord, TaskRecord } from "../domain/types";
|
|
2
|
+
|
|
3
|
+
export const EXPORT_SCHEMA_VERSION = 1;
|
|
4
|
+
|
|
5
|
+
export type ExportNodeKind = "task" | "subtask";
|
|
6
|
+
|
|
7
|
+
export interface ExportDependencyEdge {
|
|
8
|
+
readonly id: string;
|
|
9
|
+
readonly sourceId: string;
|
|
10
|
+
readonly sourceKind: ExportNodeKind;
|
|
11
|
+
readonly dependsOnId: string;
|
|
12
|
+
readonly dependsOnKind: ExportNodeKind;
|
|
13
|
+
readonly internal: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ExportExternalNode {
|
|
17
|
+
readonly id: string;
|
|
18
|
+
readonly kind: ExportNodeKind;
|
|
19
|
+
readonly title: string | null;
|
|
20
|
+
readonly status: string | null;
|
|
21
|
+
readonly epicId: string | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ExportWarning {
|
|
25
|
+
readonly code: string;
|
|
26
|
+
readonly message: string;
|
|
27
|
+
readonly entityId?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ExportStatusCounts {
|
|
31
|
+
readonly total: number;
|
|
32
|
+
readonly todo: number;
|
|
33
|
+
readonly inProgress: number;
|
|
34
|
+
readonly done: number;
|
|
35
|
+
readonly blocked: number;
|
|
36
|
+
readonly other: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ExportSummary {
|
|
40
|
+
readonly taskCount: number;
|
|
41
|
+
readonly subtaskCount: number;
|
|
42
|
+
readonly dependencyCount: number;
|
|
43
|
+
readonly externalNodeCount: number;
|
|
44
|
+
readonly warningCount: number;
|
|
45
|
+
readonly taskStatuses: ExportStatusCounts;
|
|
46
|
+
readonly subtaskStatuses: ExportStatusCounts;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ExportBundle {
|
|
50
|
+
readonly schemaVersion: number;
|
|
51
|
+
readonly exportedAt: number;
|
|
52
|
+
readonly epic: EpicRecord;
|
|
53
|
+
readonly tasks: readonly TaskRecord[];
|
|
54
|
+
readonly subtasks: readonly SubtaskRecord[];
|
|
55
|
+
readonly dependencies: readonly ExportDependencyEdge[];
|
|
56
|
+
readonly externalNodes: readonly ExportExternalNode[];
|
|
57
|
+
readonly blockedBy: ReadonlyMap<string, readonly string[]>;
|
|
58
|
+
readonly blocks: ReadonlyMap<string, readonly string[]>;
|
|
59
|
+
readonly warnings: readonly ExportWarning[];
|
|
60
|
+
readonly summary: ExportSummary;
|
|
61
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { mkdirSync, openSync, renameSync, unlinkSync, writeSync, closeSync, constants } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
|
|
5
|
+
export interface WriteResult {
|
|
6
|
+
readonly path: string;
|
|
7
|
+
readonly overwritten: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function atomicWrite(options: {
|
|
11
|
+
readonly path: string;
|
|
12
|
+
readonly content: string;
|
|
13
|
+
readonly overwrite: boolean;
|
|
14
|
+
}): WriteResult {
|
|
15
|
+
const dir = dirname(options.path);
|
|
16
|
+
mkdirSync(dir, { recursive: true });
|
|
17
|
+
|
|
18
|
+
if (options.overwrite) {
|
|
19
|
+
return writeViaTempRename(options.path, options.content);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return writeExclusive(options.path, options.content);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function writeViaTempRename(targetPath: string, content: string): WriteResult {
|
|
26
|
+
const dir = dirname(targetPath);
|
|
27
|
+
const tempPath = resolve(dir, `.export-${randomUUID()}.tmp`);
|
|
28
|
+
let overwritten = false;
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
// Probe whether the target already exists by attempting an exclusive open.
|
|
32
|
+
// If it succeeds, the file didn't exist, so we close and remove that probe
|
|
33
|
+
// since we'll write via temp+rename anyway for atomicity.
|
|
34
|
+
try {
|
|
35
|
+
const probeFd = openSync(targetPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL);
|
|
36
|
+
closeSync(probeFd);
|
|
37
|
+
unlinkSync(targetPath);
|
|
38
|
+
overwritten = false;
|
|
39
|
+
} catch {
|
|
40
|
+
overwritten = true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const fd = openSync(tempPath, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC);
|
|
44
|
+
try {
|
|
45
|
+
writeSync(fd, content);
|
|
46
|
+
} finally {
|
|
47
|
+
closeSync(fd);
|
|
48
|
+
}
|
|
49
|
+
renameSync(tempPath, targetPath);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
try { unlinkSync(tempPath); } catch { /* best-effort cleanup */ }
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { path: targetPath, overwritten };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function writeExclusive(targetPath: string, content: string): WriteResult {
|
|
59
|
+
// O_CREAT | O_EXCL is atomic: the kernel fails if the file already exists.
|
|
60
|
+
let fd: number;
|
|
61
|
+
try {
|
|
62
|
+
fd = openSync(targetPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL);
|
|
63
|
+
} catch (error: unknown) {
|
|
64
|
+
if (isFileExistsError(error)) {
|
|
65
|
+
throw new ExportWriteError(
|
|
66
|
+
`File already exists: ${targetPath}. Use --overwrite to resave.`,
|
|
67
|
+
"file_exists",
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
writeSync(fd, content);
|
|
75
|
+
} finally {
|
|
76
|
+
closeSync(fd);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return { path: targetPath, overwritten: false };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isFileExistsError(error: unknown): boolean {
|
|
83
|
+
if (typeof error === "object" && error !== null && "code" in error) {
|
|
84
|
+
return (error as { code: string }).code === "EEXIST";
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export class ExportWriteError extends Error {
|
|
90
|
+
readonly code: string;
|
|
91
|
+
|
|
92
|
+
constructor(message: string, code: string) {
|
|
93
|
+
super(message);
|
|
94
|
+
this.name = "ExportWriteError";
|
|
95
|
+
this.code = code;
|
|
96
|
+
}
|
|
97
|
+
}
|