@onebrain-ai/cli 2.0.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,271 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
2
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ import { runOrphanScan } from './orphan-scan.js';
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Helpers
10
+ // ---------------------------------------------------------------------------
11
+
12
+ async function makeTmpDir(): Promise<string> {
13
+ return mkdtemp(join(tmpdir(), 'onebrain-os-test-'));
14
+ }
15
+
16
+ /**
17
+ * Build a checkpoint filename: YYYY-MM-DD-{token}-checkpoint-{NN}.md
18
+ */
19
+ function checkpointName(date: string, token: string, nn: number): string {
20
+ return `${date}-${token}-checkpoint-${String(nn).padStart(2, '0')}.md`;
21
+ }
22
+
23
+ /**
24
+ * Build a session log filename: YYYY-MM-DD-session-{NN}.md
25
+ */
26
+ function sessionLogName(date: string, nn: number): string {
27
+ return `${date}-session-${String(nn).padStart(2, '0')}.md`;
28
+ }
29
+
30
+ function checkpointFrontmatter(merged: boolean): string {
31
+ return `---\ntags: [checkpoint, session-log]\ndate: ${new Date().toISOString().slice(0, 10)}\ncheckpoint: 01\ntrigger: stop\nmerged: ${merged}\n---\n\n## What We Worked On\n\nTest content.`;
32
+ }
33
+
34
+ function sessionLogFrontmatter(autoSaved: boolean): string {
35
+ return `---\ntags: [session-log]\ndate: 2026-04-20\nauto-saved: ${autoSaved}\n---\n\n## Session\n\nTest.`;
36
+ }
37
+
38
+ /**
39
+ * Get current and previous month dirs as { thisYear, thisMonth, prevYear, prevMonth }
40
+ */
41
+ function getMonthParts() {
42
+ const now = new Date();
43
+ const thisYear = String(now.getFullYear());
44
+ const thisMonth = String(now.getMonth() + 1).padStart(2, '0');
45
+ const prevDate = new Date(now.getFullYear(), now.getMonth() - 1, 1);
46
+ const prevYear = String(prevDate.getFullYear());
47
+ const prevMonth = String(prevDate.getMonth() + 1).padStart(2, '0');
48
+ return { thisYear, thisMonth, prevYear, prevMonth };
49
+ }
50
+
51
+ async function makeMonthDir(logsDir: string, year: string, month: string): Promise<string> {
52
+ const dir = join(logsDir, year, month);
53
+ await mkdir(dir, { recursive: true });
54
+ return dir;
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Tests
59
+ // ---------------------------------------------------------------------------
60
+
61
+ describe('runOrphanScan', () => {
62
+ let tmpDir: string;
63
+ let logsDir: string;
64
+
65
+ beforeEach(async () => {
66
+ tmpDir = await makeTmpDir();
67
+ logsDir = join(tmpDir, '07-logs');
68
+ await mkdir(logsDir, { recursive: true });
69
+ });
70
+
71
+ afterEach(async () => {
72
+ await rm(tmpDir, { recursive: true, force: true });
73
+ });
74
+
75
+ it('returns orphan_count: 0 when no checkpoint files exist', async () => {
76
+ const result = await runOrphanScan(logsDir, 'abc12345');
77
+ expect(result).toEqual({ orphan_count: 0 });
78
+ });
79
+
80
+ // Update snapshots: bun test --update-snapshots
81
+ it('output shape matches snapshot { orphan_count: N }', async () => {
82
+ // Zero orphans — verifies the shape is { orphan_count: 0 }
83
+ const zeroResult = await runOrphanScan(logsDir, 'abc12345');
84
+ expect(zeroResult).toMatchSnapshot();
85
+
86
+ // One orphan — verifies the shape is { orphan_count: 1 }
87
+ const { thisYear, thisMonth } = getMonthParts();
88
+ const monthDir = await makeMonthDir(logsDir, thisYear, thisMonth);
89
+ const pastDate = `${thisYear}-${thisMonth}-01`;
90
+ await writeFile(
91
+ join(monthDir, `${pastDate}-snaptoken-checkpoint-01.md`),
92
+ '---\ntags: [checkpoint]\nmerged: false\n---\n\nContent.',
93
+ 'utf8',
94
+ );
95
+ const oneResult = await runOrphanScan(logsDir, 'differenttoken');
96
+ expect(oneResult).toMatchSnapshot();
97
+ });
98
+
99
+ it('returns orphan_count: 0 when logs folder does not exist', async () => {
100
+ const result = await runOrphanScan(join(tmpDir, 'nonexistent'), 'abc12345');
101
+ expect(result).toEqual({ orphan_count: 0 });
102
+ });
103
+
104
+ it('skips checkpoint files with merged: true', async () => {
105
+ const { thisYear, thisMonth } = getMonthParts();
106
+ const monthDir = await makeMonthDir(logsDir, thisYear, thisMonth);
107
+ // Use a past date so today-skip doesn't apply
108
+ const pastDate = `${thisYear}-${thisMonth}-01`;
109
+ const fname = checkpointName(pastDate, 'token11', 1);
110
+ await writeFile(join(monthDir, fname), checkpointFrontmatter(true), 'utf8');
111
+ const result = await runOrphanScan(logsDir, 'current99');
112
+ expect(result).toEqual({ orphan_count: 0 });
113
+ });
114
+
115
+ it('skips checkpoint files with merged: "true" (quoted string in YAML)', async () => {
116
+ const { thisYear, thisMonth } = getMonthParts();
117
+ const monthDir = await makeMonthDir(logsDir, thisYear, thisMonth);
118
+ const pastDate = `${thisYear}-${thisMonth}-01`;
119
+ const fname = checkpointName(pastDate, 'tokenStrTrue', 1);
120
+ // Write frontmatter with merged as a quoted string (YAML string "true", not boolean)
121
+ const content = `---\ntags: [checkpoint, session-log]\ndate: ${pastDate}\ncheckpoint: 01\ntrigger: stop\nmerged: "true"\n---\n\n## What We Worked On\n\nTest content.`;
122
+ await writeFile(join(monthDir, fname), content, 'utf8');
123
+ const result = await runOrphanScan(logsDir, 'current99');
124
+ expect(result).toEqual({ orphan_count: 0 });
125
+ });
126
+
127
+ it('skips checkpoint files matching current session token', async () => {
128
+ const { thisYear, thisMonth } = getMonthParts();
129
+ const monthDir = await makeMonthDir(logsDir, thisYear, thisMonth);
130
+ const pastDate = `${thisYear}-${thisMonth}-01`;
131
+ const fname = checkpointName(pastDate, 'current99', 1);
132
+ await writeFile(join(monthDir, fname), checkpointFrontmatter(false), 'utf8');
133
+ const result = await runOrphanScan(logsDir, 'current99');
134
+ expect(result).toEqual({ orphan_count: 0 });
135
+ });
136
+
137
+ it('skips checkpoint when a manual (non-auto-saved) session log exists for that date', async () => {
138
+ const { thisYear, thisMonth } = getMonthParts();
139
+ const monthDir = await makeMonthDir(logsDir, thisYear, thisMonth);
140
+ const pastDate = `${thisYear}-${thisMonth}-02`;
141
+ // Write checkpoint (unmerged)
142
+ const cpName = checkpointName(pastDate, 'tokenAA', 1);
143
+ await writeFile(join(monthDir, cpName), checkpointFrontmatter(false), 'utf8');
144
+ // Write manual session log (auto-saved: false)
145
+ const logName = sessionLogName(pastDate, 1);
146
+ await writeFile(join(monthDir, logName), sessionLogFrontmatter(false), 'utf8');
147
+ const result = await runOrphanScan(logsDir, 'current99');
148
+ expect(result).toEqual({ orphan_count: 0 });
149
+ });
150
+
151
+ it('does NOT skip when only auto-saved session log exists for that date', async () => {
152
+ const { thisYear, thisMonth } = getMonthParts();
153
+ const monthDir = await makeMonthDir(logsDir, thisYear, thisMonth);
154
+ const pastDate = `${thisYear}-${thisMonth}-03`;
155
+ // Write checkpoint (unmerged)
156
+ const cpName = checkpointName(pastDate, 'tokenBB', 1);
157
+ await writeFile(join(monthDir, cpName), checkpointFrontmatter(false), 'utf8');
158
+ // Write auto-saved session log
159
+ const logName = sessionLogName(pastDate, 1);
160
+ await writeFile(join(monthDir, logName), sessionLogFrontmatter(true), 'utf8');
161
+ const result = await runOrphanScan(logsDir, 'current99');
162
+ expect(result).toEqual({ orphan_count: 1 });
163
+ });
164
+
165
+ it('counts unmerged orphan checkpoints from current month', async () => {
166
+ const { thisYear, thisMonth } = getMonthParts();
167
+ const monthDir = await makeMonthDir(logsDir, thisYear, thisMonth);
168
+ const pastDate = `${thisYear}-${thisMonth}-04`;
169
+ // Two different tokens
170
+ for (const token of ['tokenCC', 'tokenDD']) {
171
+ const cpName = checkpointName(pastDate, token, 1);
172
+ await writeFile(join(monthDir, cpName), checkpointFrontmatter(false), 'utf8');
173
+ }
174
+ const result = await runOrphanScan(logsDir, 'current99');
175
+ expect(result).toEqual({ orphan_count: 2 });
176
+ });
177
+
178
+ it('counts orphans from previous month dir', async () => {
179
+ const { prevYear, prevMonth } = getMonthParts();
180
+ const monthDir = await makeMonthDir(logsDir, prevYear, prevMonth);
181
+ const pastDate = `${prevYear}-${prevMonth}-15`;
182
+ const cpName = checkpointName(pastDate, 'tokenEE', 1);
183
+ await writeFile(join(monthDir, cpName), checkpointFrontmatter(false), 'utf8');
184
+ const result = await runOrphanScan(logsDir, 'current99');
185
+ expect(result).toEqual({ orphan_count: 1 });
186
+ });
187
+
188
+ it('multiple checkpoints for same token in same month count as one orphan session', async () => {
189
+ const { thisYear, thisMonth } = getMonthParts();
190
+ const monthDir = await makeMonthDir(logsDir, thisYear, thisMonth);
191
+ const pastDate = `${thisYear}-${thisMonth}-05`;
192
+ // Same token, two checkpoint files
193
+ for (let i = 1; i <= 2; i++) {
194
+ const cpName = checkpointName(pastDate, 'tokenFF', i);
195
+ await writeFile(join(monthDir, cpName), checkpointFrontmatter(false), 'utf8');
196
+ }
197
+ const result = await runOrphanScan(logsDir, 'current99');
198
+ // One token = one orphan session
199
+ expect(result).toEqual({ orphan_count: 1 });
200
+ });
201
+
202
+ it('handles files with missing frontmatter gracefully (counts as orphan)', async () => {
203
+ const { thisYear, thisMonth } = getMonthParts();
204
+ const monthDir = await makeMonthDir(logsDir, thisYear, thisMonth);
205
+ const pastDate = `${thisYear}-${thisMonth}-06`;
206
+ const cpName = checkpointName(pastDate, 'tokenGG', 1);
207
+ await writeFile(join(monthDir, cpName), '# No frontmatter here\n\nContent.', 'utf8');
208
+ const result = await runOrphanScan(logsDir, 'current99');
209
+ expect(result).toEqual({ orphan_count: 1 });
210
+ });
211
+
212
+ it("creates a checkpoint file with today's actual date → orphan_count: 0 (today boundary skipped)", async () => {
213
+ const { thisYear, thisMonth } = getMonthParts();
214
+ const monthDir = await makeMonthDir(logsDir, thisYear, thisMonth);
215
+ const todayStr = `${thisYear}-${thisMonth}-${String(new Date().getDate()).padStart(2, '0')}`;
216
+ const fname = checkpointName(todayStr, 'todaytoken', 1);
217
+ await writeFile(join(monthDir, fname), checkpointFrontmatter(false), 'utf8');
218
+ const result = await runOrphanScan(logsDir, 'current99');
219
+ // Today's checkpoints must be skipped — not orphans yet
220
+ expect(result).toEqual({ orphan_count: 0 });
221
+ });
222
+
223
+ it("today's file skipped but a past date in same month still counted", async () => {
224
+ const { thisYear, thisMonth } = getMonthParts();
225
+ const monthDir = await makeMonthDir(logsDir, thisYear, thisMonth);
226
+ const todayStr = `${thisYear}-${thisMonth}-${String(new Date().getDate()).padStart(2, '0')}`;
227
+ // Today: should be skipped
228
+ const todayFname = checkpointName(todayStr, 'todaytoken', 1);
229
+ await writeFile(join(monthDir, todayFname), checkpointFrontmatter(false), 'utf8');
230
+
231
+ // Past date in same month: day 01 (safe to use if today isn't day 01)
232
+ const todayDay = new Date().getDate();
233
+ if (todayDay !== 1) {
234
+ const pastDate = `${thisYear}-${thisMonth}-01`;
235
+ const pastFname = checkpointName(pastDate, 'pasttoken', 1);
236
+ await writeFile(join(monthDir, pastFname), checkpointFrontmatter(false), 'utf8');
237
+
238
+ const result = await runOrphanScan(logsDir, 'current99');
239
+ // today skipped, past counted
240
+ expect(result).toEqual({ orphan_count: 1 });
241
+ } else {
242
+ // If today IS day 01, just verify today is skipped
243
+ const result = await runOrphanScan(logsDir, 'current99');
244
+ expect(result).toEqual({ orphan_count: 0 });
245
+ }
246
+ });
247
+
248
+ it('combines orphans from both months in total count', async () => {
249
+ const { thisYear, thisMonth, prevYear, prevMonth } = getMonthParts();
250
+
251
+ const thisMonthDir = await makeMonthDir(logsDir, thisYear, thisMonth);
252
+ const prevMonthDir = await makeMonthDir(logsDir, prevYear, prevMonth);
253
+
254
+ const thisDate = `${thisYear}-${thisMonth}-07`;
255
+ const prevDate = `${prevYear}-${prevMonth}-20`;
256
+
257
+ await writeFile(
258
+ join(thisMonthDir, checkpointName(thisDate, 'tokenHH', 1)),
259
+ checkpointFrontmatter(false),
260
+ 'utf8',
261
+ );
262
+ await writeFile(
263
+ join(prevMonthDir, checkpointName(prevDate, 'tokenII', 1)),
264
+ checkpointFrontmatter(false),
265
+ 'utf8',
266
+ );
267
+
268
+ const result = await runOrphanScan(logsDir, 'current99');
269
+ expect(result).toEqual({ orphan_count: 2 });
270
+ });
271
+ });
@@ -0,0 +1,213 @@
1
+ /**
2
+ * orphan-scan — internal command
3
+ *
4
+ * Scans the logs folder for unmerged checkpoint files (orphans).
5
+ * An orphan is a checkpoint whose session was never wrapped up via /wrapup.
6
+ *
7
+ * Output: JSON { orphan_count: N }
8
+ * Exit code always 0.
9
+ */
10
+
11
+ import { readFile, readdir } from 'node:fs/promises';
12
+ import { join } from 'node:path';
13
+ import { parse } from 'yaml';
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Types
17
+ // ---------------------------------------------------------------------------
18
+
19
+ export type OrphanScanResult = {
20
+ orphan_count: number;
21
+ };
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Frontmatter helpers
25
+ // ---------------------------------------------------------------------------
26
+
27
+ /**
28
+ * Extract YAML frontmatter from markdown text.
29
+ * Returns parsed object or null if no valid frontmatter.
30
+ */
31
+ function parseFrontmatter(rawText: string): Record<string, unknown> | null {
32
+ const text = rawText.replace(/\r\n/g, '\n');
33
+ if (!text.startsWith('---')) return null;
34
+ const endIdx = text.indexOf('\n---', 3);
35
+ if (endIdx === -1) return null;
36
+ const fm = text.slice(3, endIdx).trim();
37
+ try {
38
+ const parsed = parse(fm);
39
+ return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : null;
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Month directory helpers
47
+ // ---------------------------------------------------------------------------
48
+
49
+ /**
50
+ * Get current and previous month as { thisYear, thisMonth, prevYear, prevMonth }
51
+ * All values are zero-padded strings.
52
+ */
53
+ function getMonthParts(now: Date = new Date()): {
54
+ thisYear: string;
55
+ thisMonth: string;
56
+ prevYear: string;
57
+ prevMonth: string;
58
+ } {
59
+ const thisYear = String(now.getFullYear());
60
+ const thisMonth = String(now.getMonth() + 1).padStart(2, '0');
61
+
62
+ const prevDate = new Date(now.getFullYear(), now.getMonth() - 1, 1);
63
+ const prevYear = String(prevDate.getFullYear());
64
+ const prevMonth = String(prevDate.getMonth() + 1).padStart(2, '0');
65
+
66
+ return { thisYear, thisMonth, prevYear, prevMonth };
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // File listing helper
71
+ // ---------------------------------------------------------------------------
72
+
73
+ async function listMdFiles(dir: string): Promise<string[]> {
74
+ try {
75
+ const entries = await readdir(dir);
76
+ return entries.filter((e) => e.endsWith('.md'));
77
+ } catch {
78
+ return [];
79
+ }
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Core scan logic
84
+ // ---------------------------------------------------------------------------
85
+
86
+ /**
87
+ * Check whether a given date has a manually-run session log (non-auto-saved).
88
+ * Returns true if such a log exists.
89
+ */
90
+ async function hasManualSessionLog(monthDir: string, date: string): Promise<boolean> {
91
+ const files = await listMdFiles(monthDir);
92
+ const sessionLogs = files.filter(
93
+ (f) => f.startsWith(date) && !f.includes('-checkpoint-') && f.endsWith('.md'),
94
+ );
95
+
96
+ for (const logName of sessionLogs) {
97
+ try {
98
+ const content = await readFile(join(monthDir, logName), 'utf8');
99
+ const fm = parseFrontmatter(content);
100
+ // auto-saved: true → written by auto-summary, NOT a wrapup log → keep scanning
101
+ if (fm && (fm['auto-saved'] === true || fm['auto-saved'] === 'true')) continue;
102
+ // Either no frontmatter or auto-saved is false/absent → this is a manual wrapup log
103
+ return true;
104
+ } catch {
105
+ // Can't read — skip
106
+ }
107
+ }
108
+ return false;
109
+ }
110
+
111
+ /**
112
+ * Scan one month directory for orphan checkpoints.
113
+ * Returns a set of orphan session tokens (one per distinct session).
114
+ */
115
+ async function scanMonthDir(
116
+ monthDir: string,
117
+ currentToken: string,
118
+ today: string,
119
+ seenTokens: Set<string>,
120
+ ): Promise<number> {
121
+ const files = await listMdFiles(monthDir);
122
+ const checkpoints = files.filter((f) => f.includes('-checkpoint-') && f.endsWith('.md'));
123
+
124
+ let count = 0;
125
+
126
+ for (const fname of checkpoints) {
127
+ // Filename format: YYYY-MM-DD-{token}-checkpoint-{NN}.md
128
+ const dateMatch = fname.match(/^(\d{4}-\d{2}-\d{2})-/);
129
+ if (!dateMatch) continue;
130
+ const fdate = dateMatch[1] ?? '';
131
+
132
+ // Extract token: everything between date- prefix and -checkpoint-
133
+ const afterDate = fname.slice(fdate.length + 1); // strip "YYYY-MM-DD-"
134
+ const cpIdx = afterDate.indexOf('-checkpoint-');
135
+ if (cpIdx === -1) continue;
136
+ const ftoken = afterDate.slice(0, cpIdx);
137
+ if (!ftoken) continue;
138
+
139
+ // Skip today's checkpoints — not orphans yet
140
+ if (fdate === today) continue;
141
+
142
+ // Skip current session's own checkpoints
143
+ if (ftoken === currentToken) continue;
144
+
145
+ // Skip tokens already counted (dedup across multiple checkpoint files per session)
146
+ if (seenTokens.has(ftoken)) continue;
147
+
148
+ // Skip if already merged (read frontmatter)
149
+ try {
150
+ const content = await readFile(join(monthDir, fname), 'utf8');
151
+ const fm = parseFrontmatter(content);
152
+ if (fm && (fm.merged === true || fm.merged === 'true')) continue;
153
+ } catch {
154
+ // Can't read frontmatter — treat as unmerged orphan
155
+ }
156
+
157
+ // Skip if a manual session log covers this date
158
+ if (await hasManualSessionLog(monthDir, fdate)) continue;
159
+
160
+ // Orphan confirmed
161
+ seenTokens.add(ftoken);
162
+ count++;
163
+ }
164
+
165
+ return count;
166
+ }
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // runOrphanScan (testable core)
170
+ // ---------------------------------------------------------------------------
171
+
172
+ /**
173
+ * Core logic for orphan-scan.
174
+ * @param logsFolder - absolute path to logs folder
175
+ * @param sessionToken - current session token to exclude
176
+ * @returns OrphanScanResult
177
+ */
178
+ export async function runOrphanScan(
179
+ logsFolder: string,
180
+ sessionToken: string,
181
+ ): Promise<OrphanScanResult> {
182
+ const now = new Date();
183
+ const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
184
+ const { thisYear, thisMonth, prevYear, prevMonth } = getMonthParts();
185
+
186
+ const monthDirs: Array<{ year: string; month: string }> = [
187
+ { year: thisYear, month: thisMonth },
188
+ { year: prevYear, month: prevMonth },
189
+ ];
190
+
191
+ // Dedup tokens across both month dirs
192
+ const seenTokens = new Set<string>();
193
+ let totalOrphans = 0;
194
+
195
+ for (const { year, month } of monthDirs) {
196
+ const monthDir = join(logsFolder, year, month);
197
+ totalOrphans += await scanMonthDir(monthDir, sessionToken, today, seenTokens);
198
+ }
199
+
200
+ return { orphan_count: totalOrphans };
201
+ }
202
+
203
+ // ---------------------------------------------------------------------------
204
+ // CLI entry point
205
+ // ---------------------------------------------------------------------------
206
+
207
+ /**
208
+ * Run orphan-scan as a CLI command: print JSON to stdout, always exit 0.
209
+ */
210
+ export async function orphanScanCommand(logsFolder: string, sessionToken: string): Promise<void> {
211
+ const result = await runOrphanScan(logsFolder, sessionToken);
212
+ process.stdout.write(`${JSON.stringify(result)}\n`);
213
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * qmd-reindex.test.ts — tests for qmd-reindex command
3
+ */
4
+
5
+ import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
6
+ import { mkdir, rm, writeFile } from 'node:fs/promises';
7
+ import { tmpdir } from 'node:os';
8
+ import { join } from 'node:path';
9
+
10
+ import { qmdReindexCommand } from './qmd-reindex.js';
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Helpers
14
+ // ---------------------------------------------------------------------------
15
+
16
+ async function makeTmpDir(): Promise<string> {
17
+ const base = join(tmpdir(), `ob-qmd-test-${Math.random().toString(36).slice(2)}`);
18
+ await mkdir(base, { recursive: true });
19
+ return base;
20
+ }
21
+
22
+ const VAULT_YML_WITH_COLLECTION = `
23
+ method: onebrain
24
+ update_channel: stable
25
+ qmd_collection: test-collection-123
26
+ folders:
27
+ inbox: 00-inbox
28
+ logs: 07-logs
29
+ `.trim();
30
+
31
+ const VAULT_YML_WITHOUT_COLLECTION = `
32
+ method: onebrain
33
+ update_channel: stable
34
+ folders:
35
+ inbox: 00-inbox
36
+ logs: 07-logs
37
+ `.trim();
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Tests
41
+ // ---------------------------------------------------------------------------
42
+
43
+ describe('qmdReindexCommand', () => {
44
+ let tmpDir: string;
45
+
46
+ beforeEach(async () => {
47
+ tmpDir = await makeTmpDir();
48
+ });
49
+
50
+ afterEach(async () => {
51
+ await rm(tmpDir, { recursive: true, force: true });
52
+ });
53
+
54
+ it('does not spawn when qmd_collection is absent in vault.yml', async () => {
55
+ await writeFile(join(tmpDir, 'vault.yml'), VAULT_YML_WITHOUT_COLLECTION, 'utf8');
56
+
57
+ const spawnSpy = spyOn(Bun, 'spawn');
58
+
59
+ await qmdReindexCommand(tmpDir);
60
+
61
+ expect(spawnSpy).not.toHaveBeenCalled();
62
+
63
+ spawnSpy.mockRestore();
64
+ });
65
+
66
+ it('does not spawn and resolves without throwing when vault.yml is missing', async () => {
67
+ // No vault.yml in tmpDir
68
+ const spawnSpy = spyOn(Bun, 'spawn');
69
+
70
+ await expect(qmdReindexCommand(tmpDir)).resolves.toBeUndefined();
71
+ expect(spawnSpy).not.toHaveBeenCalled();
72
+
73
+ spawnSpy.mockRestore();
74
+ });
75
+
76
+ it('spawns qmd update -c <collection> with detached options and calls unref()', async () => {
77
+ await writeFile(join(tmpDir, 'vault.yml'), VAULT_YML_WITH_COLLECTION, 'utf8');
78
+
79
+ const mockUnref = { called: false };
80
+ const fakeProc = {
81
+ unref: () => {
82
+ mockUnref.called = true;
83
+ },
84
+ };
85
+
86
+ const spawnSpy = spyOn(Bun, 'spawn').mockImplementation(
87
+ () => fakeProc as unknown as ReturnType<typeof Bun.spawn>,
88
+ );
89
+
90
+ await qmdReindexCommand(tmpDir);
91
+
92
+ expect(spawnSpy).toHaveBeenCalledTimes(1);
93
+ const [cmd, spawnOpts] = spawnSpy.mock.calls[0] as [string[], Record<string, unknown>];
94
+ expect(cmd).toEqual(['qmd', 'update', '-c', 'test-collection-123']);
95
+ expect(spawnOpts).toMatchObject({
96
+ detached: true,
97
+ stdin: 'ignore',
98
+ stdout: 'ignore',
99
+ stderr: 'ignore',
100
+ });
101
+ expect(mockUnref.called).toBe(true);
102
+
103
+ spawnSpy.mockRestore();
104
+ });
105
+
106
+ it('resolves without throwing when Bun.spawn throws', async () => {
107
+ await writeFile(join(tmpDir, 'vault.yml'), VAULT_YML_WITH_COLLECTION, 'utf8');
108
+
109
+ const spawnSpy = spyOn(Bun, 'spawn').mockImplementation(() => {
110
+ throw new Error('spawn failed');
111
+ });
112
+
113
+ await expect(qmdReindexCommand(tmpDir)).resolves.toBeUndefined();
114
+
115
+ spawnSpy.mockRestore();
116
+ });
117
+ });
@@ -0,0 +1,44 @@
1
+ /**
2
+ * qmd-reindex — internal command
3
+ *
4
+ * Spawns a detached background process to run `qmd update -c <collection>`.
5
+ * The process runs asynchronously without blocking the CLI.
6
+ *
7
+ * Exit behavior:
8
+ * - Always exits 0 (fire-and-forget)
9
+ * - Errors written to stderr, exit code always 0
10
+ * - No stdout output
11
+ */
12
+
13
+ import { loadVaultConfig } from '@onebrain/core';
14
+
15
+ /**
16
+ * Run qmd-reindex as a CLI command.
17
+ * Spawns detached background process, always exits 0.
18
+ */
19
+ export async function qmdReindexCommand(vaultRoot: string): Promise<void> {
20
+ try {
21
+ // Load vault config
22
+ const config = await loadVaultConfig(vaultRoot);
23
+ const collection = config.qmd_collection;
24
+
25
+ // If qmd_collection not set, exit silently (no-op)
26
+ if (!collection) {
27
+ return;
28
+ }
29
+
30
+ // Spawn detached background process
31
+ const proc = Bun.spawn(['qmd', 'update', '-c', collection], {
32
+ detached: true,
33
+ stdin: 'ignore',
34
+ stdout: 'ignore',
35
+ stderr: 'ignore',
36
+ });
37
+ proc.unref(); // release parent reference — CLI exits immediately
38
+
39
+ // Fire-and-forget: do NOT call proc.exited or await anything
40
+ // Process runs in the background
41
+ } catch (err) {
42
+ process.stderr.write(`qmd-reindex: ${err instanceof Error ? err.message : String(err)}\n`);
43
+ }
44
+ }