@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.
- package/dist/onebrain +12656 -0
- package/package.json +23 -0
- package/src/commands/doctor.test.ts +416 -0
- package/src/commands/doctor.ts +203 -0
- package/src/commands/init.test.ts +318 -0
- package/src/commands/init.ts +477 -0
- package/src/commands/update.test.ts +413 -0
- package/src/commands/update.ts +353 -0
- package/src/index.ts +144 -0
- package/src/internal/__snapshots__/checkpoint.test.ts.snap +12 -0
- package/src/internal/__snapshots__/orphan-scan.test.ts.snap +13 -0
- package/src/internal/__snapshots__/session-init.test.ts.snap +15 -0
- package/src/internal/checkpoint.test.ts +741 -0
- package/src/internal/checkpoint.ts +427 -0
- package/src/internal/migrate.test.ts +301 -0
- package/src/internal/migrate.ts +186 -0
- package/src/internal/orphan-scan.test.ts +271 -0
- package/src/internal/orphan-scan.ts +213 -0
- package/src/internal/qmd-reindex.test.ts +117 -0
- package/src/internal/qmd-reindex.ts +44 -0
- package/src/internal/register-hooks.test.ts +343 -0
- package/src/internal/register-hooks.ts +418 -0
- package/src/internal/session-init.test.ts +318 -0
- package/src/internal/session-init.ts +264 -0
- package/src/internal/vault-sync.test.ts +419 -0
- package/src/internal/vault-sync.ts +764 -0
- package/tests/integration/init.integration.test.ts +304 -0
- package/tests/integration/update.integration.test.ts +306 -0
- package/tsconfig.json +12 -0
|
@@ -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
|
+
}
|