@onebrain-ai/cli 2.0.0 → 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 +24 -2
- 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,764 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* vault-sync — internal command
|
|
3
|
-
*
|
|
4
|
-
* Replaces vault-sync.sh + pin-to-vault.sh + clean-plugin-cache.sh.
|
|
5
|
-
*
|
|
6
|
-
* Steps (in order):
|
|
7
|
-
* 1. Download tarball from GitHub
|
|
8
|
-
* 2. Sync plugin files (critical — exit 1 on failure)
|
|
9
|
-
* 3. Copy root docs (non-fatal — docs are optional, skip silently on error)
|
|
10
|
-
* 4. Merge harness files (critical — exit 1 on failure)
|
|
11
|
-
* 5. Write version to vault.yml (critical)
|
|
12
|
-
* 6. Pin to vault (non-fatal — log stderr, continue)
|
|
13
|
-
* 7. Clean plugin cache (non-fatal — log stderr, continue)
|
|
14
|
-
*
|
|
15
|
-
* Exit code: 0 on success, 1 if any critical step fails.
|
|
16
|
-
* TTY: uses @clack/prompts spinners
|
|
17
|
-
* Non-TTY: plain text prefixed with "vault-sync:"
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
import {
|
|
21
|
-
mkdir,
|
|
22
|
-
mkdtemp,
|
|
23
|
-
readFile,
|
|
24
|
-
readdir,
|
|
25
|
-
rename,
|
|
26
|
-
rm,
|
|
27
|
-
stat,
|
|
28
|
-
unlink,
|
|
29
|
-
writeFile,
|
|
30
|
-
} from 'node:fs/promises';
|
|
31
|
-
import { homedir, tmpdir } from 'node:os';
|
|
32
|
-
import { dirname, join, relative } from 'node:path';
|
|
33
|
-
import { intro, outro, spinner } from '@clack/prompts';
|
|
34
|
-
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
|
35
|
-
|
|
36
|
-
// ---------------------------------------------------------------------------
|
|
37
|
-
// Types
|
|
38
|
-
// ---------------------------------------------------------------------------
|
|
39
|
-
|
|
40
|
-
export interface VaultSyncOptions {
|
|
41
|
-
/** Overrides vault.yml update_channel branch resolution (for tests). */
|
|
42
|
-
branch?: string;
|
|
43
|
-
/** Mock fetch for tests — defaults to globalThis.fetch. */
|
|
44
|
-
fetchFn?: typeof fetch;
|
|
45
|
-
/** Override path to installed_plugins.json (for tests). */
|
|
46
|
-
installedPluginsPath?: string;
|
|
47
|
-
/** Override path to the plugins cache dir (for tests). */
|
|
48
|
-
installedPluginsCacheDir?: string;
|
|
49
|
-
/** Override TTY detection for tests — defaults to process.stdout.isTTY. */
|
|
50
|
-
isTTY?: boolean;
|
|
51
|
-
/** Injectable unlink for tests — defaults to node:fs/promises unlink. */
|
|
52
|
-
unlinkFn?: typeof unlink;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export interface VaultSyncResult {
|
|
56
|
-
ok: boolean;
|
|
57
|
-
version: string;
|
|
58
|
-
branch: string;
|
|
59
|
-
filesAdded: number;
|
|
60
|
-
filesRemoved: number;
|
|
61
|
-
importsAdded: number;
|
|
62
|
-
pinSkipped: boolean;
|
|
63
|
-
cacheRemoved: number;
|
|
64
|
-
error?: string;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// ---------------------------------------------------------------------------
|
|
68
|
-
// Branch resolution
|
|
69
|
-
// ---------------------------------------------------------------------------
|
|
70
|
-
|
|
71
|
-
function resolveBranch(updateChannel: string | undefined): string {
|
|
72
|
-
// update_channel === 'stable' → use 'main'; anything else → 'next'
|
|
73
|
-
return updateChannel === 'stable' ? 'main' : 'next';
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// ---------------------------------------------------------------------------
|
|
77
|
-
// Step 1: Download tarball
|
|
78
|
-
// ---------------------------------------------------------------------------
|
|
79
|
-
|
|
80
|
-
async function downloadTarball(
|
|
81
|
-
branch: string,
|
|
82
|
-
fetchFn: typeof fetch,
|
|
83
|
-
): Promise<{ tarball: ArrayBuffer; tmpDir: string }> {
|
|
84
|
-
const url = `https://api.github.com/repos/kengio/onebrain/tarball/${branch}`;
|
|
85
|
-
const response = await fetchFn(url);
|
|
86
|
-
if (!response.ok) {
|
|
87
|
-
const hints: Partial<Record<number, string>> = {
|
|
88
|
-
403: ' — check repo permissions or GITHUB_TOKEN',
|
|
89
|
-
404: ' — repo or branch not found',
|
|
90
|
-
429: ' — rate limited, wait and retry',
|
|
91
|
-
};
|
|
92
|
-
const hint = hints[response.status] ?? '';
|
|
93
|
-
throw new Error(`HTTP ${response.status} downloading tarball from ${url}${hint}`);
|
|
94
|
-
}
|
|
95
|
-
const tarball = await response.arrayBuffer();
|
|
96
|
-
const tmpDir = await mkdtemp(join(tmpdir(), 'onebrain-sync-'));
|
|
97
|
-
return { tarball, tmpDir };
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Extract a .tar.gz buffer to destDir using the `tar` CLI.
|
|
102
|
-
* Returns the path of the top-level extracted directory.
|
|
103
|
-
*/
|
|
104
|
-
async function extractTarball(tarball: ArrayBuffer, destDir: string): Promise<string> {
|
|
105
|
-
const tarPath = join(destDir, 'bundle.tar.gz');
|
|
106
|
-
await writeFile(tarPath, Buffer.from(tarball));
|
|
107
|
-
|
|
108
|
-
// Spawn tar to extract
|
|
109
|
-
const proc = Bun.spawn(['tar', '-xzf', tarPath, '-C', destDir], {
|
|
110
|
-
stdout: 'pipe',
|
|
111
|
-
stderr: 'pipe',
|
|
112
|
-
});
|
|
113
|
-
const exitCode = await proc.exited;
|
|
114
|
-
if (exitCode !== 0) {
|
|
115
|
-
const errText = await new Response(proc.stderr).text();
|
|
116
|
-
throw new Error(`tar extraction failed (exit ${exitCode}): ${errText.trim()}`);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// Delete the tarball file now we've extracted
|
|
120
|
-
await unlink(tarPath);
|
|
121
|
-
|
|
122
|
-
// Find the top-level directory (should be kengio-onebrain-<sha>/)
|
|
123
|
-
const entries = await readdir(destDir);
|
|
124
|
-
const topLevel = entries.find((e) => e !== 'bundle.tar.gz');
|
|
125
|
-
if (!topLevel) {
|
|
126
|
-
throw new Error('Extracted tarball contains no top-level directory');
|
|
127
|
-
}
|
|
128
|
-
return join(destDir, topLevel);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// ---------------------------------------------------------------------------
|
|
132
|
-
// Step 2: Sync plugin files
|
|
133
|
-
// ---------------------------------------------------------------------------
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* Recursively list all files under a directory (relative paths).
|
|
137
|
-
*/
|
|
138
|
-
async function listFilesRecursive(dir: string): Promise<string[]> {
|
|
139
|
-
const results: string[] = [];
|
|
140
|
-
const queue = [dir];
|
|
141
|
-
while (queue.length > 0) {
|
|
142
|
-
const current = queue.pop() as string;
|
|
143
|
-
let entries: string[];
|
|
144
|
-
try {
|
|
145
|
-
entries = await readdir(current);
|
|
146
|
-
} catch {
|
|
147
|
-
continue;
|
|
148
|
-
}
|
|
149
|
-
for (const entry of entries) {
|
|
150
|
-
const fullPath = join(current, entry);
|
|
151
|
-
let s: Awaited<ReturnType<typeof stat>>;
|
|
152
|
-
try {
|
|
153
|
-
s = await stat(fullPath);
|
|
154
|
-
} catch {
|
|
155
|
-
continue;
|
|
156
|
-
}
|
|
157
|
-
if (s.isDirectory()) {
|
|
158
|
-
queue.push(fullPath);
|
|
159
|
-
} else {
|
|
160
|
-
results.push(fullPath);
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
return results;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
async function syncPluginFiles(
|
|
168
|
-
extractedDir: string,
|
|
169
|
-
vaultRoot: string,
|
|
170
|
-
unlinkFn: typeof unlink = unlink,
|
|
171
|
-
): Promise<{ filesAdded: number; filesRemoved: number }> {
|
|
172
|
-
const sourcePlugin = join(extractedDir, '.claude', 'plugins', 'onebrain');
|
|
173
|
-
const destPlugin = join(vaultRoot, '.claude', 'plugins', 'onebrain');
|
|
174
|
-
|
|
175
|
-
await mkdir(destPlugin, { recursive: true });
|
|
176
|
-
|
|
177
|
-
// Collect source files (relative to sourcePlugin)
|
|
178
|
-
const sourceFiles = await listFilesRecursive(sourcePlugin);
|
|
179
|
-
const sourceRelSet = new Set(sourceFiles.map((f) => relative(sourcePlugin, f)));
|
|
180
|
-
|
|
181
|
-
// Collect destination files
|
|
182
|
-
const destFiles = await listFilesRecursive(destPlugin);
|
|
183
|
-
const destRelSet = new Set(destFiles.map((f) => relative(destPlugin, f)));
|
|
184
|
-
|
|
185
|
-
// Identify stale files (in dest, not in source)
|
|
186
|
-
const staleRels: string[] = [];
|
|
187
|
-
for (const rel of destRelSet) {
|
|
188
|
-
if (!sourceRelSet.has(rel)) {
|
|
189
|
-
staleRels.push(rel);
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
// Copy all source files to dest
|
|
194
|
-
let filesAdded = 0;
|
|
195
|
-
for (const srcPath of sourceFiles) {
|
|
196
|
-
const rel = relative(sourcePlugin, srcPath);
|
|
197
|
-
const destPath = join(destPlugin, rel);
|
|
198
|
-
await mkdir(dirname(destPath), { recursive: true });
|
|
199
|
-
const content = await readFile(srcPath);
|
|
200
|
-
await writeFile(destPath, content);
|
|
201
|
-
filesAdded++;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// Remove stale files — track actual deletions only
|
|
205
|
-
let filesRemoved = 0;
|
|
206
|
-
for (const rel of staleRels) {
|
|
207
|
-
const destPath = join(destPlugin, rel);
|
|
208
|
-
try {
|
|
209
|
-
await unlinkFn(destPath);
|
|
210
|
-
filesRemoved++;
|
|
211
|
-
} catch {
|
|
212
|
-
// Non-fatal within this step — log nothing (best-effort cleanup)
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
return { filesAdded, filesRemoved };
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
// ---------------------------------------------------------------------------
|
|
220
|
-
// Step 3: Copy root docs
|
|
221
|
-
// ---------------------------------------------------------------------------
|
|
222
|
-
|
|
223
|
-
async function copyRootDocs(extractedDir: string, vaultRoot: string): Promise<void> {
|
|
224
|
-
const docs = ['README.md', 'CONTRIBUTING.md', 'CHANGELOG.md'];
|
|
225
|
-
for (const doc of docs) {
|
|
226
|
-
const srcPath = join(extractedDir, doc);
|
|
227
|
-
const destPath = join(vaultRoot, doc);
|
|
228
|
-
try {
|
|
229
|
-
const content = await readFile(srcPath);
|
|
230
|
-
await writeFile(destPath, content);
|
|
231
|
-
} catch {
|
|
232
|
-
// File may not exist in tarball — skip silently
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// ---------------------------------------------------------------------------
|
|
238
|
-
// Step 4: Merge harness files
|
|
239
|
-
// ---------------------------------------------------------------------------
|
|
240
|
-
|
|
241
|
-
/**
|
|
242
|
-
* Merge a single harness file.
|
|
243
|
-
* Vault is primary. Only inject @import lines from repo that are not yet in vault.
|
|
244
|
-
* Returns number of imports added.
|
|
245
|
-
*/
|
|
246
|
-
async function mergeHarnessFile(
|
|
247
|
-
extractedDir: string,
|
|
248
|
-
vaultRoot: string,
|
|
249
|
-
filename: string,
|
|
250
|
-
): Promise<number> {
|
|
251
|
-
const srcPath = join(extractedDir, filename);
|
|
252
|
-
const destPath = join(vaultRoot, filename);
|
|
253
|
-
|
|
254
|
-
let repoText: string;
|
|
255
|
-
try {
|
|
256
|
-
repoText = await readFile(srcPath, 'utf8');
|
|
257
|
-
} catch {
|
|
258
|
-
return 0; // Not in tarball — nothing to do
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
let vaultText: string;
|
|
262
|
-
try {
|
|
263
|
-
vaultText = await readFile(destPath, 'utf8');
|
|
264
|
-
} catch {
|
|
265
|
-
// Vault copy doesn't exist — write repo version directly
|
|
266
|
-
await writeFile(destPath, repoText, 'utf8');
|
|
267
|
-
return repoText.split('\n').filter((l) => l.startsWith('@')).length;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// Find @import lines in repo not already in vault
|
|
271
|
-
const vaultAtSet = new Set(
|
|
272
|
-
vaultText
|
|
273
|
-
.split('\n')
|
|
274
|
-
.filter((l) => l.startsWith('@'))
|
|
275
|
-
.map((l) => l.trim()),
|
|
276
|
-
);
|
|
277
|
-
|
|
278
|
-
const newImports = repoText
|
|
279
|
-
.split('\n')
|
|
280
|
-
.filter((l) => l.startsWith('@') && !vaultAtSet.has(l.trim()))
|
|
281
|
-
.map((l) => l.trimEnd());
|
|
282
|
-
|
|
283
|
-
if (newImports.length === 0) {
|
|
284
|
-
return 0;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
// Insert before the last @-line in vault (keeps structural ordering)
|
|
288
|
-
const vaultLines = vaultText.split('\n');
|
|
289
|
-
const lastAtIdx = vaultLines.reduce((acc, l, i) => (l.startsWith('@') ? i : acc), -1);
|
|
290
|
-
|
|
291
|
-
if (lastAtIdx >= 0) {
|
|
292
|
-
vaultLines.splice(lastAtIdx, 0, ...newImports);
|
|
293
|
-
} else {
|
|
294
|
-
vaultLines.push('', ...newImports);
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
const merged = vaultLines.join('\n');
|
|
298
|
-
await writeFile(destPath, merged, 'utf8');
|
|
299
|
-
return newImports.length;
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
async function mergeHarnessFiles(extractedDir: string, vaultRoot: string): Promise<number> {
|
|
303
|
-
const harnessFiles = ['CLAUDE.md', 'GEMINI.md', 'AGENTS.md'];
|
|
304
|
-
let totalImportsAdded = 0;
|
|
305
|
-
const results = await Promise.all(
|
|
306
|
-
harnessFiles.map((f) => mergeHarnessFile(extractedDir, vaultRoot, f)),
|
|
307
|
-
);
|
|
308
|
-
for (const n of results) {
|
|
309
|
-
totalImportsAdded += n;
|
|
310
|
-
}
|
|
311
|
-
return totalImportsAdded;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
// ---------------------------------------------------------------------------
|
|
315
|
-
// Step 5: Write version to vault.yml
|
|
316
|
-
// ---------------------------------------------------------------------------
|
|
317
|
-
|
|
318
|
-
async function updateVaultYml(
|
|
319
|
-
vaultRoot: string,
|
|
320
|
-
version: string,
|
|
321
|
-
updateChannel: string,
|
|
322
|
-
): Promise<void> {
|
|
323
|
-
const vaultYmlPath = join(vaultRoot, 'vault.yml');
|
|
324
|
-
let text: string;
|
|
325
|
-
try {
|
|
326
|
-
text = await readFile(vaultYmlPath, 'utf8');
|
|
327
|
-
} catch {
|
|
328
|
-
// vault.yml missing — create minimal one
|
|
329
|
-
text = '';
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
const raw = (parseYaml(text) ?? {}) as Record<string, unknown>;
|
|
333
|
-
raw.onebrain_version = version;
|
|
334
|
-
raw.update_channel = updateChannel;
|
|
335
|
-
|
|
336
|
-
const updated = stringifyYaml(raw, { lineWidth: 0 });
|
|
337
|
-
const tmpPath = `${vaultYmlPath}.tmp`;
|
|
338
|
-
await writeFile(tmpPath, updated, 'utf8');
|
|
339
|
-
await rename(tmpPath, vaultYmlPath);
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
// ---------------------------------------------------------------------------
|
|
343
|
-
// Step 6: Pin to vault
|
|
344
|
-
// ---------------------------------------------------------------------------
|
|
345
|
-
|
|
346
|
-
/**
|
|
347
|
-
* Read plugin.json version from the synced plugin dir.
|
|
348
|
-
*/
|
|
349
|
-
async function readPluginVersion(vaultRoot: string): Promise<string> {
|
|
350
|
-
// plugin.json lives in .claude/plugins/onebrain/.claude-plugin/plugin.json
|
|
351
|
-
const pluginJsonPath = join(
|
|
352
|
-
vaultRoot,
|
|
353
|
-
'.claude',
|
|
354
|
-
'plugins',
|
|
355
|
-
'onebrain',
|
|
356
|
-
'.claude-plugin',
|
|
357
|
-
'plugin.json',
|
|
358
|
-
);
|
|
359
|
-
try {
|
|
360
|
-
const text = await readFile(pluginJsonPath, 'utf8');
|
|
361
|
-
const parsed = JSON.parse(text) as Record<string, unknown>;
|
|
362
|
-
return typeof parsed.version === 'string' ? parsed.version : 'unknown';
|
|
363
|
-
} catch {
|
|
364
|
-
return 'unknown';
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
interface PinResult {
|
|
369
|
-
skipped: boolean;
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
async function pinToVault(
|
|
373
|
-
vaultRoot: string,
|
|
374
|
-
installedPluginsPath: string,
|
|
375
|
-
installedPluginsCacheDir: string | undefined,
|
|
376
|
-
): Promise<PinResult> {
|
|
377
|
-
// Read installed_plugins.json
|
|
378
|
-
let text: string;
|
|
379
|
-
try {
|
|
380
|
-
text = await readFile(installedPluginsPath, 'utf8');
|
|
381
|
-
} catch {
|
|
382
|
-
return { skipped: true }; // File not found — no-op
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
let data: Record<string, unknown>;
|
|
386
|
-
try {
|
|
387
|
-
data = JSON.parse(text) as Record<string, unknown>;
|
|
388
|
-
} catch {
|
|
389
|
-
throw new Error(`installed_plugins.json is not valid JSON: ${installedPluginsPath}`);
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
const plugins = data.plugins as Record<string, unknown[]> | undefined;
|
|
393
|
-
if (!plugins) {
|
|
394
|
-
return { skipped: true };
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
// Find all onebrain@ entries
|
|
398
|
-
const onebrainKeys = Object.keys(plugins).filter((k) => k.startsWith('onebrain@'));
|
|
399
|
-
if (onebrainKeys.length === 0) {
|
|
400
|
-
return { skipped: true };
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
const vaultPluginDir = join(vaultRoot, '.claude', 'plugins', 'onebrain');
|
|
404
|
-
const pluginVersion = await readPluginVersion(vaultRoot);
|
|
405
|
-
|
|
406
|
-
// Determine cache dir: installed_plugins.json parent → plugins/ → cache/
|
|
407
|
-
const cacheDir = installedPluginsCacheDir ?? join(dirname(installedPluginsPath), 'cache');
|
|
408
|
-
|
|
409
|
-
// If ANY onebrain entry has source: marketplace, Claude Code owns it — skip entirely.
|
|
410
|
-
const hasMarketplace = onebrainKeys.some((k) => {
|
|
411
|
-
const entries = plugins[k] as Array<Record<string, unknown>>;
|
|
412
|
-
return entries.some((e) => e.source === 'marketplace');
|
|
413
|
-
});
|
|
414
|
-
if (hasMarketplace) {
|
|
415
|
-
return { skipped: true };
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
let changed = false;
|
|
419
|
-
for (const key of onebrainKeys) {
|
|
420
|
-
const entries = plugins[key] as Array<Record<string, unknown>>;
|
|
421
|
-
for (const entry of entries) {
|
|
422
|
-
const installPath = entry.installPath;
|
|
423
|
-
if (typeof installPath !== 'string') {
|
|
424
|
-
continue;
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
// Only rewrite if installPath is inside the cache dir
|
|
428
|
-
let inCache = false;
|
|
429
|
-
try {
|
|
430
|
-
// resolves any symlinks before comparison (normalize)
|
|
431
|
-
inCache = installPath.startsWith(`${cacheDir}/`) || installPath === cacheDir;
|
|
432
|
-
} catch {
|
|
433
|
-
inCache = false;
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
if (!inCache) {
|
|
437
|
-
continue;
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
entry.installPath = vaultPluginDir;
|
|
441
|
-
entry.version = pluginVersion;
|
|
442
|
-
changed = true;
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
if (!changed) {
|
|
447
|
-
return { skipped: false }; // Already pinned — no change needed
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
// Atomic write via temp file + rename
|
|
451
|
-
const tmpPath = `${installedPluginsPath}.tmp`;
|
|
452
|
-
await writeFile(tmpPath, JSON.stringify(data, null, 4), 'utf8');
|
|
453
|
-
// rename is atomic on POSIX
|
|
454
|
-
await rename(tmpPath, installedPluginsPath);
|
|
455
|
-
|
|
456
|
-
return { skipped: false };
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
// ---------------------------------------------------------------------------
|
|
460
|
-
// Step 7: Clean plugin cache
|
|
461
|
-
// ---------------------------------------------------------------------------
|
|
462
|
-
|
|
463
|
-
async function cleanPluginCache(
|
|
464
|
-
installedPluginsPath: string,
|
|
465
|
-
installedPluginsCacheDir: string | undefined,
|
|
466
|
-
): Promise<number> {
|
|
467
|
-
const cacheDir = installedPluginsCacheDir ?? join(dirname(installedPluginsPath), 'cache');
|
|
468
|
-
|
|
469
|
-
// Check cache dir exists
|
|
470
|
-
try {
|
|
471
|
-
await stat(cacheDir);
|
|
472
|
-
} catch {
|
|
473
|
-
return 0; // No cache dir — no-op
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
// Read installed_plugins.json to find onebrain marketplace entries
|
|
477
|
-
const onebrainDirs: string[] = [];
|
|
478
|
-
try {
|
|
479
|
-
const text = await readFile(installedPluginsPath, 'utf8');
|
|
480
|
-
const data = JSON.parse(text) as Record<string, unknown>;
|
|
481
|
-
const plugins = data.plugins as Record<string, unknown[]> | undefined;
|
|
482
|
-
if (plugins) {
|
|
483
|
-
for (const key of Object.keys(plugins)) {
|
|
484
|
-
if (!key.startsWith('onebrain@')) continue;
|
|
485
|
-
const marketplace = key.split('@')[1] as string;
|
|
486
|
-
const candidate = join(cacheDir, marketplace, 'onebrain');
|
|
487
|
-
try {
|
|
488
|
-
await stat(candidate);
|
|
489
|
-
onebrainDirs.push(candidate);
|
|
490
|
-
} catch {
|
|
491
|
-
// Directory doesn't exist — skip
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
} catch {
|
|
496
|
-
// JSON parse failure or file not found — fall back to glob
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
// Fallback: glob for any cache/*/onebrain/
|
|
500
|
-
if (onebrainDirs.length === 0) {
|
|
501
|
-
try {
|
|
502
|
-
const marketplaceDirs = await readdir(cacheDir);
|
|
503
|
-
for (const mp of marketplaceDirs) {
|
|
504
|
-
const candidate = join(cacheDir, mp, 'onebrain');
|
|
505
|
-
try {
|
|
506
|
-
await stat(candidate);
|
|
507
|
-
onebrainDirs.push(candidate);
|
|
508
|
-
} catch {
|
|
509
|
-
// Not found
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
|
-
} catch {
|
|
513
|
-
return 0;
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
let removed = 0;
|
|
518
|
-
for (const pluginDir of onebrainDirs) {
|
|
519
|
-
let versionDirs: string[];
|
|
520
|
-
try {
|
|
521
|
-
versionDirs = await readdir(pluginDir);
|
|
522
|
-
} catch {
|
|
523
|
-
continue;
|
|
524
|
-
}
|
|
525
|
-
for (const versionDir of versionDirs) {
|
|
526
|
-
const fullPath = join(pluginDir, versionDir);
|
|
527
|
-
try {
|
|
528
|
-
const s = await stat(fullPath);
|
|
529
|
-
if (s.isDirectory()) {
|
|
530
|
-
await rm(fullPath, { recursive: true, force: true });
|
|
531
|
-
removed++;
|
|
532
|
-
}
|
|
533
|
-
} catch {
|
|
534
|
-
// Skip
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
return removed;
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
// ---------------------------------------------------------------------------
|
|
543
|
-
// Main runVaultSync function
|
|
544
|
-
// ---------------------------------------------------------------------------
|
|
545
|
-
|
|
546
|
-
export async function runVaultSync(
|
|
547
|
-
vaultRoot: string,
|
|
548
|
-
opts: VaultSyncOptions = {},
|
|
549
|
-
): Promise<VaultSyncResult> {
|
|
550
|
-
const fetchFn = opts.fetchFn ?? globalThis.fetch;
|
|
551
|
-
const isTTY = opts.isTTY ?? process.stdout.isTTY;
|
|
552
|
-
const unlinkFn = opts.unlinkFn ?? unlink;
|
|
553
|
-
|
|
554
|
-
// Load vault.yml for config
|
|
555
|
-
let updateChannel = 'stable';
|
|
556
|
-
let harness = 'claude-code';
|
|
557
|
-
try {
|
|
558
|
-
const vaultYmlText = await readFile(join(vaultRoot, 'vault.yml'), 'utf8');
|
|
559
|
-
const vaultYml = (parseYaml(vaultYmlText) ?? {}) as Record<string, unknown>;
|
|
560
|
-
if (typeof vaultYml.update_channel === 'string') {
|
|
561
|
-
updateChannel = vaultYml.update_channel;
|
|
562
|
-
}
|
|
563
|
-
const runtime = vaultYml.runtime as Record<string, unknown> | undefined;
|
|
564
|
-
if (runtime && typeof runtime.harness === 'string') {
|
|
565
|
-
harness = runtime.harness;
|
|
566
|
-
}
|
|
567
|
-
} catch {
|
|
568
|
-
// vault.yml not found — use defaults
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
const branch = opts.branch ?? resolveBranch(updateChannel);
|
|
572
|
-
const installedPluginsPath =
|
|
573
|
-
opts.installedPluginsPath ?? join(homedir(), '.claude', 'plugins', 'installed_plugins.json');
|
|
574
|
-
const installedPluginsCacheDir = opts.installedPluginsCacheDir;
|
|
575
|
-
|
|
576
|
-
const result: VaultSyncResult = {
|
|
577
|
-
ok: false,
|
|
578
|
-
version: 'unknown',
|
|
579
|
-
branch,
|
|
580
|
-
filesAdded: 0,
|
|
581
|
-
filesRemoved: 0,
|
|
582
|
-
importsAdded: 0,
|
|
583
|
-
pinSkipped: true,
|
|
584
|
-
cacheRemoved: 0,
|
|
585
|
-
};
|
|
586
|
-
|
|
587
|
-
// TTY output helpers
|
|
588
|
-
let s: ReturnType<typeof spinner> | null = null;
|
|
589
|
-
|
|
590
|
-
function startSpinner(msg: string) {
|
|
591
|
-
if (isTTY) {
|
|
592
|
-
s = spinner();
|
|
593
|
-
s.start(msg);
|
|
594
|
-
} else {
|
|
595
|
-
process.stdout.write(`vault-sync: ${msg}\n`);
|
|
596
|
-
}
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
function stopSpinner(msg: string) {
|
|
600
|
-
if (isTTY && s) {
|
|
601
|
-
s.stop(msg);
|
|
602
|
-
s = null;
|
|
603
|
-
}
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
if (isTTY) {
|
|
607
|
-
intro('OneBrain Vault Sync');
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
let tmpDir: string | null = null;
|
|
611
|
-
|
|
612
|
-
try {
|
|
613
|
-
// ── Step 1: Download tarball ──────────────────────────────────────────
|
|
614
|
-
startSpinner('Downloading tarball...');
|
|
615
|
-
let extractedDir: string;
|
|
616
|
-
try {
|
|
617
|
-
const dl = await downloadTarball(branch, fetchFn);
|
|
618
|
-
tmpDir = dl.tmpDir;
|
|
619
|
-
extractedDir = await extractTarball(dl.tarball, tmpDir);
|
|
620
|
-
} catch (err) {
|
|
621
|
-
stopSpinner('Download failed');
|
|
622
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
623
|
-
result.error = msg;
|
|
624
|
-
process.stderr.write(`vault-sync: download failed: ${msg}\n`);
|
|
625
|
-
return result;
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
// Read version from extracted plugin.json (before sync writes it to vault)
|
|
629
|
-
try {
|
|
630
|
-
const pjText = await readFile(
|
|
631
|
-
join(extractedDir, '.claude', 'plugins', 'onebrain', '.claude-plugin', 'plugin.json'),
|
|
632
|
-
'utf8',
|
|
633
|
-
);
|
|
634
|
-
const pj = JSON.parse(pjText) as Record<string, unknown>;
|
|
635
|
-
if (typeof pj.version === 'string') {
|
|
636
|
-
result.version = pj.version;
|
|
637
|
-
}
|
|
638
|
-
} catch {
|
|
639
|
-
// Keep 'unknown'
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
stopSpinner(`kengio/onebrain@${branch} (v${result.version})`);
|
|
643
|
-
|
|
644
|
-
// ── Step 2: Sync plugin files ─────────────────────────────────────────
|
|
645
|
-
startSpinner('Syncing plugin files...');
|
|
646
|
-
try {
|
|
647
|
-
const { filesAdded, filesRemoved } = await syncPluginFiles(extractedDir, vaultRoot, unlinkFn);
|
|
648
|
-
result.filesAdded = filesAdded;
|
|
649
|
-
result.filesRemoved = filesRemoved;
|
|
650
|
-
} catch (err) {
|
|
651
|
-
stopSpinner('Plugin sync failed');
|
|
652
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
653
|
-
result.error = msg;
|
|
654
|
-
process.stderr.write(`vault-sync: plugin sync failed: ${msg}\n`);
|
|
655
|
-
return result;
|
|
656
|
-
}
|
|
657
|
-
stopSpinner(
|
|
658
|
-
`${result.filesAdded} file${result.filesAdded !== 1 ? 's' : ''} synced, ${result.filesRemoved} removed`,
|
|
659
|
-
);
|
|
660
|
-
|
|
661
|
-
// ── Step 3: Copy root docs (non-fatal) ───────────────────────────────
|
|
662
|
-
await copyRootDocs(extractedDir, vaultRoot);
|
|
663
|
-
|
|
664
|
-
// ── Step 4: Merge harness files ───────────────────────────────────────
|
|
665
|
-
startSpinner('Updating harness files...');
|
|
666
|
-
let importsAdded = 0;
|
|
667
|
-
try {
|
|
668
|
-
importsAdded = await mergeHarnessFiles(extractedDir, vaultRoot);
|
|
669
|
-
result.importsAdded = importsAdded;
|
|
670
|
-
} catch (err) {
|
|
671
|
-
stopSpinner('Harness merge failed');
|
|
672
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
673
|
-
result.error = msg;
|
|
674
|
-
process.stderr.write(`vault-sync: harness merge failed: ${msg}\n`);
|
|
675
|
-
return result;
|
|
676
|
-
}
|
|
677
|
-
if (importsAdded > 0) {
|
|
678
|
-
stopSpinner(`${importsAdded} import${importsAdded !== 1 ? 's' : ''} added`);
|
|
679
|
-
} else {
|
|
680
|
-
stopSpinner('harness files up-to-date');
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
// ── Step 5: Write version to vault.yml ───────────────────────────────
|
|
684
|
-
try {
|
|
685
|
-
await updateVaultYml(vaultRoot, result.version, updateChannel);
|
|
686
|
-
} catch (err) {
|
|
687
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
688
|
-
result.error = msg;
|
|
689
|
-
process.stderr.write(`vault-sync: vault.yml update failed: ${msg}\n`);
|
|
690
|
-
return result;
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
// ── Steps 6–7: Non-fatal, claude-code harness only ────────────────────
|
|
694
|
-
if (harness === 'claude-code') {
|
|
695
|
-
// Step 6: Pin to vault
|
|
696
|
-
startSpinner('Pinning to vault...');
|
|
697
|
-
try {
|
|
698
|
-
const pinResult = await pinToVault(
|
|
699
|
-
vaultRoot,
|
|
700
|
-
installedPluginsPath,
|
|
701
|
-
installedPluginsCacheDir,
|
|
702
|
-
);
|
|
703
|
-
result.pinSkipped = pinResult.skipped;
|
|
704
|
-
if (pinResult.skipped) {
|
|
705
|
-
stopSpinner('pin skipped (not found or marketplace)');
|
|
706
|
-
} else {
|
|
707
|
-
stopSpinner('installPath → .claude/plugins/onebrain');
|
|
708
|
-
}
|
|
709
|
-
} catch (err) {
|
|
710
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
711
|
-
process.stderr.write(`vault-sync: pin warning: ${msg}\n`);
|
|
712
|
-
result.pinSkipped = true;
|
|
713
|
-
stopSpinner('pin skipped (error — non-fatal)');
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
// Step 7: Clean plugin cache
|
|
717
|
-
startSpinner('Cleaning cache...');
|
|
718
|
-
try {
|
|
719
|
-
const cacheRemoved = await cleanPluginCache(installedPluginsPath, installedPluginsCacheDir);
|
|
720
|
-
result.cacheRemoved = cacheRemoved;
|
|
721
|
-
if (cacheRemoved > 0) {
|
|
722
|
-
stopSpinner(`${cacheRemoved} cached version${cacheRemoved !== 1 ? 's' : ''} removed`);
|
|
723
|
-
} else {
|
|
724
|
-
stopSpinner('no cache to clean');
|
|
725
|
-
}
|
|
726
|
-
} catch (err) {
|
|
727
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
728
|
-
process.stderr.write(`vault-sync: cache clean warning: ${msg}\n`);
|
|
729
|
-
stopSpinner('cache clean skipped (error — non-fatal)');
|
|
730
|
-
}
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
result.ok = true;
|
|
734
|
-
|
|
735
|
-
if (isTTY) {
|
|
736
|
-
outro(`Done — v${result.version} synced`);
|
|
737
|
-
} else {
|
|
738
|
-
process.stdout.write('vault-sync: done\n');
|
|
739
|
-
}
|
|
740
|
-
} finally {
|
|
741
|
-
// Clean up temp dir
|
|
742
|
-
if (tmpDir) {
|
|
743
|
-
rm(tmpDir, { recursive: true, force: true }).catch(() => {
|
|
744
|
-
// Non-fatal cleanup
|
|
745
|
-
});
|
|
746
|
-
}
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
return result;
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
// ---------------------------------------------------------------------------
|
|
753
|
-
// CLI entry point
|
|
754
|
-
// ---------------------------------------------------------------------------
|
|
755
|
-
|
|
756
|
-
export async function vaultSyncCommand(
|
|
757
|
-
vaultRoot: string,
|
|
758
|
-
opts: VaultSyncOptions = {},
|
|
759
|
-
): Promise<void> {
|
|
760
|
-
const result = await runVaultSync(vaultRoot, opts);
|
|
761
|
-
if (!result.ok) {
|
|
762
|
-
process.exit(1);
|
|
763
|
-
}
|
|
764
|
-
}
|