@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,301 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
2
+ import { chmodSync } from 'node:fs';
3
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+
7
+ import { runBackfillRecapped } from './migrate.js';
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Helpers
11
+ // ---------------------------------------------------------------------------
12
+
13
+ async function makeTmpDir(): Promise<string> {
14
+ return mkdtemp(join(tmpdir(), 'onebrain-test-migrate-'));
15
+ }
16
+
17
+ /**
18
+ * Make a month directory under logs folder
19
+ */
20
+ async function makeMonthDir(logsDir: string, year: string, month: string): Promise<string> {
21
+ const dir = join(logsDir, year, month);
22
+ await mkdir(dir, { recursive: true });
23
+ return dir;
24
+ }
25
+
26
+ /**
27
+ * Create a session log file with optional frontmatter
28
+ */
29
+ async function writeSessionLog(
30
+ dir: string,
31
+ filename: string,
32
+ frontmatter?: Record<string, unknown>,
33
+ ): Promise<void> {
34
+ let content = '';
35
+ if (frontmatter) {
36
+ content += '---\n';
37
+ for (const [key, value] of Object.entries(frontmatter)) {
38
+ if (typeof value === 'string') {
39
+ content += `${key}: ${value}\n`;
40
+ } else if (typeof value === 'boolean') {
41
+ content += `${key}: ${value}\n`;
42
+ } else {
43
+ content += `${key}: ${JSON.stringify(value)}\n`;
44
+ }
45
+ }
46
+ content += '---\n';
47
+ }
48
+ content += '\n## Session\n\nTest content\n';
49
+
50
+ await writeFile(join(dir, filename), content);
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Tests
55
+ // ---------------------------------------------------------------------------
56
+
57
+ describe('runBackfillRecapped', () => {
58
+ let tmpDir: string;
59
+ let logsDir: string;
60
+ const today = new Date().toISOString().slice(0, 10);
61
+
62
+ beforeEach(async () => {
63
+ tmpDir = await makeTmpDir();
64
+ logsDir = join(tmpDir, '07-logs');
65
+ await mkdir(logsDir, { recursive: true });
66
+ });
67
+
68
+ afterEach(async () => {
69
+ await rm(tmpDir, { recursive: true, force: true });
70
+ });
71
+
72
+ it('returns backfilled: 0, skipped: 0 for empty logs dir', async () => {
73
+ const result = await runBackfillRecapped(logsDir);
74
+ expect(result).toEqual({ backfilled: 0, skipped: 0 });
75
+ });
76
+
77
+ it('returns backfilled: 0, skipped: 0 when logs dir does not exist', async () => {
78
+ const nonexistent = join(tmpDir, 'nonexistent');
79
+ const result = await runBackfillRecapped(nonexistent);
80
+ expect(result).toEqual({ backfilled: 0, skipped: 0 });
81
+ });
82
+
83
+ it('skips checkpoint files (filename contains -checkpoint-)', async () => {
84
+ const monthDir = await makeMonthDir(logsDir, '2026', '04');
85
+ await writeSessionLog(monthDir, '2026-04-20-abc12345-checkpoint-01.md', {
86
+ tags: 'checkpoint',
87
+ checkpoint: '01',
88
+ recapped: false,
89
+ });
90
+
91
+ const result = await runBackfillRecapped(logsDir);
92
+ expect(result).toEqual({ backfilled: 0, skipped: 0 });
93
+ });
94
+
95
+ it('skips files that already have recapped field', async () => {
96
+ const monthDir = await makeMonthDir(logsDir, '2026', '04');
97
+ await writeSessionLog(monthDir, '2026-04-20-session-01.md', {
98
+ tags: 'session-log',
99
+ recapped: '2026-04-20',
100
+ });
101
+
102
+ const result = await runBackfillRecapped(logsDir);
103
+ expect(result).toEqual({ backfilled: 0, skipped: 0 });
104
+ });
105
+
106
+ it('skips session log with recapped: false (field present, falsy value)', async () => {
107
+ // recapped: false means "field exists" → !== undefined → skip
108
+ // A falsy check (!recapped) would incorrectly backfill this file
109
+ const monthDir = await makeMonthDir(logsDir, '2026', '04');
110
+ await writeSessionLog(monthDir, '2026-04-21-session-01.md', {
111
+ tags: 'session-log',
112
+ recapped: false,
113
+ });
114
+
115
+ const result = await runBackfillRecapped(logsDir);
116
+ expect(result).toEqual({ backfilled: 0, skipped: 0 });
117
+ });
118
+
119
+ it('adds recapped field to session log missing it', async () => {
120
+ const monthDir = await makeMonthDir(logsDir, '2026', '04');
121
+ await writeSessionLog(monthDir, '2026-04-20-session-01.md', {
122
+ tags: 'session-log',
123
+ date: '2026-04-20',
124
+ });
125
+
126
+ const result = await runBackfillRecapped(logsDir);
127
+ expect(result).toEqual({ backfilled: 1, skipped: 0 });
128
+
129
+ // Verify file was updated
130
+ const content = await Bun.file(join(monthDir, '2026-04-20-session-01.md')).text();
131
+ expect(content).toContain(`recapped: ${today}`);
132
+ });
133
+
134
+ it('skips files with malformed frontmatter', async () => {
135
+ const monthDir = await makeMonthDir(logsDir, '2026', '04');
136
+ // Missing closing ---
137
+ await writeFile(
138
+ join(monthDir, '2026-04-20-session-01.md'),
139
+ '---\ntags: session-log\ndate: 2026-04-20\n\n## Session\nContent\n',
140
+ );
141
+
142
+ const result = await runBackfillRecapped(logsDir);
143
+ expect(result.skipped).toBeGreaterThan(0);
144
+ });
145
+
146
+ it('processes multiple files in multiple months', async () => {
147
+ const monthDir1 = await makeMonthDir(logsDir, '2026', '04');
148
+ const monthDir2 = await makeMonthDir(logsDir, '2026', '03');
149
+
150
+ // File in April without recapped
151
+ await writeSessionLog(monthDir1, '2026-04-20-session-01.md', {
152
+ tags: 'session-log',
153
+ date: '2026-04-20',
154
+ });
155
+
156
+ // File in March without recapped
157
+ await writeSessionLog(monthDir2, '2026-03-15-session-01.md', {
158
+ tags: 'session-log',
159
+ date: '2026-03-15',
160
+ });
161
+
162
+ // File that already has recapped
163
+ await writeSessionLog(monthDir1, '2026-04-19-session-01.md', {
164
+ tags: 'session-log',
165
+ date: '2026-04-19',
166
+ recapped: '2026-04-19',
167
+ });
168
+
169
+ const result = await runBackfillRecapped(logsDir);
170
+ expect(result).toEqual({ backfilled: 2, skipped: 0 });
171
+ });
172
+
173
+ it('skips files without frontmatter', async () => {
174
+ const monthDir = await makeMonthDir(logsDir, '2026', '04');
175
+ // File with no frontmatter
176
+ await writeFile(join(monthDir, '2026-04-20-session-01.md'), '## Session\n\nContent\n');
177
+
178
+ const result = await runBackfillRecapped(logsDir);
179
+ expect(result).toEqual({ backfilled: 0, skipped: 1 });
180
+ });
181
+
182
+ it('does not treat ---something as a closing frontmatter delimiter', async () => {
183
+ const monthDir = await makeMonthDir(logsDir, '2026', '04');
184
+ // Body contains a line starting with ---foo which must NOT close the frontmatter.
185
+ // The real closing --- (bare, followed by newline) appears later.
186
+ const content = [
187
+ '---',
188
+ 'tags: session-log',
189
+ 'date: 2026-04-20',
190
+ '---',
191
+ '',
192
+ '## Session',
193
+ '',
194
+ '---some-separator',
195
+ '',
196
+ 'Content below separator.',
197
+ ].join('\n');
198
+ await writeFile(join(monthDir, '2026-04-20-session-01.md'), content);
199
+
200
+ const result = await runBackfillRecapped(logsDir);
201
+ // File has valid frontmatter (no recapped), should be backfilled
202
+ expect(result).toEqual({ backfilled: 1, skipped: 0 });
203
+
204
+ // Verify frontmatter was updated correctly without corrupting the body
205
+ const updated = await Bun.file(join(monthDir, '2026-04-20-session-01.md')).text();
206
+ expect(updated).toContain(`recapped: ${today}`);
207
+ expect(updated).toContain('---some-separator');
208
+ });
209
+
210
+ it('preserves existing frontmatter fields when adding recapped', async () => {
211
+ const monthDir = await makeMonthDir(logsDir, '2026', '04');
212
+ await writeSessionLog(monthDir, '2026-04-20-session-01.md', {
213
+ tags: 'session-log',
214
+ date: '2026-04-20',
215
+ foo: 'bar',
216
+ });
217
+
218
+ const result = await runBackfillRecapped(logsDir);
219
+ expect(result).toEqual({ backfilled: 1, skipped: 0 });
220
+
221
+ // Verify all fields are preserved
222
+ const content = await Bun.file(join(monthDir, '2026-04-20-session-01.md')).text();
223
+ expect(content).toContain('tags: session-log');
224
+ expect(content).toContain('date: 2026-04-20');
225
+ expect(content).toContain('foo: bar');
226
+ expect(content).toContain(`recapped: ${today}`);
227
+ });
228
+
229
+ it('writeFile failure (EACCES via chmodSync): skipped === 1, backfilled === 1 (other file writable)', async () => {
230
+ const monthDir = await makeMonthDir(logsDir, '2026', '04');
231
+
232
+ // File 1: readable, writable — will be backfilled
233
+ const _file1 = join(monthDir, '2026-04-20-session-01.md');
234
+ await writeSessionLog(monthDir, '2026-04-20-session-01.md', {
235
+ tags: 'session-log',
236
+ date: '2026-04-20',
237
+ });
238
+
239
+ // File 2: readable, but write-protected — will cause EACCES on writeFile
240
+ const file2 = join(monthDir, '2026-04-21-session-01.md');
241
+ await writeSessionLog(monthDir, '2026-04-21-session-01.md', {
242
+ tags: 'session-log',
243
+ date: '2026-04-21',
244
+ });
245
+ chmodSync(file2, 0o444);
246
+
247
+ try {
248
+ const result = await runBackfillRecapped(logsDir);
249
+ // One file successfully backfilled, one failed due to EACCES
250
+ expect(result.backfilled).toBe(1);
251
+ expect(result.skipped).toBe(1);
252
+ } finally {
253
+ // Restore permissions so cleanup works
254
+ chmodSync(file2, 0o644);
255
+ }
256
+ });
257
+
258
+ it('all files read-only: backfilled === 0, skipped === 2', async () => {
259
+ const monthDir = await makeMonthDir(logsDir, '2026', '04');
260
+
261
+ const file1 = join(monthDir, '2026-04-20-session-01.md');
262
+ const file2 = join(monthDir, '2026-04-21-session-01.md');
263
+
264
+ await writeSessionLog(monthDir, '2026-04-20-session-01.md', {
265
+ tags: 'session-log',
266
+ date: '2026-04-20',
267
+ });
268
+ await writeSessionLog(monthDir, '2026-04-21-session-01.md', {
269
+ tags: 'session-log',
270
+ date: '2026-04-21',
271
+ });
272
+
273
+ chmodSync(file1, 0o444);
274
+ chmodSync(file2, 0o444);
275
+
276
+ try {
277
+ const result = await runBackfillRecapped(logsDir);
278
+ expect(result.backfilled).toBe(0);
279
+ expect(result.skipped).toBe(2);
280
+ } finally {
281
+ chmodSync(file1, 0o644);
282
+ chmodSync(file2, 0o644);
283
+ }
284
+ });
285
+
286
+ it('handles idempotent re-runs: only first run backfills', async () => {
287
+ const monthDir = await makeMonthDir(logsDir, '2026', '04');
288
+ await writeSessionLog(monthDir, '2026-04-20-session-01.md', {
289
+ tags: 'session-log',
290
+ date: '2026-04-20',
291
+ });
292
+
293
+ // First run
294
+ const result1 = await runBackfillRecapped(logsDir);
295
+ expect(result1).toEqual({ backfilled: 1, skipped: 0 });
296
+
297
+ // Second run — should skip since recapped now exists
298
+ const result2 = await runBackfillRecapped(logsDir);
299
+ expect(result2).toEqual({ backfilled: 0, skipped: 0 });
300
+ });
301
+ });
@@ -0,0 +1,186 @@
1
+ /**
2
+ * migrate — internal command
3
+ *
4
+ * Runs one-time migration scripts to update vault state.
5
+ * Currently supports `backfill-recapped` migration.
6
+ *
7
+ * Output: plain text summary (not JSON)
8
+ * Exit code: always 0 (internal pattern)
9
+ */
10
+
11
+ import { readFile, readdir, writeFile } from 'node:fs/promises';
12
+ import { join } from 'node:path';
13
+ import { loadVaultConfig } from '@onebrain/core';
14
+ import { parse, stringify } from 'yaml';
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Types
18
+ // ---------------------------------------------------------------------------
19
+
20
+ export type MigrateResult = {
21
+ backfilled: number;
22
+ skipped: number;
23
+ };
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Frontmatter helpers
27
+ // ---------------------------------------------------------------------------
28
+
29
+ /**
30
+ * Extract YAML frontmatter from markdown text.
31
+ * Returns { frontmatter object, remainingText } or null if no valid frontmatter.
32
+ */
33
+ function parseFrontmatterWithRest(rawText: string): {
34
+ frontmatter: Record<string, unknown>;
35
+ rest: string;
36
+ } | null {
37
+ const text = rawText.replace(/\r\n/g, '\n');
38
+ if (!text.startsWith('---')) return null;
39
+
40
+ // Require closing --- to be followed by newline or end-of-string (bare --- line only).
41
+ // Rejects lines like ---foo or ---some-separator as false closers.
42
+ const endMatch = /\n---(\n|$)/.exec(text.slice(3));
43
+ if (!endMatch) return null;
44
+ const endIdx = 3 + endMatch.index;
45
+ const rest = text.slice(endIdx + endMatch[0].length);
46
+
47
+ const fmText = text.slice(3, endIdx).trim();
48
+
49
+ try {
50
+ const parsed = parse(fmText);
51
+ if (parsed && typeof parsed === 'object') {
52
+ return {
53
+ frontmatter: parsed as Record<string, unknown>,
54
+ rest,
55
+ };
56
+ }
57
+ return null;
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // File listing helper
65
+ // ---------------------------------------------------------------------------
66
+
67
+ async function listMdFiles(dir: string): Promise<string[]> {
68
+ try {
69
+ const entries = await readdir(dir);
70
+ return entries.filter((e) => e.endsWith('.md'));
71
+ } catch {
72
+ return [];
73
+ }
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // runBackfillRecapped (testable core)
78
+ // ---------------------------------------------------------------------------
79
+
80
+ /**
81
+ * Core logic for backfill-recapped migration.
82
+ * Scans session logs and adds `recapped: <today-date>` to frontmatter where missing.
83
+ *
84
+ * @param logsFolder - absolute path to logs folder
85
+ * @returns MigrateResult with backfilled and skipped counts
86
+ */
87
+ export async function runBackfillRecapped(logsFolder: string): Promise<MigrateResult> {
88
+ const today = new Date().toISOString().slice(0, 10);
89
+ let backfilled = 0;
90
+ let skipped = 0;
91
+
92
+ // List all year directories
93
+ let yearDirs: string[] = [];
94
+ try {
95
+ yearDirs = await readdir(logsFolder);
96
+ } catch {
97
+ // Logs folder doesn't exist; return empty result
98
+ return { backfilled: 0, skipped: 0 };
99
+ }
100
+
101
+ for (const yearDir of yearDirs) {
102
+ const yearPath = join(logsFolder, yearDir);
103
+
104
+ // List all month directories under year
105
+ let monthDirs: string[] = [];
106
+ try {
107
+ monthDirs = await readdir(yearPath);
108
+ } catch {
109
+ continue;
110
+ }
111
+
112
+ for (const monthDir of monthDirs) {
113
+ const monthPath = join(yearPath, monthDir);
114
+ const files = await listMdFiles(monthPath);
115
+
116
+ for (const fname of files) {
117
+ const fpath = join(monthPath, fname);
118
+
119
+ // Skip checkpoint files
120
+ if (fname.includes('-checkpoint-')) {
121
+ continue;
122
+ }
123
+
124
+ try {
125
+ const content = await readFile(fpath, 'utf8');
126
+ const parsed = parseFrontmatterWithRest(content);
127
+
128
+ if (!parsed) {
129
+ // No frontmatter or malformed
130
+ process.stderr.write(`migrate: ${fname} — malformed frontmatter\n`);
131
+ skipped++;
132
+ continue;
133
+ }
134
+
135
+ const { frontmatter, rest } = parsed;
136
+
137
+ // Skip if already has recapped
138
+ if (frontmatter.recapped !== undefined) {
139
+ continue;
140
+ }
141
+
142
+ // Add recapped field
143
+ frontmatter.recapped = today;
144
+
145
+ // Rebuild file with updated frontmatter
146
+ const updatedFm = stringify(frontmatter);
147
+ const updatedContent = `---\n${updatedFm}---\n${rest}`;
148
+
149
+ await writeFile(fpath, updatedContent, 'utf8');
150
+ backfilled++;
151
+ } catch (error) {
152
+ process.stderr.write(`migrate: error processing ${fname}: ${error}\n`);
153
+ skipped++;
154
+ }
155
+ }
156
+ }
157
+ }
158
+
159
+ return { backfilled, skipped };
160
+ }
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // CLI entry point
164
+ // ---------------------------------------------------------------------------
165
+
166
+ /**
167
+ * Run migrate as a CLI command.
168
+ * Currently supports 'backfill-recapped' migration.
169
+ * Always exits 0 (internal pattern).
170
+ */
171
+ export async function migrateCommand(migrationName: string): Promise<void> {
172
+ try {
173
+ const vaultRoot = process.cwd();
174
+ const config = await loadVaultConfig(vaultRoot);
175
+ const logsFolder = join(vaultRoot, config.folders.logs);
176
+
177
+ if (migrationName === 'backfill-recapped') {
178
+ const result = await runBackfillRecapped(logsFolder);
179
+ process.stdout.write(`backfilled: ${result.backfilled} files, skipped: ${result.skipped}\n`);
180
+ } else {
181
+ process.stderr.write(`migrate: unknown migration '${migrationName}'\n`);
182
+ }
183
+ } catch (error) {
184
+ process.stderr.write(`migrate: ${error}\n`);
185
+ }
186
+ }