@onebrain-ai/cli 2.0.1 → 2.0.3

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.
@@ -1,318 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
2
- import { mkdtemp, rm, stat, utimes, writeFile } from 'node:fs/promises';
3
- import { tmpdir } from 'node:os';
4
- import { join } from 'node:path';
5
-
6
- // We test the runSessionInit function which returns the payload rather than printing it,
7
- // so we can assert against the returned value in tests.
8
- import { formatDatetime, resolveSessionToken, runSessionInit } from './session-init.js';
9
-
10
- // ---------------------------------------------------------------------------
11
- // Helpers
12
- // ---------------------------------------------------------------------------
13
-
14
- async function makeTmpDir(): Promise<string> {
15
- return mkdtemp(join(tmpdir(), 'onebrain-si-test-'));
16
- }
17
-
18
- /** Set process.ppid (read-only property) for testing. Returns the original value. */
19
- function setPpid(value: number): number {
20
- const original = process.ppid;
21
- Object.defineProperty(process, 'ppid', { value, configurable: true });
22
- return original;
23
- }
24
-
25
- /** Restore process.ppid to its original value. */
26
- function restorePpid(original: number): void {
27
- Object.defineProperty(process, 'ppid', { value: original, configurable: true });
28
- }
29
-
30
- const VALID_VAULT_YML = `
31
- method: onebrain
32
- update_channel: stable
33
- folders:
34
- inbox: 00-inbox
35
- logs: 07-logs
36
- `.trim();
37
-
38
- const MALFORMED_YAML = `
39
- folders: [
40
- - broken: yaml
41
- `.trim();
42
-
43
- // ---------------------------------------------------------------------------
44
- // formatDatetime
45
- // ---------------------------------------------------------------------------
46
-
47
- describe('formatDatetime', () => {
48
- it('formats a date as "Ddd · DD Mon YYYY · HH:MM"', () => {
49
- // 2026-04-23 18:04 Thursday
50
- const d = new Date('2026-04-23T18:04:00');
51
- const result = formatDatetime(d);
52
- // Should match pattern like "Thu · 23 Apr 2026 · 18:04"
53
- expect(result).toMatch(/^[A-Z][a-z]{2} · \d{2} [A-Z][a-z]{2} \d{4} · \d{2}:\d{2}$/);
54
- });
55
-
56
- it('zero-pads day and hour', () => {
57
- // 2026-01-03 09:05 Saturday
58
- const d = new Date('2026-01-03T09:05:00');
59
- const result = formatDatetime(d);
60
- expect(result).toContain('· 03 Jan 2026 ·');
61
- expect(result).toContain('· 09:05');
62
- });
63
- });
64
-
65
- // ---------------------------------------------------------------------------
66
- // resolveSessionToken
67
- // ---------------------------------------------------------------------------
68
-
69
- describe('resolveSessionToken', () => {
70
- let originalEnv: NodeJS.ProcessEnv;
71
- let originalPpid: number;
72
- let tmpDir: string;
73
-
74
- beforeEach(async () => {
75
- originalEnv = { ...process.env };
76
- originalPpid = process.ppid;
77
- tmpDir = await makeTmpDir();
78
- });
79
-
80
- afterEach(async () => {
81
- process.env = originalEnv;
82
- restorePpid(originalPpid);
83
- await rm(tmpDir, { recursive: true, force: true });
84
- });
85
-
86
- it('uses PPID when > 1', async () => {
87
- process.env.WT_SESSION = undefined;
88
- setPpid(12345);
89
- const token = await resolveSessionToken(tmpDir);
90
- expect(token).toBe('12345');
91
- });
92
-
93
- it('ignores PPID when = 1', async () => {
94
- process.env.WT_SESSION = undefined;
95
- setPpid(1);
96
- // Should fall through to cache
97
- const token = await resolveSessionToken(tmpDir);
98
- // Token should be a 5-digit number or numeric string from cache
99
- expect(token).toMatch(/^\d+$/);
100
- });
101
-
102
- it('prefers WT_SESSION over PPID', async () => {
103
- process.env.WT_SESSION = 'abc-123-def-456-ghi';
104
- setPpid(99999);
105
- const token = await resolveSessionToken(tmpDir);
106
- // WT_SESSION stripped to alphanumeric, first 8 chars: 'abc123de'
107
- expect(token).toBe('abc123de');
108
- });
109
-
110
- it('strips non-alphanumeric from WT_SESSION and takes first 8 chars', async () => {
111
- process.env.WT_SESSION = '{a1b2c3d4-e5f6-7890-abcd-ef1234567890}';
112
- setPpid(1); // force PPID fallthrough if WT_SESSION somehow fails
113
- const token = await resolveSessionToken(tmpDir);
114
- expect(token).toBe('a1b2c3d4');
115
- expect(token.length).toBe(8);
116
- });
117
-
118
- it('reads cached token from day-scoped cache file', async () => {
119
- process.env.WT_SESSION = undefined;
120
- setPpid(1); // force fallthrough
121
- const today = new Date().toISOString().slice(0, 10).replace(/-/g, '');
122
- const cacheFile = join(tmpDir, `onebrain-day-${today}.token`);
123
- await writeFile(cacheFile, '54321', 'utf8');
124
- const token = await resolveSessionToken(tmpDir);
125
- expect(token).toBe('54321');
126
- });
127
-
128
- it('writes new cache file when none exists', async () => {
129
- process.env.WT_SESSION = undefined;
130
- setPpid(1); // force fallthrough
131
- const token = await resolveSessionToken(tmpDir);
132
- // Should be a 5-digit number
133
- expect(token).toMatch(/^\d{5}$/);
134
- // Cache file should exist
135
- const today = new Date().toISOString().slice(0, 10).replace(/-/g, '');
136
- const cacheFile = join(tmpDir, `onebrain-day-${today}.token`);
137
- const cached = await Bun.file(cacheFile).text();
138
- expect(cached.trim()).toBe(token);
139
- });
140
- });
141
-
142
- // ---------------------------------------------------------------------------
143
- // runSessionInit
144
- // ---------------------------------------------------------------------------
145
-
146
- describe('runSessionInit', () => {
147
- let tmpDir: string;
148
- let originalEnv: NodeJS.ProcessEnv;
149
- let originalPpid: number;
150
-
151
- beforeEach(async () => {
152
- originalEnv = { ...process.env };
153
- originalPpid = process.ppid;
154
- tmpDir = await makeTmpDir();
155
- // Ensure predictable token resolution in most tests
156
- process.env.WT_SESSION = undefined;
157
- setPpid(77777);
158
- });
159
-
160
- afterEach(async () => {
161
- process.env = originalEnv;
162
- restorePpid(originalPpid);
163
- await rm(tmpDir, { recursive: true, force: true });
164
- });
165
-
166
- it('returns block decision when vault.yml is missing', async () => {
167
- const result = await runSessionInit(tmpDir, tmpDir);
168
- expect(result).toEqual({ decision: 'block', reason: 'onebrain-init-required' });
169
- });
170
-
171
- it('returns block decision when vault.yml is malformed YAML', async () => {
172
- await writeFile(join(tmpDir, 'vault.yml'), MALFORMED_YAML, 'utf8');
173
- const result = await runSessionInit(tmpDir, tmpDir);
174
- expect(result).toEqual({ decision: 'block', reason: 'onebrain-init-required' });
175
- });
176
-
177
- it('returns normal payload when vault.yml is present and valid', async () => {
178
- await writeFile(join(tmpDir, 'vault.yml'), VALID_VAULT_YML, 'utf8');
179
- const result = await runSessionInit(tmpDir, tmpDir);
180
- expect(result).toHaveProperty('datetime');
181
- expect(result).toHaveProperty('session_token');
182
- expect(result).toHaveProperty('qmd_unembedded', 0);
183
- // Datetime format check
184
- expect((result as Record<string, unknown>).datetime).toMatch(
185
- /^[A-Z][a-z]{2} · \d{2} [A-Z][a-z]{2} \d{4} · \d{2}:\d{2}$/,
186
- );
187
- });
188
-
189
- it('uses PPID as session_token', async () => {
190
- await writeFile(join(tmpDir, 'vault.yml'), VALID_VAULT_YML, 'utf8');
191
- setPpid(42001);
192
- const result = (await runSessionInit(tmpDir, tmpDir)) as Record<string, unknown>;
193
- expect(result.session_token).toBe('42001');
194
- });
195
-
196
- it('qmd_unembedded is 0 when qmd is not in PATH / errors', async () => {
197
- await writeFile(join(tmpDir, 'vault.yml'), VALID_VAULT_YML, 'utf8');
198
- const result = (await runSessionInit(tmpDir, tmpDir)) as Record<string, unknown>;
199
- // qmd is not expected to be installed in test env
200
- expect(result.qmd_unembedded).toBe(0);
201
- });
202
-
203
- // Update snapshots: bun test --update-snapshots
204
- it('normal payload output shape matches snapshot', async () => {
205
- await writeFile(join(tmpDir, 'vault.yml'), VALID_VAULT_YML, 'utf8');
206
- setPpid(55555);
207
- const result = (await runSessionInit(tmpDir, tmpDir)) as Record<string, unknown>;
208
-
209
- // Lock the exact field names of the SessionInitPayload shape.
210
- // The values are dynamic (datetime, session_token vary), so we assert structure only.
211
- expect(Object.keys(result).sort()).toMatchSnapshot();
212
- expect(typeof result.datetime).toMatchSnapshot();
213
- expect(typeof result.session_token).toMatchSnapshot();
214
- expect(typeof result.qmd_unembedded).toMatchSnapshot();
215
- });
216
-
217
- it('cleanStaleStateFile — no state file → resolves normally with payload', async () => {
218
- await writeFile(join(tmpDir, 'vault.yml'), VALID_VAULT_YML, 'utf8');
219
- // No state file exists in tmpDir
220
- const result = await runSessionInit(tmpDir, tmpDir);
221
- expect(result).toHaveProperty('datetime');
222
- expect(result).toHaveProperty('session_token');
223
- });
224
-
225
- it('cleanStaleStateFile — fresh mtime → file NOT deleted (still exists after stat)', async () => {
226
- await writeFile(join(tmpDir, 'vault.yml'), VALID_VAULT_YML, 'utf8');
227
- // Create a state file with a fresh mtime (just written = after process start)
228
- const stateFile = join(tmpDir, 'onebrain-77777.state');
229
- await writeFile(stateFile, '1:0:00', 'utf8');
230
- // Fresh file should NOT be deleted
231
- await runSessionInit(tmpDir, tmpDir);
232
- // File should still exist (mtime is fresh, after process start)
233
- const s = await stat(stateFile);
234
- expect(s).toBeDefined();
235
- });
236
-
237
- it('cleanStaleStateFile — stale mtime (utimes sets to epoch 0) → file deleted', async () => {
238
- await writeFile(join(tmpDir, 'vault.yml'), VALID_VAULT_YML, 'utf8');
239
- const stateFile = join(tmpDir, 'onebrain-77777.state');
240
- await writeFile(stateFile, '1:0:00', 'utf8');
241
- // Set mtime to epoch (far in the past) — definitely before process start
242
- await utimes(stateFile, 0, 0);
243
- // Run session init — stale file should be cleaned up
244
- await runSessionInit(tmpDir, tmpDir);
245
- // File should be gone
246
- await expect(stat(stateFile)).rejects.toThrow();
247
- });
248
-
249
- it('cleanStaleStateFile — EACCES from Bun.file().stat() → caught silently, result still has datetime', async () => {
250
- await writeFile(join(tmpDir, 'vault.yml'), VALID_VAULT_YML, 'utf8');
251
- // Spy on Bun.file to simulate stat() throwing EACCES
252
- const originalBunFile = Bun.file.bind(Bun);
253
- const bunFileSpy = spyOn(Bun, 'file').mockImplementation((path: unknown) => {
254
- const f = originalBunFile(path as string);
255
- if (typeof path === 'string' && path.endsWith('.state')) {
256
- return {
257
- ...f,
258
- exists: async () => true,
259
- stat: async () => {
260
- const err = new Error('Permission denied') as NodeJS.ErrnoException;
261
- err.code = 'EACCES';
262
- throw err;
263
- },
264
- text: f.text.bind(f),
265
- } as unknown as ReturnType<typeof Bun.file>;
266
- }
267
- return f;
268
- });
269
-
270
- try {
271
- const result = await runSessionInit(tmpDir, tmpDir);
272
- expect(result).toHaveProperty('datetime');
273
- } finally {
274
- bunFileSpy.mockRestore();
275
- }
276
- });
277
-
278
- it('qmd_unembedded reflects unembedded count from qmd status --json', async () => {
279
- await writeFile(join(tmpDir, 'vault.yml'), VALID_VAULT_YML, 'utf8');
280
-
281
- const qmdJson = JSON.stringify({ unembedded: 5 });
282
- const encoded = new TextEncoder().encode(qmdJson);
283
-
284
- // Build a minimal fake subprocess: exited resolves to 0, stdout is readable
285
- const fakeStdout = new ReadableStream<Uint8Array>({
286
- start(controller) {
287
- controller.enqueue(encoded);
288
- controller.close();
289
- },
290
- });
291
-
292
- const fakeProc = {
293
- exited: Promise.resolve(0),
294
- stdout: fakeStdout,
295
- stderr: new ReadableStream({
296
- start(c) {
297
- c.close();
298
- },
299
- }),
300
- kill: () => {},
301
- };
302
-
303
- const spawnSpy = spyOn(Bun, 'spawn').mockImplementation((cmd: unknown) => {
304
- if (Array.isArray(cmd) && cmd[0] === 'qmd') {
305
- return fakeProc as unknown as ReturnType<typeof Bun.spawn>;
306
- }
307
- // PowerShell or other spawns — throw so they fall through gracefully
308
- throw new Error('not on Windows');
309
- });
310
-
311
- try {
312
- const result = (await runSessionInit(tmpDir, tmpDir)) as Record<string, unknown>;
313
- expect(result.qmd_unembedded).toBe(5);
314
- } finally {
315
- spawnSpy.mockRestore();
316
- }
317
- });
318
- });
@@ -1,264 +0,0 @@
1
- /**
2
- * session-init — internal command
3
- *
4
- * Outputs JSON to stdout with session token, datetime, and qmd_unembedded.
5
- * If vault.yml is missing or invalid, outputs a block decision JSON.
6
- *
7
- * Exit code is always 0 (never crashes Claude Code).
8
- */
9
-
10
- import { unlink } from 'node:fs/promises';
11
- import { tmpdir as osTmpdir } from 'node:os';
12
- import { join } from 'node:path';
13
- import { loadVaultConfig } from '@onebrain/core';
14
-
15
- // ---------------------------------------------------------------------------
16
- // Types
17
- // ---------------------------------------------------------------------------
18
-
19
- export type SessionInitPayload = {
20
- datetime: string;
21
- session_token: string;
22
- qmd_unembedded: number;
23
- };
24
-
25
- export type SessionInitBlock = {
26
- decision: 'block';
27
- reason: 'onebrain-init-required';
28
- };
29
-
30
- export type SessionInitResult = SessionInitPayload | SessionInitBlock;
31
-
32
- // ---------------------------------------------------------------------------
33
- // formatDatetime
34
- // ---------------------------------------------------------------------------
35
-
36
- const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
37
- const MONTH_NAMES = [
38
- 'Jan',
39
- 'Feb',
40
- 'Mar',
41
- 'Apr',
42
- 'May',
43
- 'Jun',
44
- 'Jul',
45
- 'Aug',
46
- 'Sep',
47
- 'Oct',
48
- 'Nov',
49
- 'Dec',
50
- ];
51
-
52
- /**
53
- * Format a Date as "Ddd · DD Mon YYYY · HH:MM" (24h, zero-padded).
54
- * Example: "Thu · 23 Apr 2026 · 18:04"
55
- */
56
- export function formatDatetime(date: Date): string {
57
- const dow = DAY_NAMES[date.getDay()];
58
- const day = String(date.getDate()).padStart(2, '0');
59
- const mon = MONTH_NAMES[date.getMonth()];
60
- const year = date.getFullYear();
61
- const hh = String(date.getHours()).padStart(2, '0');
62
- const mm = String(date.getMinutes()).padStart(2, '0');
63
- return `${dow} · ${day} ${mon} ${year} · ${hh}:${mm}`;
64
- }
65
-
66
- // ---------------------------------------------------------------------------
67
- // resolveSessionToken
68
- // ---------------------------------------------------------------------------
69
-
70
- /**
71
- * Resolve session token using priority order:
72
- * 1. WT_SESSION env var (strip non-alphanumeric, first 8 chars)
73
- * 2. process.ppid if > 1
74
- * 3. PowerShell parent PID (Windows fallback — not tested on Mac)
75
- * 4. Day-scoped cache file: $tmpDir/onebrain-day-YYYYMMDD.token
76
- */
77
- export async function resolveSessionToken(tmpDir: string = osTmpdir()): Promise<string> {
78
- // 1. WT_SESSION
79
- const wtSession = process.env.WT_SESSION;
80
- if (wtSession) {
81
- const stripped = wtSession.replace(/[^a-zA-Z0-9]/g, '').slice(0, 8);
82
- if (stripped.length > 0) return stripped;
83
- }
84
-
85
- // 2. PPID
86
- const ppid = process.ppid;
87
- if (ppid !== undefined && ppid > 1) return String(ppid);
88
-
89
- // 3. PowerShell fallback (Windows only)
90
- try {
91
- const ps = Bun.spawn(
92
- [
93
- 'powershell.exe',
94
- '-NoProfile',
95
- '-NonInteractive',
96
- '-Command',
97
- '(Get-Process -Id $PID).Parent.Id',
98
- ],
99
- {
100
- stdout: 'pipe',
101
- stderr: 'pipe',
102
- },
103
- );
104
- const timeoutMs = 3000;
105
- let timerId: ReturnType<typeof setTimeout> | undefined;
106
- const race = await Promise.race([
107
- ps.exited,
108
- new Promise<'timeout'>((resolve) => {
109
- timerId = setTimeout(() => resolve('timeout'), timeoutMs);
110
- }),
111
- ]);
112
- if (timerId !== undefined) clearTimeout(timerId);
113
- if (race !== 'timeout') {
114
- const out = (await new Response(ps.stdout).text()).replace(/\D/g, '').trim();
115
- if (out && Number(out) > 1) return out;
116
- } else {
117
- ps.kill();
118
- }
119
- } catch {
120
- // Not on Windows or powershell.exe not available — fall through
121
- }
122
-
123
- // 4. Day-scoped cache
124
- const today = new Date();
125
- const yyyymmdd = [
126
- today.getFullYear(),
127
- String(today.getMonth() + 1).padStart(2, '0'),
128
- String(today.getDate()).padStart(2, '0'),
129
- ].join('');
130
- const cacheFile = join(tmpDir, `onebrain-day-${yyyymmdd}.token`);
131
-
132
- const f = Bun.file(cacheFile);
133
- const exists = await f.exists();
134
- if (exists) {
135
- const cached = (await f.text()).trim();
136
- const n = Number(cached);
137
- if (!Number.isNaN(n) && n > 1) return cached;
138
- }
139
-
140
- // Generate and cache a random 5-digit token (10000–99999)
141
- const token = String(Math.floor(Math.random() * 90000) + 10000);
142
- await Bun.write(cacheFile, token);
143
- return token;
144
- }
145
-
146
- // ---------------------------------------------------------------------------
147
- // cleanStaleStateFile
148
- // ---------------------------------------------------------------------------
149
-
150
- /**
151
- * Delete $tmpDir/onebrain-{token}.state if it exists and its mtime is before
152
- * this process started.
153
- */
154
- async function cleanStaleStateFile(token: string, tmpDir: string): Promise<void> {
155
- try {
156
- // Approximate process start time
157
- const processStartMs = Date.now() - performance.now();
158
- const stateFile = join(tmpDir, `onebrain-${token}.state`);
159
- const f = Bun.file(stateFile);
160
- const exists = await f.exists();
161
- if (!exists) return;
162
-
163
- const { mtime } = await f.stat();
164
- const mtimeMs = mtime instanceof Date ? mtime.getTime() : Number(mtime) * 1000;
165
- if (mtimeMs < processStartMs) {
166
- try {
167
- await unlink(stateFile);
168
- } catch {
169
- // Already deleted or never existed — non-fatal
170
- }
171
- }
172
- } catch {
173
- // Non-fatal — stale cleanup is best-effort
174
- }
175
- }
176
-
177
- // ---------------------------------------------------------------------------
178
- // queryQmdUnembedded
179
- // ---------------------------------------------------------------------------
180
-
181
- /**
182
- * Spawn `qmd status --json` and extract the `unembedded` count.
183
- * Returns 0 on any error, timeout, or missing binary.
184
- */
185
- async function queryQmdUnembedded(): Promise<number> {
186
- try {
187
- const proc = Bun.spawn(['qmd', 'status', '--json'], {
188
- stdout: 'pipe',
189
- stderr: 'pipe',
190
- });
191
-
192
- const timeoutMs = 2000;
193
- let timerId: ReturnType<typeof setTimeout> | undefined;
194
- const race = await Promise.race([
195
- proc.exited,
196
- new Promise<'timeout'>((resolve) => {
197
- timerId = setTimeout(() => resolve('timeout'), timeoutMs);
198
- }),
199
- ]);
200
- if (timerId !== undefined) clearTimeout(timerId);
201
-
202
- if (race === 'timeout') {
203
- proc.kill();
204
- return 0;
205
- }
206
-
207
- const stdout = await new Response(proc.stdout).text();
208
- const parsed = JSON.parse(stdout) as Record<string, unknown>;
209
- const unembedded = parsed.unembedded;
210
- return typeof unembedded === 'number' ? unembedded : 0;
211
- } catch {
212
- return 0;
213
- }
214
- }
215
-
216
- // ---------------------------------------------------------------------------
217
- // runSessionInit (testable core)
218
- // ---------------------------------------------------------------------------
219
-
220
- /**
221
- * Core logic for session-init.
222
- * @param vaultRoot - root of the vault (where vault.yml lives)
223
- * @param tmpDir - override tmpdir (for tests)
224
- * @returns SessionInitResult
225
- */
226
- export async function runSessionInit(
227
- vaultRoot: string,
228
- tmpDir: string = osTmpdir(),
229
- ): Promise<SessionInitResult> {
230
- // Validate vault.yml — block if missing or malformed
231
- try {
232
- await loadVaultConfig(vaultRoot);
233
- } catch {
234
- return { decision: 'block', reason: 'onebrain-init-required' };
235
- }
236
-
237
- // Resolve session token and clean up stale state
238
- const sessionToken = await resolveSessionToken(tmpDir);
239
- await cleanStaleStateFile(sessionToken, tmpDir);
240
-
241
- // Format datetime
242
- const datetime = formatDatetime(new Date());
243
-
244
- // Query qmd
245
- const qmdUnembedded = await queryQmdUnembedded();
246
-
247
- return {
248
- datetime,
249
- session_token: sessionToken,
250
- qmd_unembedded: qmdUnembedded,
251
- };
252
- }
253
-
254
- // ---------------------------------------------------------------------------
255
- // CLI entry point
256
- // ---------------------------------------------------------------------------
257
-
258
- /**
259
- * Run session-init as a CLI command: print JSON to stdout, always exit 0.
260
- */
261
- export async function sessionInitCommand(vaultRoot: string): Promise<void> {
262
- const result = await runSessionInit(vaultRoot);
263
- process.stdout.write(`${JSON.stringify(result)}\n`);
264
- }