dig-burrow 1.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
+ 'use strict';
2
+
3
+ // --- Constants ---
4
+
5
+ const HR = '\u2500'.repeat(40);
6
+ const CHECKMARK = '\u2713';
7
+ const CROSSMARK = '\u2717';
8
+ const BRANCH = '\u251c\u2500';
9
+ const CORNER = '\u2514\u2500';
10
+ const PIPE = '\u2502';
11
+ const DOT = '\u2022';
12
+ const ARROW = '\u2192';
13
+ const BREADCRUMB_SEP = ' \u203a ';
14
+ const BODY_TRUNCATE_LENGTH = 200;
15
+ const DIFF_TRUNCATE_LENGTH = 40;
16
+ // Minimum terminal width floor: 2 (margin) + 2 (branch) + 1 (space) + 10 (id bracket) + 1 (space) + 15 (min title) + 2 (padding) + 7 (age) = 40
17
+ const MIN_TERM_WIDTH = 40;
18
+
19
+ // --- Internal Helpers ---
20
+
21
+ /**
22
+ * Produce a relative age string from an ISO date string.
23
+ * @param {string} isoString
24
+ * @returns {string}
25
+ */
26
+ function formatAge(isoString) {
27
+ if (typeof isoString !== 'string') return '???';
28
+ if (!isoString) return '???';
29
+ const now = Date.now();
30
+ const then = new Date(isoString).getTime();
31
+ if (isNaN(then)) return '???';
32
+ const diffMs = Math.max(0, now - then);
33
+ const diffSec = Math.floor(diffMs / 1000);
34
+
35
+ if (diffSec < 60) return 'just now';
36
+ const diffMin = Math.floor(diffSec / 60);
37
+ if (diffMin < 60) return `${diffMin}m ago`;
38
+ const diffHours = Math.floor(diffMin / 60);
39
+ if (diffHours < 24) return `${diffHours}h ago`;
40
+ const diffDays = Math.floor(diffHours / 24);
41
+ if (diffDays < 7) return `${diffDays}d ago`;
42
+ const diffWeeks = Math.floor(diffDays / 7);
43
+ if (diffWeeks < 52) return `${diffWeeks}w ago`;
44
+ const diffYears = Math.floor(diffWeeks / 52);
45
+ return `${diffYears}y ago`;
46
+ }
47
+
48
+ /**
49
+ * Format created date: "YYYY-MM-DD (Xd ago)"
50
+ * @param {string} isoString
51
+ * @returns {string}
52
+ */
53
+ function formatCreatedDate(isoString) {
54
+ if (!isoString) return `??? (???)`;
55
+ const date = new Date(isoString);
56
+ if (isNaN(date.getTime())) return `??? (???)`;
57
+ const yyyy = date.getUTCFullYear();
58
+ const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
59
+ const dd = String(date.getUTCDate()).padStart(2, '0');
60
+ return `${yyyy}-${mm}-${dd} (${formatAge(isoString)})`;
61
+ }
62
+
63
+ /**
64
+ * Format breadcrumb: "burrow > ancestors > card name"
65
+ * @param {Array<{id, title}>} ancestors - Ancestor breadcrumbs (not including card)
66
+ * @param {string} cardTitle
67
+ * @param {string} [context] - Optional context string after dot separator
68
+ * @returns {string}
69
+ */
70
+ function formatBreadcrumb(ancestors, cardTitle, context) {
71
+ const safeCardTitle = (cardTitle && cardTitle.trim()) ? cardTitle : '(untitled)';
72
+ const parts = ['burrow'];
73
+ for (const a of ancestors) {
74
+ parts.push(a.title);
75
+ }
76
+ parts.push(safeCardTitle);
77
+ let result = parts.join(BREADCRUMB_SEP);
78
+ if (context) {
79
+ result += ` \u00b7 ${context}`;
80
+ }
81
+ return result;
82
+ }
83
+
84
+ /**
85
+ * Truncate string with ellipsis if over maxLen.
86
+ * @param {string} str
87
+ * @param {number} maxLen
88
+ * @returns {string}
89
+ */
90
+ function truncate(str, maxLen) {
91
+ if (str.length <= maxLen) return str;
92
+ return str.slice(0, maxLen - 1) + '\u2026';
93
+ }
94
+
95
+
96
+ /**
97
+ * Format a single card line for tree listing.
98
+ * @param {object} card - {id, title, created, archived, body, children, hasBody, descendantCount}
99
+ * @param {string} prefix - Box-drawing prefix (branch or corner)
100
+ * @param {number} termWidth - Terminal width
101
+ * @returns {string}
102
+ */
103
+ function formatCardLine(card, prefix, termWidth) {
104
+ const tw = Math.max(MIN_TERM_WIDTH, termWidth || 80);
105
+ const id = `[${card.id}]`;
106
+ const hasBody = card.hasBody !== undefined ? card.hasBody : !!(card.body && card.body.trim());
107
+ const bodyMarker = hasBody ? ' +' : '';
108
+ const age = formatAge(card.created);
109
+ const descCount = card.descendantCount || 0;
110
+ const countStr = descCount > 0 ? ` (${descCount})` : '';
111
+ const archivedLabel = card.archived ? ' [archived]' : '';
112
+ const safeTitle = (card.title && card.title.trim()) ? card.title : '(untitled)';
113
+
114
+ // Left side without title: prefix + space + id + space
115
+ const leftFixedParts = ` ${prefix} ${id} `;
116
+ // Right side: just age
117
+ const rightSide = age;
118
+ // Indicators after title
119
+ const indicators = `${countStr}${bodyMarker}${archivedLabel}`;
120
+
121
+ // Available space for title
122
+ const availableForTitle = tw - leftFixedParts.length - indicators.length - 2 - rightSide.length;
123
+ const title = availableForTitle > 0 ? truncate(safeTitle, availableForTitle) : truncate(safeTitle, 1);
124
+
125
+ // Pad middle to right-align age
126
+ const leftContent = `${leftFixedParts}${title}${indicators}`;
127
+ const padding = Math.max(1, tw - leftContent.length - rightSide.length);
128
+
129
+ return `${leftContent}${' '.repeat(padding)}${rightSide}`;
130
+ }
131
+
132
+ /**
133
+ * Recursively render tree lines with proper indentation.
134
+ * @param {Array} children - Array of card objects (may have nested children)
135
+ * @param {number} depth - Current nesting depth (0 = top-level children)
136
+ * @param {string} indent - Accumulated indent prefix for this level
137
+ * @param {number} tw - Terminal width
138
+ * @returns {string[]} Array of formatted lines
139
+ */
140
+ function renderTreeLines(children, depth, indent, tw) {
141
+ const result = [];
142
+ for (let i = 0; i < children.length; i++) {
143
+ const child = children[i];
144
+ const isLast = i === children.length - 1;
145
+ const prefix = indent + (isLast ? CORNER : BRANCH);
146
+ result.push(formatCardLine(child, prefix, tw));
147
+
148
+ if (child.children && child.children.length > 0) {
149
+ const nextIndent = indent + (isLast ? ' ' : `${PIPE} `);
150
+ const subLines = renderTreeLines(child.children, depth + 1, nextIndent, tw);
151
+ for (const sl of subLines) {
152
+ result.push(sl);
153
+ }
154
+ }
155
+ }
156
+ return result;
157
+ }
158
+
159
+ // --- Exported Functions ---
160
+
161
+ /**
162
+ * Render a card in full detail format.
163
+ * @param {object} card - Full card object
164
+ * @param {Array<{id, title}>} breadcrumbs - Ancestor breadcrumbs
165
+ * @param {object} opts - {full, termWidth, archiveFilter}
166
+ * @returns {string}
167
+ */
168
+ function renderCard(card, breadcrumbs, opts) {
169
+ const { full, termWidth } = opts || {};
170
+ const tw = Math.max(MIN_TERM_WIDTH, termWidth || 80);
171
+ const lines = [];
172
+ const safeTitle = (card.title && card.title.trim()) ? card.title : '(untitled)';
173
+
174
+ // Breadcrumb header
175
+ if (card.id === '(root)') {
176
+ lines.push('burrow');
177
+ } else {
178
+ lines.push(formatBreadcrumb(breadcrumbs || [], safeTitle));
179
+ }
180
+ lines.push('');
181
+
182
+ // Title section
183
+ lines.push(HR);
184
+ lines.push(safeTitle);
185
+ lines.push(HR);
186
+
187
+ // Metadata
188
+ lines.push(`id: ${card.id}`);
189
+ lines.push(`created: ${formatCreatedDate(card.created)}`);
190
+ lines.push(`archived: ${card.archived ? 'yes' : 'no'}`);
191
+ lines.push(HR);
192
+
193
+ // Children section — pre-filtered by renderTree, trust the data
194
+ const children = card.children || [];
195
+
196
+ if (children.length === 0) {
197
+ lines.push('children: (none)');
198
+ } else {
199
+ const activeCount = children.length;
200
+ const totalDescendants = children.reduce(
201
+ (sum, c) => sum + 1 + (c.descendantCount || 0), 0
202
+ );
203
+ lines.push(`children: ${activeCount} cards (${totalDescendants} total)`);
204
+ const treeLines = renderTreeLines(children, 0, '', tw);
205
+ for (const tl of treeLines) {
206
+ lines.push(tl);
207
+ }
208
+ }
209
+ lines.push(HR);
210
+
211
+ // Body section
212
+ const body = card.body || '';
213
+ if (!body.trim()) {
214
+ lines.push('body: (empty)');
215
+ } else {
216
+ lines.push('body:');
217
+ let displayBody = body;
218
+ if (!full && displayBody.length > BODY_TRUNCATE_LENGTH) {
219
+ displayBody = displayBody.slice(0, BODY_TRUNCATE_LENGTH) +
220
+ '\u2026(truncated \u2014 use --full for complete body)';
221
+ }
222
+ const bodyLines = displayBody.split('\n');
223
+ for (const bl of bodyLines) {
224
+ lines.push(` ${bl}`);
225
+ }
226
+ }
227
+ lines.push(HR);
228
+
229
+ return lines.join('\n');
230
+ }
231
+
232
+ /**
233
+ * Render mutation output.
234
+ * @param {string} type - 'add'|'edit'|'remove'|'move'|'archive'|'unarchive'
235
+ * @param {object} result - Command result
236
+ * @param {object} opts - {breadcrumbs, card, oldTitle, oldBody, fromParentTitle, termWidth}
237
+ * @returns {string}
238
+ */
239
+ function renderMutation(type, result, opts) {
240
+ const { breadcrumbs, card, oldTitle, oldBody, fromParentTitle, toParentTitle, termWidth } = opts || {};
241
+
242
+ switch (type) {
243
+ case 'add': {
244
+ const cardToRender = card || result;
245
+ const detail = renderCard(cardToRender, breadcrumbs || [], { termWidth });
246
+ return `${CHECKMARK} Added card\n\n${detail}`;
247
+ }
248
+
249
+ case 'edit': {
250
+ const cardToRender = card || result;
251
+ const diffLines = [];
252
+
253
+ if (oldTitle !== undefined && oldTitle !== cardToRender.title) {
254
+ diffLines.push(
255
+ ` title: ${truncate(oldTitle, DIFF_TRUNCATE_LENGTH)} ${ARROW} ${truncate(cardToRender.title, DIFF_TRUNCATE_LENGTH)}`
256
+ );
257
+ }
258
+
259
+ if (oldBody !== undefined && oldBody !== cardToRender.body) {
260
+ const oldBodyClean = oldBody.replace(/\n/g, ' ');
261
+ const newBodyClean = (cardToRender.body || '').replace(/\n/g, ' ');
262
+ diffLines.push(
263
+ ` body: ${truncate(oldBodyClean, DIFF_TRUNCATE_LENGTH)} ${ARROW} ${truncate(newBodyClean, DIFF_TRUNCATE_LENGTH)}`
264
+ );
265
+ }
266
+
267
+ const detail = renderCard(cardToRender, breadcrumbs || [], { termWidth });
268
+ const diffSection = diffLines.length > 0 ? '\n' + diffLines.join('\n') + '\n' : '';
269
+ return `${CHECKMARK} Edited card${diffSection}\n${detail}`;
270
+ }
271
+
272
+ case 'remove': {
273
+ const childPart = result.descendantCount > 0
274
+ ? ` (and ${result.descendantCount} children)`
275
+ : '';
276
+ return `${CHECKMARK} Removed "${result.title}" [${result.id}]${childPart}`;
277
+ }
278
+
279
+ case 'move': {
280
+ const from = fromParentTitle || 'root';
281
+ const to = toParentTitle || 'root';
282
+ return `${CHECKMARK} Moved "${result.title}" [${result.id}]: ${from} ${ARROW} ${to}`;
283
+ }
284
+
285
+ case 'archive': {
286
+ const childPart = result.descendantCount > 0
287
+ ? ` (and ${result.descendantCount} children)`
288
+ : '';
289
+ return `${CHECKMARK} Archived "${result.title}" [${result.id}]${childPart}`;
290
+ }
291
+
292
+ case 'unarchive': {
293
+ const childPart = result.descendantCount > 0
294
+ ? ` (and ${result.descendantCount} children)`
295
+ : '';
296
+ return `${CHECKMARK} Unarchived "${result.title}" [${result.id}]${childPart}`;
297
+ }
298
+
299
+ default:
300
+ return `${CHECKMARK} ${type} completed`;
301
+ }
302
+ }
303
+
304
+ /**
305
+ * Render a breadcrumb path string.
306
+ * @param {Array<{id, title}>} pathArray - From root to target
307
+ * @returns {string}
308
+ */
309
+ function renderPath(pathArray) {
310
+ if (!pathArray || pathArray.length === 0) return 'burrow';
311
+ const parts = ['burrow'];
312
+ for (const entry of pathArray) {
313
+ parts.push(entry.title);
314
+ }
315
+ const lastEntry = pathArray[pathArray.length - 1];
316
+ // Replace last part with "title [id]"
317
+ parts[parts.length - 1] = `${lastEntry.title} [${lastEntry.id}]`;
318
+ return parts.join(BREADCRUMB_SEP);
319
+ }
320
+
321
+ /**
322
+ * Render an error message.
323
+ * @param {string} message
324
+ * @returns {string}
325
+ */
326
+ function renderError(message) {
327
+ return `${CROSSMARK} ${message}`;
328
+ }
329
+
330
+ module.exports = { renderCard, renderMutation, renderPath, renderError };
@@ -0,0 +1,137 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const https = require('node:https');
6
+
7
+ // Cache file path relative to targetDir
8
+ const UPDATE_CACHE_FILE = '.planning/burrow/.update-check';
9
+
10
+ // 24 hours in milliseconds
11
+ const CACHE_TTL_MS = 86400000;
12
+
13
+ /**
14
+ * Read the VERSION file from targetDir/.claude/burrow/VERSION.
15
+ * @param {string} targetDir - Root of the target (installed) project
16
+ * @returns {string|null} Trimmed version string or null on missing/error
17
+ */
18
+ function getInstalledVersion(targetDir) {
19
+ try {
20
+ const versionPath = path.join(targetDir, '.claude', 'burrow', 'VERSION');
21
+ return fs.readFileSync(versionPath, 'utf-8').trim();
22
+ } catch (_) {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Compare two semver strings numerically.
29
+ * null is treated as "0.0.0" (always behind any real version).
30
+ *
31
+ * @param {string|null} a
32
+ * @param {string|null} b
33
+ * @returns {-1|0|1} -1 if a < b, 0 if a == b, 1 if a > b
34
+ */
35
+ function compareSemver(a, b) {
36
+ const parse = (v) => (v ? v.split('.').map((n) => parseInt(n, 10) || 0) : [0, 0, 0]);
37
+ const [aMaj, aMin, aPatch] = parse(a);
38
+ const [bMaj, bMin, bPatch] = parse(b);
39
+
40
+ if (aMaj !== bMaj) return aMaj < bMaj ? -1 : 1;
41
+ if (aMin !== bMin) return aMin < bMin ? -1 : 1;
42
+ if (aPatch !== bPatch) return aPatch < bPatch ? -1 : 1;
43
+ return 0;
44
+ }
45
+
46
+ /**
47
+ * Fetch the latest version of the create-burrow package from the npm registry.
48
+ * Returns null on network error or parse failure — never throws.
49
+ *
50
+ * @returns {Promise<string|null>}
51
+ */
52
+ function fetchLatestVersion() {
53
+ return new Promise((resolve) => {
54
+ const req = https.get('https://registry.npmjs.org/create-burrow/latest', {
55
+ headers: { 'Accept': 'application/json' },
56
+ timeout: 5000,
57
+ }, (res) => {
58
+ let data = '';
59
+ res.on('data', (chunk) => { data += chunk; });
60
+ res.on('end', () => {
61
+ try {
62
+ const pkg = JSON.parse(data);
63
+ resolve(pkg.version || null);
64
+ } catch (_) {
65
+ resolve(null);
66
+ }
67
+ });
68
+ });
69
+ req.on('error', () => resolve(null));
70
+ req.on('timeout', () => { req.destroy(); resolve(null); });
71
+ });
72
+ }
73
+
74
+ /**
75
+ * Check if an update is available by comparing the installed version against
76
+ * the latest version published on npm.
77
+ *
78
+ * Uses a 24h cache stored at cwd/.planning/burrow/.update-check to avoid
79
+ * hitting the registry on every CLI invocation.
80
+ *
81
+ * Returns null if:
82
+ * - The cache is fresh (< 24h old)
83
+ * - Any error occurs (network failure, file I/O, etc.)
84
+ *
85
+ * Returns { outdated: boolean, latestVersion: string|null, installedVersion: string|null }
86
+ * after performing a fresh check.
87
+ *
88
+ * @param {string} cwd - Root of the target (installed) project
89
+ * @returns {Promise<{ outdated: boolean, latestVersion: string|null, installedVersion: string|null }|null>}
90
+ */
91
+ async function checkForUpdate(cwd) {
92
+ try {
93
+ const cachePath = path.join(cwd, UPDATE_CACHE_FILE);
94
+
95
+ // Check if cache is fresh
96
+ if (fs.existsSync(cachePath)) {
97
+ try {
98
+ const cache = JSON.parse(fs.readFileSync(cachePath, 'utf-8'));
99
+ if (cache.lastCheck && Date.now() - Date.parse(cache.lastCheck) < CACHE_TTL_MS) {
100
+ return null; // Cache is still valid — skip check
101
+ }
102
+ } catch (_) {
103
+ // Corrupt cache — proceed with fresh check
104
+ }
105
+ }
106
+
107
+ const latestVersion = await fetchLatestVersion();
108
+ const installedVersion = getInstalledVersion(cwd);
109
+ const outdated = compareSemver(installedVersion, latestVersion) < 0;
110
+
111
+ // Write cache
112
+ try {
113
+ const cacheDir = path.dirname(cachePath);
114
+ if (!fs.existsSync(cacheDir)) {
115
+ fs.mkdirSync(cacheDir, { recursive: true });
116
+ }
117
+ fs.writeFileSync(
118
+ cachePath,
119
+ JSON.stringify({ lastCheck: new Date().toISOString(), latestVersion, installedVersion })
120
+ );
121
+ } catch (_) {
122
+ // Cache write failure is non-fatal
123
+ }
124
+
125
+ return { outdated, latestVersion, installedVersion };
126
+ } catch (_) {
127
+ return null;
128
+ }
129
+ }
130
+
131
+ module.exports = {
132
+ getInstalledVersion,
133
+ compareSemver,
134
+ checkForUpdate,
135
+ fetchLatestVersion,
136
+ UPDATE_CACHE_FILE,
137
+ };
@@ -0,0 +1,168 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { ensureDataDir } = require('./core.cjs');
6
+
7
+ const DATA_FILE = 'cards.json';
8
+ const BACKUP_EXT = '.bak';
9
+ const TMP_EXT = '.tmp';
10
+
11
+ /**
12
+ * Resolve the path to cards.json within the given working directory.
13
+ * @param {string} cwd
14
+ * @returns {string}
15
+ */
16
+ function dataPath(cwd) {
17
+ return path.join(cwd, '.planning', 'burrow', DATA_FILE);
18
+ }
19
+
20
+ /**
21
+ * Recursively migrate a single card from v1 to v2 format.
22
+ * - Renames notes -> body
23
+ * - Deletes position
24
+ * - Flattens children from {ordering, cards/items: []} to plain []
25
+ * @param {object} card
26
+ */
27
+ function migrateCard(card) {
28
+ // Rename notes -> body
29
+ if (card.notes !== undefined) {
30
+ card.body = card.notes;
31
+ delete card.notes;
32
+ }
33
+ if (card.body === undefined) {
34
+ card.body = '';
35
+ }
36
+
37
+ // Delete position
38
+ delete card.position;
39
+
40
+ // Flatten children
41
+ if (card.children && !Array.isArray(card.children)) {
42
+ // v1 children: {ordering, cards: [...]} or {ordering, items: [...]}
43
+ const childArray = card.children.cards || card.children.items || [];
44
+ card.children = childArray;
45
+ }
46
+ if (!card.children) {
47
+ card.children = [];
48
+ }
49
+
50
+ // Recurse into children
51
+ for (const child of card.children) {
52
+ migrateCard(child);
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Migrate data from v1 to v2 format. Idempotent on v2 data.
58
+ * - Root: items -> cards, delete ordering
59
+ * - Cards: notes -> body, delete position, flatten children
60
+ * - Set version: 2
61
+ * @param {object} data
62
+ * @returns {object} Migrated data (mutated in place)
63
+ */
64
+ function migrate(data) {
65
+ if (data.version >= 2) return data;
66
+
67
+ // Root: items -> cards
68
+ if (data.items && !data.cards) {
69
+ data.cards = data.items;
70
+ delete data.items;
71
+ }
72
+
73
+ // Delete root ordering
74
+ delete data.ordering;
75
+
76
+ // Migrate each card
77
+ if (data.cards) {
78
+ for (const card of data.cards) {
79
+ migrateCard(card);
80
+ }
81
+ } else {
82
+ data.cards = [];
83
+ }
84
+
85
+ data.version = 2;
86
+ return data;
87
+ }
88
+
89
+ /**
90
+ * Validate the schema of parsed cards.json data.
91
+ * Throws a human-readable Error if the data is structurally invalid.
92
+ * @param {*} data - Parsed JSON value
93
+ */
94
+ function validateSchema(data) {
95
+ if (data === null || typeof data !== 'object' || Array.isArray(data)) {
96
+ throw new Error('Burrow: invalid cards.json — expected a JSON object, got ' + (data === null ? 'null' : Array.isArray(data) ? 'array' : typeof data));
97
+ }
98
+
99
+ // Support v1 (items) and v2 (cards) root arrays
100
+ const rootArray = data.cards !== undefined ? data.cards : data.items;
101
+
102
+ if (rootArray === undefined) {
103
+ throw new Error("Burrow: invalid cards.json — missing 'cards' array");
104
+ }
105
+
106
+ if (!Array.isArray(rootArray)) {
107
+ throw new Error("Burrow: invalid cards.json — expected 'cards' to be an array, got " + typeof rootArray);
108
+ }
109
+
110
+ // Spot-check: first element must have a string id (if present)
111
+ if (rootArray.length > 0 && typeof rootArray[0].id !== 'string') {
112
+ throw new Error("Burrow: invalid cards.json — expected card 'id' to be a string, got " + typeof rootArray[0].id);
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Load the burrow data from cards.json.
118
+ * Returns default empty v2 structure if the file does not exist.
119
+ * Automatically migrates v1 data to v2 format.
120
+ * @param {string} cwd - Working directory
121
+ * @returns {object} Parsed data in v2 format
122
+ */
123
+ function load(cwd) {
124
+ const filePath = dataPath(cwd);
125
+ try {
126
+ const raw = fs.readFileSync(filePath, 'utf-8');
127
+ const data = JSON.parse(raw);
128
+ validateSchema(data);
129
+ // Skip migrate() entirely for already-v2 data (PERF-09)
130
+ if (data.version < 2) {
131
+ return migrate(data);
132
+ }
133
+ return data;
134
+ } catch (err) {
135
+ if (err.code === 'ENOENT') {
136
+ return { version: 2, cards: [] };
137
+ }
138
+ throw err;
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Save the burrow data to cards.json with atomic write and backup.
144
+ * 1. Ensure data directory exists
145
+ * 2. If cards.json exists, copy to cards.json.bak
146
+ * 3. Write to cards.json.tmp
147
+ * 4. Rename tmp to cards.json
148
+ * @param {string} cwd - Working directory
149
+ * @param {object} data - Data to save
150
+ */
151
+ function save(cwd, data) {
152
+ ensureDataDir(cwd);
153
+ const filePath = dataPath(cwd);
154
+ const backupPath = filePath + BACKUP_EXT;
155
+ const tmpPath = filePath + TMP_EXT;
156
+
157
+ // Backup existing file
158
+ if (fs.existsSync(filePath)) {
159
+ fs.copyFileSync(filePath, backupPath);
160
+ }
161
+
162
+ // Atomic write: tmp then rename
163
+ const content = JSON.stringify(data, null, 2) + '\n';
164
+ fs.writeFileSync(tmpPath, content, 'utf-8');
165
+ fs.renameSync(tmpPath, filePath);
166
+ }
167
+
168
+ module.exports = { load, save, migrate };