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