@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,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for `initCommand` (the CLI entry point that wraps runInit).
|
|
3
|
+
*
|
|
4
|
+
* These tests exercise the full init flow end-to-end through initCommand(), which
|
|
5
|
+
* calls runInit() and then process.exit(). All network calls (vault-sync, register-hooks)
|
|
6
|
+
* are injected as mocks so tests run offline and fast.
|
|
7
|
+
*
|
|
8
|
+
* Note: initCommand() calls process.exit(1) on failure. We test runInit() directly for
|
|
9
|
+
* error cases where the exit would abort the test process.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
13
|
+
import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
|
|
17
|
+
import { type InitOptions, runInit } from '../../src/commands/init.js';
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Helpers
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
async function makeTempVault(): Promise<string> {
|
|
24
|
+
const dir = join(
|
|
25
|
+
tmpdir(),
|
|
26
|
+
`onebrain-init-int-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
27
|
+
);
|
|
28
|
+
await mkdir(dir, { recursive: true });
|
|
29
|
+
return dir;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function fileExists(path: string): Promise<boolean> {
|
|
33
|
+
try {
|
|
34
|
+
await stat(path);
|
|
35
|
+
return true;
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function readVaultYml(vaultDir: string): Promise<Record<string, unknown>> {
|
|
42
|
+
const { parse } = await import('yaml');
|
|
43
|
+
const text = await readFile(join(vaultDir, 'vault.yml'), 'utf8');
|
|
44
|
+
return (parse(text) ?? {}) as Record<string, unknown>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const noopVaultSync = async (_vaultDir: string, _opts: Record<string, unknown>) => {};
|
|
48
|
+
const noopRegisterHooks = async (_vaultDir: string) => {};
|
|
49
|
+
|
|
50
|
+
const STANDARD_FOLDERS = [
|
|
51
|
+
'00-inbox',
|
|
52
|
+
'01-projects',
|
|
53
|
+
'02-areas',
|
|
54
|
+
'03-knowledge',
|
|
55
|
+
'04-resources',
|
|
56
|
+
'05-agent',
|
|
57
|
+
'06-archive',
|
|
58
|
+
'07-logs',
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
let tempDir: string;
|
|
62
|
+
|
|
63
|
+
beforeEach(async () => {
|
|
64
|
+
tempDir = await makeTempVault();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterEach(async () => {
|
|
68
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Scenario 1: Fresh vault (non-TTY) — all 7 folders created, vault.yml with folders: section
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
describe('init integration: fresh vault (non-TTY)', () => {
|
|
76
|
+
it('creates all 7 standard folders plus inbox/imports sub-directory', async () => {
|
|
77
|
+
const opts: InitOptions = {
|
|
78
|
+
vaultDir: tempDir,
|
|
79
|
+
isTTY: false,
|
|
80
|
+
vaultSyncFn: noopVaultSync,
|
|
81
|
+
registerHooksFn: noopRegisterHooks,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const result = await runInit(opts);
|
|
85
|
+
|
|
86
|
+
expect(result.ok).toBe(true);
|
|
87
|
+
expect(result.exitCode).toBe(0);
|
|
88
|
+
|
|
89
|
+
for (const folder of STANDARD_FOLDERS) {
|
|
90
|
+
expect(await fileExists(join(tempDir, folder))).toBe(true);
|
|
91
|
+
}
|
|
92
|
+
expect(await fileExists(join(tempDir, '00-inbox', 'imports'))).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('vault.yml exists with required top-level fields and folders: section', async () => {
|
|
96
|
+
const opts: InitOptions = {
|
|
97
|
+
vaultDir: tempDir,
|
|
98
|
+
isTTY: false,
|
|
99
|
+
vaultSyncFn: noopVaultSync,
|
|
100
|
+
registerHooksFn: noopRegisterHooks,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const result = await runInit(opts);
|
|
104
|
+
expect(result.ok).toBe(true);
|
|
105
|
+
|
|
106
|
+
expect(await fileExists(join(tempDir, 'vault.yml'))).toBe(true);
|
|
107
|
+
|
|
108
|
+
const parsed = await readVaultYml(tempDir);
|
|
109
|
+
expect(parsed).toHaveProperty('folders');
|
|
110
|
+
expect(typeof parsed.folders).toBe('object');
|
|
111
|
+
// Verify all expected folder keys are present
|
|
112
|
+
const folders = parsed.folders as Record<string, string>;
|
|
113
|
+
expect(folders.inbox).toBe('00-inbox');
|
|
114
|
+
expect(folders.logs).toBe('07-logs');
|
|
115
|
+
expect(parsed.method).toBe('onebrain');
|
|
116
|
+
expect(parsed.update_channel).toBe('stable');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('command completes without throwing, result.ok is true', async () => {
|
|
120
|
+
const opts: InitOptions = {
|
|
121
|
+
vaultDir: tempDir,
|
|
122
|
+
isTTY: false,
|
|
123
|
+
vaultSyncFn: noopVaultSync,
|
|
124
|
+
registerHooksFn: noopRegisterHooks,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// Should not throw
|
|
128
|
+
await expect(runInit(opts)).resolves.toMatchObject({ ok: true, exitCode: 0 });
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// Scenario 2: Existing vault.yml + non-TTY + no --force → exit 1 with error
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
describe('init integration: existing vault.yml, no --force (non-TTY)', () => {
|
|
137
|
+
it('returns exitCode 1 and error message containing vault.yml and --force', async () => {
|
|
138
|
+
await writeFile(join(tempDir, 'vault.yml'), 'method: onebrain\n', 'utf8');
|
|
139
|
+
|
|
140
|
+
const opts: InitOptions = {
|
|
141
|
+
vaultDir: tempDir,
|
|
142
|
+
isTTY: false,
|
|
143
|
+
vaultSyncFn: noopVaultSync,
|
|
144
|
+
registerHooksFn: noopRegisterHooks,
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const result = await runInit(opts);
|
|
148
|
+
|
|
149
|
+
expect(result.ok).toBe(false);
|
|
150
|
+
expect(result.exitCode).toBe(1);
|
|
151
|
+
expect(result.message).toBeDefined();
|
|
152
|
+
expect(result.message).toMatch(/vault\.yml/);
|
|
153
|
+
expect(result.message).toMatch(/--force/);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('does not create folders or overwrite vault.yml when returning early', async () => {
|
|
157
|
+
const originalContent = 'method: legacy\n';
|
|
158
|
+
await writeFile(join(tempDir, 'vault.yml'), originalContent, 'utf8');
|
|
159
|
+
|
|
160
|
+
const opts: InitOptions = {
|
|
161
|
+
vaultDir: tempDir,
|
|
162
|
+
isTTY: false,
|
|
163
|
+
vaultSyncFn: noopVaultSync,
|
|
164
|
+
registerHooksFn: noopRegisterHooks,
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
await runInit(opts);
|
|
168
|
+
|
|
169
|
+
// vault.yml should be unchanged
|
|
170
|
+
const content = await readFile(join(tempDir, 'vault.yml'), 'utf8');
|
|
171
|
+
expect(content).toBe(originalContent);
|
|
172
|
+
|
|
173
|
+
// Folders should NOT have been created (early return before folder step)
|
|
174
|
+
expect(await fileExists(join(tempDir, '00-inbox'))).toBe(false);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// Scenario 3: Plugin files present → skip vault-sync download
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
describe('init integration: plugin files present (skip vault-sync)', () => {
|
|
183
|
+
it('skips vault-sync when .claude/plugins/onebrain/plugin.json already exists', async () => {
|
|
184
|
+
// Pre-create plugin.json to simulate existing plugin files
|
|
185
|
+
const pluginMetaDir = join(tempDir, '.claude', 'plugins', 'onebrain', '.claude-plugin');
|
|
186
|
+
await mkdir(pluginMetaDir, { recursive: true });
|
|
187
|
+
await writeFile(
|
|
188
|
+
join(pluginMetaDir, 'plugin.json'),
|
|
189
|
+
JSON.stringify({ version: '1.11.0' }),
|
|
190
|
+
'utf8',
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
let vaultSyncCallCount = 0;
|
|
194
|
+
const trackingVaultSync = async (_vaultDir: string, _opts: Record<string, unknown>) => {
|
|
195
|
+
vaultSyncCallCount++;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const opts: InitOptions = {
|
|
199
|
+
vaultDir: tempDir,
|
|
200
|
+
isTTY: false,
|
|
201
|
+
force: true,
|
|
202
|
+
vaultSyncFn: trackingVaultSync,
|
|
203
|
+
registerHooksFn: noopRegisterHooks,
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const result = await runInit(opts);
|
|
207
|
+
|
|
208
|
+
expect(result.ok).toBe(true);
|
|
209
|
+
expect(result.pluginSkipped).toBe(true);
|
|
210
|
+
// vault-sync was NOT called because plugin files already exist
|
|
211
|
+
expect(vaultSyncCallCount).toBe(0);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('still creates folders and vault.yml even when vault-sync is skipped', async () => {
|
|
215
|
+
const pluginDir = join(tempDir, '.claude', 'plugins', 'onebrain');
|
|
216
|
+
await mkdir(pluginDir, { recursive: true });
|
|
217
|
+
await writeFile(join(pluginDir, 'plugin.json'), JSON.stringify({ version: '1.11.0' }), 'utf8');
|
|
218
|
+
|
|
219
|
+
// Pre-create vault.yml so --force is needed
|
|
220
|
+
await writeFile(join(tempDir, 'vault.yml'), 'method: legacy\n', 'utf8');
|
|
221
|
+
|
|
222
|
+
const opts: InitOptions = {
|
|
223
|
+
vaultDir: tempDir,
|
|
224
|
+
isTTY: false,
|
|
225
|
+
force: true,
|
|
226
|
+
vaultSyncFn: noopVaultSync,
|
|
227
|
+
registerHooksFn: noopRegisterHooks,
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const result = await runInit(opts);
|
|
231
|
+
|
|
232
|
+
expect(result.ok).toBe(true);
|
|
233
|
+
|
|
234
|
+
// Folders created
|
|
235
|
+
for (const folder of STANDARD_FOLDERS) {
|
|
236
|
+
expect(await fileExists(join(tempDir, folder))).toBe(true);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// vault.yml updated (method now onebrain, not legacy)
|
|
240
|
+
const parsed = await readVaultYml(tempDir);
|
|
241
|
+
expect(parsed.method).toBe('onebrain');
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
// Scenario 4: marketplace source in installed_plugins.json → skip registration
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
describe('init integration: marketplace source → skip plugin registration', () => {
|
|
250
|
+
it('skips plugin registration when installed_plugins.json has source: marketplace', async () => {
|
|
251
|
+
// Set up fake installed_plugins.json with marketplace entry
|
|
252
|
+
const pluginsMetaDir = join(tempDir, '.claude-meta');
|
|
253
|
+
await mkdir(pluginsMetaDir, { recursive: true });
|
|
254
|
+
const installedPluginsPath = join(pluginsMetaDir, 'installed_plugins.json');
|
|
255
|
+
await writeFile(
|
|
256
|
+
installedPluginsPath,
|
|
257
|
+
JSON.stringify({
|
|
258
|
+
plugins: {
|
|
259
|
+
'onebrain@1.0.0': [{ source: 'marketplace', installPath: '/some/marketplace/path' }],
|
|
260
|
+
},
|
|
261
|
+
}),
|
|
262
|
+
'utf8',
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
const opts: InitOptions = {
|
|
266
|
+
vaultDir: tempDir,
|
|
267
|
+
isTTY: false,
|
|
268
|
+
installedPluginsPath,
|
|
269
|
+
vaultSyncFn: noopVaultSync,
|
|
270
|
+
registerHooksFn: noopRegisterHooks,
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const result = await runInit(opts);
|
|
274
|
+
|
|
275
|
+
expect(result.ok).toBe(true);
|
|
276
|
+
expect(result.pluginRegistrationSkipped).toBe(true);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('does not crash or exit non-zero when marketplace entry present', async () => {
|
|
280
|
+
const pluginsMetaDir = join(tempDir, '.claude-meta');
|
|
281
|
+
await mkdir(pluginsMetaDir, { recursive: true });
|
|
282
|
+
const installedPluginsPath = join(pluginsMetaDir, 'installed_plugins.json');
|
|
283
|
+
await writeFile(
|
|
284
|
+
installedPluginsPath,
|
|
285
|
+
JSON.stringify({
|
|
286
|
+
plugins: {
|
|
287
|
+
'onebrain@2.0.0': [{ source: 'marketplace', installPath: '/marketplace/path' }],
|
|
288
|
+
},
|
|
289
|
+
}),
|
|
290
|
+
'utf8',
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
// Should complete normally
|
|
294
|
+
await expect(
|
|
295
|
+
runInit({
|
|
296
|
+
vaultDir: tempDir,
|
|
297
|
+
isTTY: false,
|
|
298
|
+
installedPluginsPath,
|
|
299
|
+
vaultSyncFn: noopVaultSync,
|
|
300
|
+
registerHooksFn: noopRegisterHooks,
|
|
301
|
+
}),
|
|
302
|
+
).resolves.toMatchObject({ ok: true, exitCode: 0 });
|
|
303
|
+
});
|
|
304
|
+
});
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for `updateCommand` / `runUpdate` (the CLI entry point).
|
|
3
|
+
*
|
|
4
|
+
* Exercises the update flow using injectable mock dependencies so tests run
|
|
5
|
+
* offline and fast. Focuses on scenarios not already covered by update.test.ts:
|
|
6
|
+
* - --check dry-run mode output and no-side-effects guarantee
|
|
7
|
+
* - graceful network failure (fetch throws instead of returning error status)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
11
|
+
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
12
|
+
import { tmpdir } from 'node:os';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
|
|
15
|
+
import { type UpdateOptions, runUpdate } from '../../src/commands/update.js';
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Helpers
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
async function makeTempVault(): Promise<string> {
|
|
22
|
+
const dir = join(
|
|
23
|
+
tmpdir(),
|
|
24
|
+
`onebrain-update-int-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
25
|
+
);
|
|
26
|
+
await mkdir(dir, { recursive: true });
|
|
27
|
+
return dir;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function writeVaultYml(vaultDir: string, content: Record<string, unknown>): Promise<void> {
|
|
31
|
+
const { stringify } = await import('yaml');
|
|
32
|
+
await writeFile(join(vaultDir, 'vault.yml'), stringify(content), 'utf8');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function readVaultYml(vaultDir: string): Promise<Record<string, unknown>> {
|
|
36
|
+
const { parse } = await import('yaml');
|
|
37
|
+
const text = await readFile(join(vaultDir, 'vault.yml'), 'utf8');
|
|
38
|
+
return (parse(text) ?? {}) as Record<string, unknown>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Build a mock fetch that returns a fake GitHub releases/latest response. */
|
|
42
|
+
function makeMockFetch(tagName: string): typeof fetch {
|
|
43
|
+
return async (input: RequestInfo | URL, _init?: RequestInit): Promise<Response> => {
|
|
44
|
+
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
|
45
|
+
if (url.includes('/releases/latest')) {
|
|
46
|
+
return new Response(
|
|
47
|
+
JSON.stringify({ tag_name: tagName, published_at: '2026-04-24T00:00:00Z' }),
|
|
48
|
+
{
|
|
49
|
+
status: 200,
|
|
50
|
+
headers: { 'Content-Type': 'application/json' },
|
|
51
|
+
},
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
return new Response('Not Found', { status: 404 });
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Mock fetch that throws (simulates network unavailable). */
|
|
59
|
+
const throwingFetch: typeof fetch = async (_input: RequestInfo | URL) => {
|
|
60
|
+
throw new Error('fetch failed: network unavailable');
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** Noop mocks with required return shapes. */
|
|
64
|
+
const noopVaultSync = async (
|
|
65
|
+
_vaultDir: string,
|
|
66
|
+
_opts: Record<string, unknown>,
|
|
67
|
+
): Promise<{ filesAdded: number; filesRemoved: number }> => ({ filesAdded: 0, filesRemoved: 0 });
|
|
68
|
+
|
|
69
|
+
const noopInstallBinary = async (_version: string): Promise<void> => {};
|
|
70
|
+
const noopValidateBinary = async (): Promise<boolean> => true;
|
|
71
|
+
const noopRegisterHooks = async (_vaultDir: string): Promise<void> => {};
|
|
72
|
+
|
|
73
|
+
let tempDir: string;
|
|
74
|
+
|
|
75
|
+
beforeEach(async () => {
|
|
76
|
+
tempDir = await makeTempVault();
|
|
77
|
+
await writeVaultYml(tempDir, {
|
|
78
|
+
method: 'onebrain',
|
|
79
|
+
update_channel: 'stable',
|
|
80
|
+
onebrain_version: 'v1.10.18',
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
afterEach(async () => {
|
|
85
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// --check dry-run integration scenario
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
describe('update integration: --check dry-run mode', () => {
|
|
93
|
+
it('reports what would change (latestVersion) without downloading or modifying files', async () => {
|
|
94
|
+
const sideEffects: string[] = [];
|
|
95
|
+
|
|
96
|
+
const opts: UpdateOptions = {
|
|
97
|
+
vaultDir: tempDir,
|
|
98
|
+
isTTY: false,
|
|
99
|
+
check: true,
|
|
100
|
+
fetchFn: makeMockFetch('v1.99.0'),
|
|
101
|
+
vaultSyncFn: async (vaultDir, syncOpts) => {
|
|
102
|
+
sideEffects.push('vault-sync');
|
|
103
|
+
return noopVaultSync(vaultDir, syncOpts);
|
|
104
|
+
},
|
|
105
|
+
installBinaryFn: async (version) => {
|
|
106
|
+
sideEffects.push(`install:${version}`);
|
|
107
|
+
},
|
|
108
|
+
validateBinaryFn: async () => {
|
|
109
|
+
sideEffects.push('validate');
|
|
110
|
+
return true;
|
|
111
|
+
},
|
|
112
|
+
registerHooksFn: async (vaultDir) => {
|
|
113
|
+
sideEffects.push('register-hooks');
|
|
114
|
+
return noopRegisterHooks(vaultDir);
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const result = await runUpdate(opts);
|
|
119
|
+
|
|
120
|
+
// Command succeeds
|
|
121
|
+
expect(result.ok).toBe(true);
|
|
122
|
+
expect(result.exitCode).toBe(0);
|
|
123
|
+
|
|
124
|
+
// Reports the available version
|
|
125
|
+
expect(result.latestVersion).toBe('v1.99.0');
|
|
126
|
+
expect(result.currentVersion).toBe('v1.10.18');
|
|
127
|
+
|
|
128
|
+
// No side-effecting steps called
|
|
129
|
+
expect(sideEffects).not.toContain('vault-sync');
|
|
130
|
+
expect(sideEffects).not.toContain('register-hooks');
|
|
131
|
+
expect(sideEffects).toHaveLength(0);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('vault.yml is not modified by dry-run', async () => {
|
|
135
|
+
const opts: UpdateOptions = {
|
|
136
|
+
vaultDir: tempDir,
|
|
137
|
+
isTTY: false,
|
|
138
|
+
check: true,
|
|
139
|
+
fetchFn: makeMockFetch('v1.99.0'),
|
|
140
|
+
vaultSyncFn: noopVaultSync,
|
|
141
|
+
installBinaryFn: noopInstallBinary,
|
|
142
|
+
validateBinaryFn: noopValidateBinary,
|
|
143
|
+
registerHooksFn: noopRegisterHooks,
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
await runUpdate(opts);
|
|
147
|
+
|
|
148
|
+
// vault.yml onebrain_version unchanged
|
|
149
|
+
const vaultYml = await readVaultYml(tempDir);
|
|
150
|
+
expect(vaultYml.onebrain_version).toBe('v1.10.18');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('result carries both current and latest version in dry-run mode', async () => {
|
|
154
|
+
const result = await runUpdate({
|
|
155
|
+
vaultDir: tempDir,
|
|
156
|
+
isTTY: false,
|
|
157
|
+
check: true,
|
|
158
|
+
fetchFn: makeMockFetch('v1.99.0'),
|
|
159
|
+
vaultSyncFn: noopVaultSync,
|
|
160
|
+
installBinaryFn: noopInstallBinary,
|
|
161
|
+
validateBinaryFn: noopValidateBinary,
|
|
162
|
+
registerHooksFn: noopRegisterHooks,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// Both versions available on the result object
|
|
166
|
+
expect(result.latestVersion).toBe('v1.99.0');
|
|
167
|
+
expect(result.currentVersion).toBe('v1.10.18');
|
|
168
|
+
expect(result.ok).toBe(true);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// Network failure integration scenario
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
describe('update integration: network unavailable (fetch throws)', () => {
|
|
177
|
+
it('exits with clear error message when fetch throws, does not crash', async () => {
|
|
178
|
+
const opts: UpdateOptions = {
|
|
179
|
+
vaultDir: tempDir,
|
|
180
|
+
isTTY: false,
|
|
181
|
+
fetchFn: throwingFetch,
|
|
182
|
+
vaultSyncFn: noopVaultSync,
|
|
183
|
+
installBinaryFn: noopInstallBinary,
|
|
184
|
+
validateBinaryFn: noopValidateBinary,
|
|
185
|
+
registerHooksFn: noopRegisterHooks,
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// Should resolve (not throw) — errors go to result, not thrown
|
|
189
|
+
const result = await runUpdate(opts);
|
|
190
|
+
|
|
191
|
+
expect(result.ok).toBe(false);
|
|
192
|
+
expect(result.exitCode).toBe(1);
|
|
193
|
+
expect(result.error).toBeDefined();
|
|
194
|
+
// Error should be descriptive
|
|
195
|
+
expect(result.error).toMatch(/Fetch failed/i);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('vault.yml unchanged when network fails', async () => {
|
|
199
|
+
const opts: UpdateOptions = {
|
|
200
|
+
vaultDir: tempDir,
|
|
201
|
+
isTTY: false,
|
|
202
|
+
fetchFn: throwingFetch,
|
|
203
|
+
vaultSyncFn: noopVaultSync,
|
|
204
|
+
installBinaryFn: noopInstallBinary,
|
|
205
|
+
validateBinaryFn: noopValidateBinary,
|
|
206
|
+
registerHooksFn: noopRegisterHooks,
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
await runUpdate(opts);
|
|
210
|
+
|
|
211
|
+
const vaultYml = await readVaultYml(tempDir);
|
|
212
|
+
expect(vaultYml.onebrain_version).toBe('v1.10.18');
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
// Full end-to-end success scenario
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
describe('update integration: full end-to-end success', () => {
|
|
221
|
+
it('all 6 steps complete, vault.yml gets updated version, registerHooks is called', async () => {
|
|
222
|
+
const sideEffects: string[] = [];
|
|
223
|
+
|
|
224
|
+
const opts: UpdateOptions = {
|
|
225
|
+
vaultDir: tempDir,
|
|
226
|
+
isTTY: false,
|
|
227
|
+
fetchFn: makeMockFetch('v2.0.0'),
|
|
228
|
+
vaultSyncFn: async (vaultDir, syncOpts) => {
|
|
229
|
+
sideEffects.push('vault-sync');
|
|
230
|
+
return noopVaultSync(vaultDir, syncOpts);
|
|
231
|
+
},
|
|
232
|
+
installBinaryFn: async (version) => {
|
|
233
|
+
sideEffects.push(`install:${version}`);
|
|
234
|
+
},
|
|
235
|
+
validateBinaryFn: async () => {
|
|
236
|
+
sideEffects.push('validate');
|
|
237
|
+
return true;
|
|
238
|
+
},
|
|
239
|
+
registerHooksFn: async (vaultDir) => {
|
|
240
|
+
sideEffects.push('register-hooks');
|
|
241
|
+
return noopRegisterHooks(vaultDir);
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const result = await runUpdate(opts);
|
|
246
|
+
|
|
247
|
+
// Result is success
|
|
248
|
+
expect(result.ok).toBe(true);
|
|
249
|
+
expect(result.exitCode).toBe(0);
|
|
250
|
+
expect(result.latestVersion).toBe('v2.0.0');
|
|
251
|
+
expect(result.currentVersion).toBe('v1.10.18');
|
|
252
|
+
|
|
253
|
+
// All side-effecting steps were called
|
|
254
|
+
expect(sideEffects).toContain('vault-sync');
|
|
255
|
+
expect(sideEffects).toContain('install:v2.0.0');
|
|
256
|
+
expect(sideEffects).toContain('validate');
|
|
257
|
+
expect(sideEffects).toContain('register-hooks');
|
|
258
|
+
|
|
259
|
+
// vault.yml has the new version
|
|
260
|
+
const vaultYml = await readVaultYml(tempDir);
|
|
261
|
+
expect(vaultYml.onebrain_version).toBe('v2.0.0');
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
// Atomic gate: validateBinary returns false → registerHooks must NOT be called
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
describe('update integration: atomic gate (validateBinary returns false)', () => {
|
|
270
|
+
it('registerHooks is NOT called when validateBinary returns false', async () => {
|
|
271
|
+
const sideEffects: string[] = [];
|
|
272
|
+
|
|
273
|
+
const opts: UpdateOptions = {
|
|
274
|
+
vaultDir: tempDir,
|
|
275
|
+
isTTY: false,
|
|
276
|
+
fetchFn: makeMockFetch('v2.0.0'),
|
|
277
|
+
vaultSyncFn: async (vaultDir, syncOpts) => {
|
|
278
|
+
sideEffects.push('vault-sync');
|
|
279
|
+
return noopVaultSync(vaultDir, syncOpts);
|
|
280
|
+
},
|
|
281
|
+
installBinaryFn: async (version) => {
|
|
282
|
+
sideEffects.push(`install:${version}`);
|
|
283
|
+
},
|
|
284
|
+
validateBinaryFn: async () => {
|
|
285
|
+
sideEffects.push('validate');
|
|
286
|
+
return false; // ATOMIC GATE: validation fails
|
|
287
|
+
},
|
|
288
|
+
registerHooksFn: async (vaultDir) => {
|
|
289
|
+
sideEffects.push('register-hooks');
|
|
290
|
+
return noopRegisterHooks(vaultDir);
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const result = await runUpdate(opts);
|
|
295
|
+
|
|
296
|
+
// Result is failure
|
|
297
|
+
expect(result.ok).toBe(false);
|
|
298
|
+
expect(result.exitCode).toBe(1);
|
|
299
|
+
expect(result.error).toBeDefined();
|
|
300
|
+
expect(result.error).toMatch(/validation failed/i);
|
|
301
|
+
|
|
302
|
+
// validate was called, but register-hooks was NOT
|
|
303
|
+
expect(sideEffects).toContain('validate');
|
|
304
|
+
expect(sideEffects).not.toContain('register-hooks');
|
|
305
|
+
});
|
|
306
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"rootDir": "src",
|
|
5
|
+
"outDir": "dist",
|
|
6
|
+
"composite": true,
|
|
7
|
+
"tsBuildInfoFile": "dist/.tsbuildinfo"
|
|
8
|
+
},
|
|
9
|
+
"references": [{ "path": "../core" }],
|
|
10
|
+
"include": ["src/**/*.ts"],
|
|
11
|
+
"exclude": ["node_modules", "dist"]
|
|
12
|
+
}
|