@brainjar/cli 0.1.0 → 0.2.1

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/src/pack.ts ADDED
@@ -0,0 +1,671 @@
1
+ import { readFile, readdir, writeFile, access, mkdir, cp, stat } from 'node:fs/promises'
2
+ import { join, dirname, basename } from 'node:path'
3
+ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
4
+ import { Errors } from 'incur'
5
+ import { paths } from './paths.js'
6
+ import { normalizeSlug, requireBrainjarDir, readState, writeState, withStateLock } from './state.js'
7
+ import { readBrain, type BrainConfig } from './brain.js'
8
+ import { sync } from './sync.js'
9
+
10
+ const { IncurError } = Errors
11
+
12
+ const SEMVER_RE = /^\d+\.\d+\.\d+$/
13
+
14
+ export interface PackManifest {
15
+ name: string
16
+ version: string
17
+ description?: string
18
+ author?: string
19
+ brain: string
20
+ contents: {
21
+ soul: string
22
+ persona: string
23
+ rules: string[]
24
+ }
25
+ }
26
+
27
+ interface PackFile {
28
+ /** Relative path within the pack directory (e.g. "souls/craftsman.md") */
29
+ rel: string
30
+ /** Absolute source path in ~/.brainjar/ */
31
+ src: string
32
+ /** Whether this is a directory (rule pack) */
33
+ isDir: boolean
34
+ }
35
+
36
+ export interface ExportOptions {
37
+ out?: string
38
+ name?: string
39
+ version?: string
40
+ author?: string
41
+ }
42
+
43
+ export interface ExportResult {
44
+ exported: string
45
+ path: string
46
+ brain: string
47
+ contents: { soul: string; persona: string; rules: string[] }
48
+ warnings: string[]
49
+ }
50
+
51
+ export interface ImportOptions {
52
+ force?: boolean
53
+ merge?: boolean
54
+ activate?: boolean
55
+ }
56
+
57
+ interface Conflict {
58
+ rel: string
59
+ target: string
60
+ }
61
+
62
+ export interface ImportResult {
63
+ imported: string
64
+ from: string
65
+ brain: string
66
+ written: string[]
67
+ skipped: string[]
68
+ overwritten: string[]
69
+ activated: boolean
70
+ warnings: string[]
71
+ }
72
+
73
+ /** Collect all files referenced by a brain config. */
74
+ async function collectFiles(brainName: string, config: BrainConfig): Promise<{ files: PackFile[]; warnings: string[] }> {
75
+ const files: PackFile[] = []
76
+ const warnings: string[] = []
77
+
78
+ // Brain YAML
79
+ files.push({
80
+ rel: `brains/${brainName}.yaml`,
81
+ src: join(paths.brains, `${brainName}.yaml`),
82
+ isDir: false,
83
+ })
84
+
85
+ // Soul
86
+ const soulPath = join(paths.souls, `${config.soul}.md`)
87
+ try {
88
+ await access(soulPath)
89
+ files.push({ rel: `souls/${config.soul}.md`, src: soulPath, isDir: false })
90
+ } catch {
91
+ throw new IncurError({
92
+ code: 'PACK_MISSING_SOUL',
93
+ message: `Brain "${brainName}" references soul "${config.soul}" which does not exist.`,
94
+ })
95
+ }
96
+
97
+ // Persona
98
+ const personaPath = join(paths.personas, `${config.persona}.md`)
99
+ try {
100
+ await access(personaPath)
101
+ files.push({ rel: `personas/${config.persona}.md`, src: personaPath, isDir: false })
102
+ } catch {
103
+ throw new IncurError({
104
+ code: 'PACK_MISSING_PERSONA',
105
+ message: `Brain "${brainName}" references persona "${config.persona}" which does not exist.`,
106
+ })
107
+ }
108
+
109
+ // Rules — soft failure
110
+ for (const rule of config.rules) {
111
+ const dirPath = join(paths.rules, rule)
112
+ const filePath = join(paths.rules, `${rule}.md`)
113
+
114
+ try {
115
+ const s = await stat(dirPath)
116
+ if (s.isDirectory()) {
117
+ const entries = await readdir(dirPath)
118
+ const mdFiles = entries.filter(f => f.endsWith('.md'))
119
+ if (mdFiles.length === 0) {
120
+ warnings.push(`Rule "${rule}" directory exists but contains no .md files — skipped.`)
121
+ continue
122
+ }
123
+ files.push({ rel: `rules/${rule}`, src: dirPath, isDir: true })
124
+ continue
125
+ }
126
+ } catch {}
127
+
128
+ try {
129
+ await access(filePath)
130
+ files.push({ rel: `rules/${rule}.md`, src: filePath, isDir: false })
131
+ continue
132
+ } catch {}
133
+
134
+ warnings.push(`Rule "${rule}" not found — skipped.`)
135
+ }
136
+
137
+ return { files, warnings }
138
+ }
139
+
140
+ /** Export a brain as a pack directory. */
141
+ export async function exportPack(brainName: string, options: ExportOptions = {}): Promise<ExportResult> {
142
+ await requireBrainjarDir()
143
+
144
+ const slug = normalizeSlug(brainName, 'brain name')
145
+ const config = await readBrain(slug)
146
+ const packName = options.name ? normalizeSlug(options.name, 'pack name') : slug
147
+ const version = options.version ?? '0.1.0'
148
+
149
+ if (!SEMVER_RE.test(version)) {
150
+ throw new IncurError({
151
+ code: 'PACK_INVALID_VERSION',
152
+ message: `Invalid version "${version}". Expected semver (e.g., 0.1.0).`,
153
+ })
154
+ }
155
+
156
+ const { files, warnings } = await collectFiles(slug, config)
157
+
158
+ // Determine which rules actually made it into the pack
159
+ const exportedRules = files
160
+ .filter(f => f.rel.startsWith('rules/'))
161
+ .map(f => f.isDir ? basename(f.rel) : basename(f.rel, '.md'))
162
+
163
+ const parentDir = options.out ?? process.cwd()
164
+ const packDir = join(parentDir, packName)
165
+
166
+ try {
167
+ await access(packDir)
168
+ throw new IncurError({
169
+ code: 'PACK_DIR_EXISTS',
170
+ message: `Pack directory "${packDir}" already exists.`,
171
+ hint: 'Remove it first or use a different --out path.',
172
+ })
173
+ } catch (e) {
174
+ if (e instanceof IncurError) throw e
175
+ }
176
+
177
+ // Create pack directory structure
178
+ await mkdir(packDir, { recursive: true })
179
+
180
+ for (const file of files) {
181
+ const dest = join(packDir, file.rel)
182
+ await mkdir(dirname(dest), { recursive: true })
183
+ if (file.isDir) {
184
+ await cp(file.src, dest, { recursive: true })
185
+ } else {
186
+ await cp(file.src, dest)
187
+ }
188
+ }
189
+
190
+ // Write manifest
191
+ const manifest: PackManifest = {
192
+ name: packName,
193
+ version,
194
+ ...(options.author ? { author: options.author } : {}),
195
+ brain: slug,
196
+ contents: {
197
+ soul: config.soul,
198
+ persona: config.persona,
199
+ rules: exportedRules,
200
+ },
201
+ }
202
+
203
+ await writeFile(join(packDir, 'pack.yaml'), stringifyYaml(manifest))
204
+
205
+ return {
206
+ exported: packName,
207
+ path: packDir,
208
+ brain: slug,
209
+ contents: manifest.contents,
210
+ warnings,
211
+ }
212
+ }
213
+
214
+ /** Read and validate a pack.yaml manifest. */
215
+ export async function readManifest(packDir: string): Promise<PackManifest> {
216
+ const manifestPath = join(packDir, 'pack.yaml')
217
+
218
+ let raw: string
219
+ try {
220
+ raw = await readFile(manifestPath, 'utf-8')
221
+ } catch {
222
+ throw new IncurError({
223
+ code: 'PACK_NO_MANIFEST',
224
+ message: `No pack.yaml found in "${packDir}". Is this a brainjar pack?`,
225
+ })
226
+ }
227
+
228
+ let parsed: unknown
229
+ try {
230
+ parsed = parseYaml(raw)
231
+ } catch (e) {
232
+ throw new IncurError({
233
+ code: 'PACK_CORRUPT_MANIFEST',
234
+ message: `pack.yaml is corrupt: ${(e as Error).message}`,
235
+ })
236
+ }
237
+
238
+ if (!parsed || typeof parsed !== 'object') {
239
+ throw new IncurError({
240
+ code: 'PACK_CORRUPT_MANIFEST',
241
+ message: 'pack.yaml is empty or invalid.',
242
+ })
243
+ }
244
+
245
+ const p = parsed as Record<string, unknown>
246
+
247
+ for (const field of ['name', 'version', 'brain'] as const) {
248
+ if (typeof p[field] !== 'string' || !p[field]) {
249
+ throw new IncurError({
250
+ code: 'PACK_INVALID_MANIFEST',
251
+ message: `pack.yaml is missing required field "${field}".`,
252
+ })
253
+ }
254
+ }
255
+
256
+ if (!SEMVER_RE.test(p.version as string)) {
257
+ throw new IncurError({
258
+ code: 'PACK_INVALID_VERSION',
259
+ message: `Invalid version "${p.version}" in pack.yaml. Expected semver (e.g., 0.1.0).`,
260
+ })
261
+ }
262
+
263
+ const contents = p.contents as Record<string, unknown> | undefined
264
+ if (!contents || typeof contents !== 'object') {
265
+ throw new IncurError({
266
+ code: 'PACK_INVALID_MANIFEST',
267
+ message: 'pack.yaml is missing required field "contents".',
268
+ })
269
+ }
270
+
271
+ if (typeof contents.soul !== 'string' || !contents.soul) {
272
+ throw new IncurError({
273
+ code: 'PACK_INVALID_MANIFEST',
274
+ message: 'pack.yaml is missing required field "contents.soul".',
275
+ })
276
+ }
277
+
278
+ if (typeof contents.persona !== 'string' || !contents.persona) {
279
+ throw new IncurError({
280
+ code: 'PACK_INVALID_MANIFEST',
281
+ message: 'pack.yaml is missing required field "contents.persona".',
282
+ })
283
+ }
284
+
285
+ if (!Array.isArray(contents.rules)) {
286
+ throw new IncurError({
287
+ code: 'PACK_INVALID_MANIFEST',
288
+ message: 'pack.yaml is missing required field "contents.rules".',
289
+ })
290
+ }
291
+
292
+ const rules = contents.rules.map(String)
293
+
294
+ // Validate all slugs to prevent path traversal from untrusted pack.yaml
295
+ normalizeSlug(p.name as string, 'pack name')
296
+ normalizeSlug(p.brain as string, 'brain name')
297
+ normalizeSlug(contents.soul as string, 'soul name')
298
+ normalizeSlug(contents.persona as string, 'persona name')
299
+ for (const rule of rules) {
300
+ normalizeSlug(rule, 'rule name')
301
+ }
302
+
303
+ return {
304
+ name: p.name as string,
305
+ version: p.version as string,
306
+ ...(p.description ? { description: p.description as string } : {}),
307
+ ...(p.author ? { author: p.author as string } : {}),
308
+ brain: p.brain as string,
309
+ contents: {
310
+ soul: contents.soul as string,
311
+ persona: contents.persona as string,
312
+ rules,
313
+ },
314
+ }
315
+ }
316
+
317
+ /** Validate that all files declared in the manifest exist in the pack directory. */
318
+ async function validatePackFiles(packDir: string, manifest: PackManifest): Promise<void> {
319
+ // Brain YAML
320
+ const brainFile = join(packDir, 'brains', `${manifest.brain}.yaml`)
321
+ try {
322
+ await access(brainFile)
323
+ } catch {
324
+ throw new IncurError({
325
+ code: 'PACK_MISSING_FILE',
326
+ message: `Pack declares brain "${manifest.brain}" but brains/${manifest.brain}.yaml is missing.`,
327
+ })
328
+ }
329
+
330
+ // Soul
331
+ const soulFile = join(packDir, 'souls', `${manifest.contents.soul}.md`)
332
+ try {
333
+ await access(soulFile)
334
+ } catch {
335
+ throw new IncurError({
336
+ code: 'PACK_MISSING_FILE',
337
+ message: `Pack declares soul "${manifest.contents.soul}" but souls/${manifest.contents.soul}.md is missing.`,
338
+ })
339
+ }
340
+
341
+ // Persona
342
+ const personaFile = join(packDir, 'personas', `${manifest.contents.persona}.md`)
343
+ try {
344
+ await access(personaFile)
345
+ } catch {
346
+ throw new IncurError({
347
+ code: 'PACK_MISSING_FILE',
348
+ message: `Pack declares persona "${manifest.contents.persona}" but personas/${manifest.contents.persona}.md is missing.`,
349
+ })
350
+ }
351
+
352
+ // Rules
353
+ for (const rule of manifest.contents.rules) {
354
+ const dirPath = join(packDir, 'rules', rule)
355
+ const filePath = join(packDir, 'rules', `${rule}.md`)
356
+
357
+ let found = false
358
+ try {
359
+ const s = await stat(dirPath)
360
+ if (s.isDirectory()) found = true
361
+ } catch {}
362
+
363
+ if (!found) {
364
+ try {
365
+ await access(filePath)
366
+ found = true
367
+ } catch {}
368
+ }
369
+
370
+ if (!found) {
371
+ throw new IncurError({
372
+ code: 'PACK_MISSING_FILE',
373
+ message: `Pack declares rule "${rule}" but neither rules/${rule}/ nor rules/${rule}.md exists.`,
374
+ })
375
+ }
376
+ }
377
+ }
378
+
379
+ /** Collect all importable files from a validated pack. Returns relative paths. */
380
+ async function collectImportFiles(packDir: string, manifest: PackManifest): Promise<PackFile[]> {
381
+ const files: PackFile[] = []
382
+
383
+ // Brain
384
+ files.push({
385
+ rel: `brains/${manifest.brain}.yaml`,
386
+ src: join(packDir, 'brains', `${manifest.brain}.yaml`),
387
+ isDir: false,
388
+ })
389
+
390
+ // Soul
391
+ files.push({
392
+ rel: `souls/${manifest.contents.soul}.md`,
393
+ src: join(packDir, 'souls', `${manifest.contents.soul}.md`),
394
+ isDir: false,
395
+ })
396
+
397
+ // Persona
398
+ files.push({
399
+ rel: `personas/${manifest.contents.persona}.md`,
400
+ src: join(packDir, 'personas', `${manifest.contents.persona}.md`),
401
+ isDir: false,
402
+ })
403
+
404
+ // Rules
405
+ for (const rule of manifest.contents.rules) {
406
+ const dirPath = join(packDir, 'rules', rule)
407
+ try {
408
+ const s = await stat(dirPath)
409
+ if (s.isDirectory()) {
410
+ files.push({ rel: `rules/${rule}`, src: dirPath, isDir: true })
411
+ continue
412
+ }
413
+ } catch {}
414
+
415
+ files.push({ rel: `rules/${rule}.md`, src: join(packDir, 'rules', `${rule}.md`), isDir: false })
416
+ }
417
+
418
+ return files
419
+ }
420
+
421
+ /** Compare file content to detect conflicts. For directories, compares each .md file. */
422
+ async function detectConflicts(files: PackFile[]): Promise<{ conflicts: Conflict[]; skippedRels: Set<string>; skippedLabels: string[] }> {
423
+ const conflicts: Conflict[] = []
424
+ const skippedRels = new Set<string>()
425
+ const skippedLabels: string[] = []
426
+
427
+ for (const file of files) {
428
+ const target = join(paths.root, file.rel)
429
+
430
+ if (file.isDir) {
431
+ const srcEntries = await readdir(file.src)
432
+ for (const entry of srcEntries.filter(f => f.endsWith('.md'))) {
433
+ const rel = `${file.rel}/${entry}`
434
+ const srcContent = await readFile(join(file.src, entry), 'utf-8')
435
+ const targetFile = join(target, entry)
436
+ try {
437
+ const targetContent = await readFile(targetFile, 'utf-8')
438
+ if (srcContent === targetContent) {
439
+ skippedRels.add(rel)
440
+ skippedLabels.push(`${rel} (identical)`)
441
+ } else {
442
+ conflicts.push({ rel, target: targetFile })
443
+ }
444
+ } catch {
445
+ // Doesn't exist — will be copied
446
+ }
447
+ }
448
+ } else {
449
+ try {
450
+ const srcContent = await readFile(file.src, 'utf-8')
451
+ const targetContent = await readFile(target, 'utf-8')
452
+ if (srcContent === targetContent) {
453
+ skippedRels.add(file.rel)
454
+ skippedLabels.push(`${file.rel} (identical)`)
455
+ } else {
456
+ conflicts.push({ rel: file.rel, target })
457
+ }
458
+ } catch {
459
+ // Doesn't exist — will be copied
460
+ }
461
+ }
462
+ }
463
+
464
+ return { conflicts, skippedRels, skippedLabels }
465
+ }
466
+
467
+ /** Generate a non-conflicting merge name. */
468
+ async function findMergeName(basePath: string, slug: string, packName: string, ext: string): Promise<string> {
469
+ let candidate = `${slug}-from-${packName}`
470
+ let suffix = 2
471
+
472
+ while (true) {
473
+ const candidatePath = join(basePath, `${candidate}${ext}`)
474
+ try {
475
+ await access(candidatePath)
476
+ candidate = `${slug}-from-${packName}-${suffix}`
477
+ suffix++
478
+ } catch {
479
+ return candidate
480
+ }
481
+ }
482
+ }
483
+
484
+ /** Import a pack directory into ~/.brainjar/. */
485
+ export async function importPack(packDir: string, options: ImportOptions = {}): Promise<ImportResult> {
486
+ await requireBrainjarDir()
487
+
488
+ if (options.force && options.merge) {
489
+ throw new IncurError({
490
+ code: 'PACK_INVALID_OPTIONS',
491
+ message: '--force and --merge are mutually exclusive.',
492
+ })
493
+ }
494
+
495
+ // Validate path
496
+ try {
497
+ const s = await stat(packDir)
498
+ if (!s.isDirectory()) {
499
+ throw new IncurError({
500
+ code: 'PACK_NOT_DIR',
501
+ message: `Pack path "${packDir}" is a file, not a directory. Packs are directories.`,
502
+ })
503
+ }
504
+ } catch (e) {
505
+ if (e instanceof IncurError) throw e
506
+ throw new IncurError({
507
+ code: 'PACK_NOT_FOUND',
508
+ message: `Pack path "${packDir}" does not exist.`,
509
+ })
510
+ }
511
+
512
+ const manifest = await readManifest(packDir)
513
+ await validatePackFiles(packDir, manifest)
514
+
515
+ const files = await collectImportFiles(packDir, manifest)
516
+ const { conflicts, skippedRels, skippedLabels } = await detectConflicts(files)
517
+
518
+ const written: string[] = []
519
+ const overwritten: string[] = []
520
+ const warnings: string[] = []
521
+
522
+ if (conflicts.length > 0 && !options.force && !options.merge) {
523
+ const list = conflicts.map(c => ` ${c.rel}`).join('\n')
524
+ throw new IncurError({
525
+ code: 'PACK_CONFLICTS',
526
+ message: `Import blocked — ${conflicts.length} file(s) conflict with existing content:\n${list}`,
527
+ hint: 'Use --force to overwrite or --merge to keep both.',
528
+ })
529
+ }
530
+
531
+ // Build rename map for merge mode
532
+ const renameMap = new Map<string, string>()
533
+ const conflictRels = new Set(conflicts.map(c => c.rel))
534
+
535
+ if (options.merge && conflicts.length > 0) {
536
+ for (const conflict of conflicts) {
537
+ const parts = conflict.rel.split('/')
538
+ const fileName = parts[parts.length - 1]
539
+ const parentRel = parts.slice(0, -1).join('/')
540
+ const parentAbs = join(paths.root, parentRel)
541
+ const ext = fileName.endsWith('.yaml') ? '.yaml' : '.md'
542
+ const slug = basename(fileName, ext)
543
+
544
+ const newSlug = await findMergeName(parentAbs, slug, manifest.name, ext)
545
+ renameMap.set(conflict.rel, newSlug)
546
+ }
547
+ }
548
+
549
+ // Track which files are handled by brain patching so we don't write them twice
550
+ const brainRel = `brains/${manifest.brain}.yaml`
551
+ const needsBrainPatch = options.merge && renameMap.size > 0
552
+
553
+ // Copy files
554
+ for (const file of files) {
555
+ const target = join(paths.root, file.rel)
556
+ await mkdir(dirname(target), { recursive: true })
557
+
558
+ if (file.isDir) {
559
+ const srcEntries = await readdir(file.src)
560
+ for (const entry of srcEntries.filter(f => f.endsWith('.md'))) {
561
+ const srcFile = join(file.src, entry)
562
+ const rel = `${file.rel}/${entry}`
563
+ const targetFile = join(target, entry)
564
+
565
+ if (skippedRels.has(rel)) continue
566
+
567
+ if (conflictRels.has(rel)) {
568
+ if (options.force) {
569
+ await cp(srcFile, targetFile)
570
+ overwritten.push(targetFile)
571
+ } else if (options.merge) {
572
+ const newSlug = renameMap.get(rel)!
573
+ const newTarget = join(target, `${newSlug}.md`)
574
+ await cp(srcFile, newTarget)
575
+ written.push(newTarget)
576
+ }
577
+ } else {
578
+ await mkdir(target, { recursive: true })
579
+ await cp(srcFile, targetFile)
580
+ written.push(targetFile)
581
+ }
582
+ }
583
+ } else {
584
+ if (skippedRels.has(file.rel)) continue
585
+
586
+ // Skip brain file copy if we'll write a patched version later
587
+ if (needsBrainPatch && file.rel === brainRel && !conflictRels.has(brainRel)) continue
588
+
589
+ if (conflictRels.has(file.rel)) {
590
+ if (options.force) {
591
+ await cp(file.src, target)
592
+ overwritten.push(target)
593
+ } else if (options.merge) {
594
+ // Brain will be handled by patching below
595
+ if (file.rel === brainRel) continue
596
+
597
+ const newSlug = renameMap.get(file.rel)!
598
+ const ext = file.rel.endsWith('.yaml') ? '.yaml' : '.md'
599
+ const parentRel = dirname(file.rel)
600
+ const newTarget = join(paths.root, parentRel, `${newSlug}${ext}`)
601
+ await cp(file.src, newTarget)
602
+ written.push(newTarget)
603
+ }
604
+ } else {
605
+ await cp(file.src, target)
606
+ written.push(target)
607
+ }
608
+ }
609
+ }
610
+
611
+ // In merge mode, write a patched brain YAML with renamed references
612
+ if (needsBrainPatch) {
613
+ const brainRenamed = renameMap.get(brainRel)
614
+
615
+ const packBrainContent = await readFile(join(packDir, 'brains', `${manifest.brain}.yaml`), 'utf-8')
616
+ const brainConfig = parseYaml(packBrainContent) as Record<string, unknown>
617
+
618
+ // Patch soul reference
619
+ const soulRel = `souls/${manifest.contents.soul}.md`
620
+ if (renameMap.has(soulRel)) {
621
+ brainConfig.soul = renameMap.get(soulRel)
622
+ }
623
+
624
+ // Patch persona reference
625
+ const personaRel = `personas/${manifest.contents.persona}.md`
626
+ if (renameMap.has(personaRel)) {
627
+ brainConfig.persona = renameMap.get(personaRel)
628
+ }
629
+
630
+ // Patch rules (single-file rules only — directory rules keep their name)
631
+ if (Array.isArray(brainConfig.rules)) {
632
+ brainConfig.rules = (brainConfig.rules as string[]).map(rule => {
633
+ const fileRel = `rules/${rule}.md`
634
+ if (renameMap.has(fileRel)) return renameMap.get(fileRel)
635
+ return rule
636
+ })
637
+ }
638
+
639
+ // Write the patched brain (possibly renamed)
640
+ const brainSlug = brainRenamed ?? manifest.brain
641
+ const brainTarget = join(paths.brains, `${brainSlug}.yaml`)
642
+ await writeFile(brainTarget, stringifyYaml(brainConfig))
643
+ written.push(brainTarget)
644
+ }
645
+
646
+ // Activate brain if requested
647
+ let activated = false
648
+ if (options.activate) {
649
+ const config = await readBrain(manifest.brain)
650
+ await withStateLock(async () => {
651
+ const state = await readState()
652
+ state.soul = config.soul
653
+ state.persona = config.persona
654
+ state.rules = config.rules
655
+ await writeState(state)
656
+ await sync()
657
+ })
658
+ activated = true
659
+ }
660
+
661
+ return {
662
+ imported: manifest.name,
663
+ from: packDir,
664
+ brain: manifest.brain,
665
+ written,
666
+ skipped: skippedLabels,
667
+ overwritten,
668
+ activated,
669
+ warnings,
670
+ }
671
+ }
package/src/sync.ts CHANGED
@@ -80,10 +80,7 @@ export async function sync(options?: Backend | SyncOptions) {
80
80
  try {
81
81
  existingContent = await readFile(config.configFile, 'utf-8')
82
82
  } catch (e) {
83
- const code = (e as NodeJS.ErrnoException).code
84
- if (code !== 'ENOENT') {
85
- warnings.push(`Could not read existing config: ${(e as Error).message}`)
86
- }
83
+ if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e
87
84
  }
88
85
 
89
86
  // Backup existing config if it has no brainjar markers (first-time takeover)