lyra-core 0.1.2 → 0.1.11
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/package.json +1 -1
- package/src/brain/compaction.test.ts +0 -156
- package/src/brain/frozen-prefix.test.ts +0 -235
- package/src/brain/history-persist.test.ts +0 -129
- package/src/brain/openai-brain.test.ts +0 -61
- package/src/brain/sanitize.test.ts +0 -27
- package/src/claude-plugins/discovery.test.ts +0 -71
- package/src/commands/registry.test.ts +0 -186
- package/src/events/queue.test.ts +0 -43
- package/src/index.test.ts +0 -6
- package/src/locks.test.ts +0 -259
- package/src/mcp/discovery.test.ts +0 -97
- package/src/mcp/manager.test.ts +0 -97
- package/src/memory/edit.test.ts +0 -41
- package/src/memory/encryption.test.ts +0 -68
- package/src/memory/index-sync.test.ts +0 -81
- package/src/memory/pack-blob.test.ts +0 -90
- package/src/memory/pack-gather.test.ts +0 -110
- package/src/memory/parser.test.ts +0 -29
- package/src/memory/path-drift.test.ts +0 -71
- package/src/memory/read-tool-fallback.test.ts +0 -54
- package/src/memory/save-tool.test.ts +0 -193
- package/src/memory/scan.test.ts +0 -24
- package/src/migration/option3-crypto.test.ts +0 -106
- package/src/pairing.test.ts +0 -212
- package/src/permission/permission.test.ts +0 -299
- package/src/plugins/plugins.test.ts +0 -196
- package/src/public/card.test.ts +0 -70
- package/src/runtime/runtime.test.ts +0 -55
- package/src/sandbox/sandbox.test.ts +0 -386
- package/src/skills/scanner.test.ts +0 -137
- package/src/skills/triggers.test.ts +0 -77
- package/src/storage/encryption.test.ts +0 -30
- package/src/storage/sqlite.test.ts +0 -29
- package/src/tools/escalation.test.ts +0 -348
- package/src/tools/registry.test.ts +0 -70
- package/src/tools/zod-helpers.test.ts +0 -63
- package/src/wallet/drain.test.ts +0 -41
- package/src/wallet/keystore.test.ts +0 -17
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
|
2
|
-
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
3
|
-
import { tmpdir } from 'node:os'
|
|
4
|
-
import { join } from 'node:path'
|
|
5
|
-
import { discoverClaudeExtras } from './discovery'
|
|
6
|
-
|
|
7
|
-
let scratch: string
|
|
8
|
-
|
|
9
|
-
beforeEach(async () => {
|
|
10
|
-
scratch = await mkdtemp(join(tmpdir(), 'lyra-claude-extras-'))
|
|
11
|
-
})
|
|
12
|
-
|
|
13
|
-
afterEach(async () => {
|
|
14
|
-
await rm(scratch, { recursive: true, force: true })
|
|
15
|
-
})
|
|
16
|
-
|
|
17
|
-
describe('discoverClaudeExtras', () => {
|
|
18
|
-
it('discovers commands + agents from plugin cache layout', async () => {
|
|
19
|
-
const versionDir = join(scratch, 'cache', 'mp', 'plug', '0.1.0')
|
|
20
|
-
await mkdir(join(versionDir, 'commands'), { recursive: true })
|
|
21
|
-
await mkdir(join(versionDir, 'agents'), { recursive: true })
|
|
22
|
-
await writeFile(
|
|
23
|
-
join(versionDir, 'commands', 'setup.md'),
|
|
24
|
-
'---\nname: setup\ndescription: Build pragma\n---\n\n# Setup body\n',
|
|
25
|
-
)
|
|
26
|
-
await writeFile(
|
|
27
|
-
join(versionDir, 'agents', 'thymos.md'),
|
|
28
|
-
'---\nname: thymos\ndescription: scalper\nmodel: sonnet\n---\n\n# Agent body\n',
|
|
29
|
-
)
|
|
30
|
-
const out = await discoverClaudeExtras({
|
|
31
|
-
claudePluginsCacheRoot: join(scratch, 'cache'),
|
|
32
|
-
importsClaudeCode: true,
|
|
33
|
-
})
|
|
34
|
-
expect(out.commands).toHaveLength(1)
|
|
35
|
-
expect(out.commands[0]!).toMatchObject({
|
|
36
|
-
id: 'plug:setup',
|
|
37
|
-
name: 'setup',
|
|
38
|
-
description: 'Build pragma',
|
|
39
|
-
source: { marketplace: 'mp', plugin: 'plug', version: '0.1.0' },
|
|
40
|
-
})
|
|
41
|
-
expect(out.commands[0]!.body).toContain('Setup body')
|
|
42
|
-
expect(out.agents).toHaveLength(1)
|
|
43
|
-
expect(out.agents[0]!).toMatchObject({
|
|
44
|
-
id: 'plug:thymos',
|
|
45
|
-
name: 'thymos',
|
|
46
|
-
model: 'sonnet',
|
|
47
|
-
})
|
|
48
|
-
expect(out.agents[0]!.body).toContain('Agent body')
|
|
49
|
-
})
|
|
50
|
-
|
|
51
|
-
it('returns empty when imports.claudeCode is false', async () => {
|
|
52
|
-
const out = await discoverClaudeExtras({
|
|
53
|
-
claudePluginsCacheRoot: join(scratch, 'cache'),
|
|
54
|
-
importsClaudeCode: false,
|
|
55
|
-
})
|
|
56
|
-
expect(out).toEqual({ commands: [], agents: [] })
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
it('skips files without frontmatter (still parses but with empty meta)', async () => {
|
|
60
|
-
const versionDir = join(scratch, 'cache', 'mp', 'plug', '0.1.0')
|
|
61
|
-
await mkdir(join(versionDir, 'commands'), { recursive: true })
|
|
62
|
-
await writeFile(join(versionDir, 'commands', 'foo.md'), 'no frontmatter just body')
|
|
63
|
-
const out = await discoverClaudeExtras({
|
|
64
|
-
claudePluginsCacheRoot: join(scratch, 'cache'),
|
|
65
|
-
importsClaudeCode: true,
|
|
66
|
-
})
|
|
67
|
-
expect(out.commands).toHaveLength(1)
|
|
68
|
-
expect(out.commands[0]!.name).toBe('foo')
|
|
69
|
-
expect(out.commands[0]!.body).toBe('no frontmatter just body')
|
|
70
|
-
})
|
|
71
|
-
})
|
|
@@ -1,186 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'bun:test'
|
|
2
|
-
import {
|
|
3
|
-
COMMAND_REGISTRY,
|
|
4
|
-
commandsForSurface,
|
|
5
|
-
findCommand,
|
|
6
|
-
parseSlash,
|
|
7
|
-
suggestForPrefix,
|
|
8
|
-
} from './registry'
|
|
9
|
-
|
|
10
|
-
describe('COMMAND_REGISTRY', () => {
|
|
11
|
-
it('has no duplicate names', () => {
|
|
12
|
-
const names = COMMAND_REGISTRY.map(c => c.name)
|
|
13
|
-
const set = new Set(names)
|
|
14
|
-
expect(set.size).toBe(names.length)
|
|
15
|
-
})
|
|
16
|
-
|
|
17
|
-
it('every entry has at least one surface', () => {
|
|
18
|
-
for (const c of COMMAND_REGISTRY) {
|
|
19
|
-
expect(c.surfaces.length).toBeGreaterThan(0)
|
|
20
|
-
}
|
|
21
|
-
})
|
|
22
|
-
|
|
23
|
-
it('every entry has a non-empty description', () => {
|
|
24
|
-
for (const c of COMMAND_REGISTRY) {
|
|
25
|
-
expect(c.description.length).toBeGreaterThan(0)
|
|
26
|
-
}
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
it('all names are lowercase, no leading slash', () => {
|
|
30
|
-
for (const c of COMMAND_REGISTRY) {
|
|
31
|
-
expect(c.name).toBe(c.name.toLowerCase())
|
|
32
|
-
expect(c.name.startsWith('/')).toBe(false)
|
|
33
|
-
}
|
|
34
|
-
})
|
|
35
|
-
|
|
36
|
-
it('contains the cross-surface bypass commands required for v0.20.0', () => {
|
|
37
|
-
expect(findCommand('yolo')).toBeDefined()
|
|
38
|
-
expect(findCommand('perms')).toBeDefined()
|
|
39
|
-
expect(findCommand('reset')).toBeDefined()
|
|
40
|
-
expect(findCommand('yolo')?.surfaces).toEqual(['tui', 'tg'])
|
|
41
|
-
expect(findCommand('perms')?.surfaces).toEqual(['tui', 'tg'])
|
|
42
|
-
expect(findCommand('reset')?.surfaces).toEqual(['tui', 'tg'])
|
|
43
|
-
})
|
|
44
|
-
})
|
|
45
|
-
|
|
46
|
-
describe('commandsForSurface', () => {
|
|
47
|
-
it('returns only TUI commands when surface=tui', () => {
|
|
48
|
-
const tui = commandsForSurface('tui')
|
|
49
|
-
expect(tui.length).toBeGreaterThan(0)
|
|
50
|
-
for (const c of tui) expect(c.surfaces).toContain('tui')
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
it('returns only TG commands when surface=tg', () => {
|
|
54
|
-
const tg = commandsForSurface('tg')
|
|
55
|
-
expect(tg.length).toBeGreaterThan(0)
|
|
56
|
-
for (const c of tg) expect(c.surfaces).toContain('tg')
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
it('cross-surface commands appear in both lists', () => {
|
|
60
|
-
const tui = commandsForSurface('tui').map(c => c.name)
|
|
61
|
-
const tg = commandsForSurface('tg').map(c => c.name)
|
|
62
|
-
expect(tui).toContain('yolo')
|
|
63
|
-
expect(tg).toContain('yolo')
|
|
64
|
-
expect(tui).toContain('perms')
|
|
65
|
-
expect(tg).toContain('perms')
|
|
66
|
-
expect(tui).toContain('reset')
|
|
67
|
-
expect(tg).toContain('reset')
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
it('TUI-only commands do not appear in TG', () => {
|
|
71
|
-
const tg = commandsForSurface('tg').map(c => c.name)
|
|
72
|
-
expect(tg).not.toContain('sync')
|
|
73
|
-
expect(tg).not.toContain('model')
|
|
74
|
-
expect(tg).not.toContain('jobs')
|
|
75
|
-
expect(tg).not.toContain('help')
|
|
76
|
-
})
|
|
77
|
-
|
|
78
|
-
it('TG-only commands do not appear in TUI', () => {
|
|
79
|
-
const tui = commandsForSurface('tui').map(c => c.name)
|
|
80
|
-
expect(tui).not.toContain('stop')
|
|
81
|
-
expect(tui).not.toContain('new')
|
|
82
|
-
expect(tui).not.toContain('status')
|
|
83
|
-
expect(tui).not.toContain('approve')
|
|
84
|
-
})
|
|
85
|
-
})
|
|
86
|
-
|
|
87
|
-
describe('findCommand', () => {
|
|
88
|
-
it('resolves bare names', () => {
|
|
89
|
-
expect(findCommand('yolo')?.name).toBe('yolo')
|
|
90
|
-
})
|
|
91
|
-
|
|
92
|
-
it('strips leading slashes', () => {
|
|
93
|
-
expect(findCommand('/yolo')?.name).toBe('yolo')
|
|
94
|
-
expect(findCommand('//yolo')?.name).toBe('yolo')
|
|
95
|
-
})
|
|
96
|
-
|
|
97
|
-
it('is case-insensitive', () => {
|
|
98
|
-
expect(findCommand('YOLO')?.name).toBe('yolo')
|
|
99
|
-
expect(findCommand('Yolo')?.name).toBe('yolo')
|
|
100
|
-
})
|
|
101
|
-
|
|
102
|
-
it('returns undefined for unknown names', () => {
|
|
103
|
-
expect(findCommand('definitely-not-a-command')).toBeUndefined()
|
|
104
|
-
})
|
|
105
|
-
})
|
|
106
|
-
|
|
107
|
-
describe('parseSlash', () => {
|
|
108
|
-
it('returns null for non-slash input', () => {
|
|
109
|
-
expect(parseSlash('hello')).toBeNull()
|
|
110
|
-
expect(parseSlash(' hi /yolo')).toBeNull()
|
|
111
|
-
})
|
|
112
|
-
|
|
113
|
-
it('returns null for empty slash', () => {
|
|
114
|
-
expect(parseSlash('/')).toBeNull()
|
|
115
|
-
expect(parseSlash(' / ')).toBeNull()
|
|
116
|
-
})
|
|
117
|
-
|
|
118
|
-
it('parses bare command name', () => {
|
|
119
|
-
const r = parseSlash('/yolo')
|
|
120
|
-
expect(r?.name).toBe('yolo')
|
|
121
|
-
expect(r?.args).toEqual([])
|
|
122
|
-
expect(r?.command?.name).toBe('yolo')
|
|
123
|
-
})
|
|
124
|
-
|
|
125
|
-
it('parses command with args', () => {
|
|
126
|
-
const r = parseSlash('/perms strict')
|
|
127
|
-
expect(r?.name).toBe('perms')
|
|
128
|
-
expect(r?.args).toEqual(['strict'])
|
|
129
|
-
expect(r?.command?.name).toBe('perms')
|
|
130
|
-
})
|
|
131
|
-
|
|
132
|
-
it('lowercases the command name', () => {
|
|
133
|
-
const r = parseSlash('/YOLO')
|
|
134
|
-
expect(r?.name).toBe('yolo')
|
|
135
|
-
})
|
|
136
|
-
|
|
137
|
-
it('preserves arg case', () => {
|
|
138
|
-
const r = parseSlash('/perms STRICT')
|
|
139
|
-
expect(r?.args).toEqual(['STRICT'])
|
|
140
|
-
})
|
|
141
|
-
|
|
142
|
-
it('returns parsed shape with undefined command for unknown names', () => {
|
|
143
|
-
const r = parseSlash('/wat is this')
|
|
144
|
-
expect(r?.name).toBe('wat')
|
|
145
|
-
expect(r?.args).toEqual(['is', 'this'])
|
|
146
|
-
expect(r?.command).toBeUndefined()
|
|
147
|
-
})
|
|
148
|
-
|
|
149
|
-
it('tolerates leading whitespace', () => {
|
|
150
|
-
const r = parseSlash(' /yolo on')
|
|
151
|
-
expect(r?.name).toBe('yolo')
|
|
152
|
-
expect(r?.args).toEqual(['on'])
|
|
153
|
-
})
|
|
154
|
-
})
|
|
155
|
-
|
|
156
|
-
describe('suggestForPrefix', () => {
|
|
157
|
-
it('returns all surface commands for empty query', () => {
|
|
158
|
-
const all = commandsForSurface('tui')
|
|
159
|
-
expect(suggestForPrefix('tui', '')).toEqual(all)
|
|
160
|
-
expect(suggestForPrefix('tui', '/')).toEqual(all)
|
|
161
|
-
})
|
|
162
|
-
|
|
163
|
-
it('filters by prefix', () => {
|
|
164
|
-
const out = suggestForPrefix('tui', 'y')
|
|
165
|
-
const names = out.map(c => c.name)
|
|
166
|
-
expect(names).toContain('yolo')
|
|
167
|
-
expect(names).not.toContain('perms')
|
|
168
|
-
})
|
|
169
|
-
|
|
170
|
-
it('strips leading slash from query', () => {
|
|
171
|
-
expect(suggestForPrefix('tui', '/y').map(c => c.name)).toContain('yolo')
|
|
172
|
-
})
|
|
173
|
-
|
|
174
|
-
it('is case-insensitive on query', () => {
|
|
175
|
-
expect(suggestForPrefix('tui', 'YO').map(c => c.name)).toContain('yolo')
|
|
176
|
-
})
|
|
177
|
-
|
|
178
|
-
it('returns empty when nothing matches', () => {
|
|
179
|
-
expect(suggestForPrefix('tui', 'xyz-no-match')).toEqual([])
|
|
180
|
-
})
|
|
181
|
-
|
|
182
|
-
it('respects surface filter', () => {
|
|
183
|
-
const tg = suggestForPrefix('tg', 'sy').map(c => c.name)
|
|
184
|
-
expect(tg).not.toContain('sync')
|
|
185
|
-
})
|
|
186
|
-
})
|
package/src/events/queue.test.ts
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import { expect, test } from 'bun:test'
|
|
2
|
-
import { EventQueue, newEventId } from './queue'
|
|
3
|
-
|
|
4
|
-
test('FIFO ordering', async () => {
|
|
5
|
-
const q = new EventQueue()
|
|
6
|
-
for (let i = 0; i < 3; i++) {
|
|
7
|
-
q.enqueue({
|
|
8
|
-
id: newEventId(),
|
|
9
|
-
source: 'stdin',
|
|
10
|
-
payload: { label: `e${i}`, data: i },
|
|
11
|
-
ts: Date.now(),
|
|
12
|
-
})
|
|
13
|
-
}
|
|
14
|
-
const a = await q.dequeue()
|
|
15
|
-
const b = await q.dequeue()
|
|
16
|
-
const c = await q.dequeue()
|
|
17
|
-
expect(a.payload.label).toBe('e0')
|
|
18
|
-
expect(b.payload.label).toBe('e1')
|
|
19
|
-
expect(c.payload.label).toBe('e2')
|
|
20
|
-
})
|
|
21
|
-
|
|
22
|
-
test('dequeue awaits when empty', async () => {
|
|
23
|
-
const q = new EventQueue()
|
|
24
|
-
const pending = q.dequeue()
|
|
25
|
-
setTimeout(() => {
|
|
26
|
-
q.enqueue({
|
|
27
|
-
id: 'late',
|
|
28
|
-
source: 'stdin',
|
|
29
|
-
payload: { label: 'late', data: null },
|
|
30
|
-
ts: Date.now(),
|
|
31
|
-
})
|
|
32
|
-
}, 10)
|
|
33
|
-
const ev = await pending
|
|
34
|
-
expect(ev.payload.label).toBe('late')
|
|
35
|
-
})
|
|
36
|
-
|
|
37
|
-
test('closed queue throws on enqueue', () => {
|
|
38
|
-
const q = new EventQueue()
|
|
39
|
-
q.close()
|
|
40
|
-
expect(() =>
|
|
41
|
-
q.enqueue({ id: 'x', source: 'stdin', payload: { label: 'x', data: null }, ts: 0 }),
|
|
42
|
-
).toThrow()
|
|
43
|
-
})
|
package/src/index.test.ts
DELETED
package/src/locks.test.ts
DELETED
|
@@ -1,259 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
|
2
|
-
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
|
-
import { tmpdir } from 'node:os'
|
|
4
|
-
import { join } from 'node:path'
|
|
5
|
-
import {
|
|
6
|
-
DEFAULT_LOCK_TTL_SECONDS,
|
|
7
|
-
acquireScopedLock,
|
|
8
|
-
clearStaleScopedLock,
|
|
9
|
-
isZombieLinux,
|
|
10
|
-
} from './locks'
|
|
11
|
-
|
|
12
|
-
let testDir: string
|
|
13
|
-
|
|
14
|
-
beforeEach(() => {
|
|
15
|
-
testDir = mkdtempSync(join(tmpdir(), 'lyra-locks-test-'))
|
|
16
|
-
})
|
|
17
|
-
|
|
18
|
-
afterEach(() => {
|
|
19
|
-
rmSync(testDir, { recursive: true, force: true })
|
|
20
|
-
})
|
|
21
|
-
|
|
22
|
-
describe('acquireScopedLock', () => {
|
|
23
|
-
it('acquires when no prior holder exists', () => {
|
|
24
|
-
const r = acquireScopedLock({ scope: 'test', identity: 'token-a', rootDir: testDir })
|
|
25
|
-
expect(r.acquired).toBe(true)
|
|
26
|
-
expect(r.handle).toBeDefined()
|
|
27
|
-
r.handle?.releaseFn()
|
|
28
|
-
})
|
|
29
|
-
|
|
30
|
-
it('rejects when another live process holds it', () => {
|
|
31
|
-
const r1 = acquireScopedLock({ scope: 'test', identity: 'token-a', rootDir: testDir })
|
|
32
|
-
expect(r1.acquired).toBe(true)
|
|
33
|
-
|
|
34
|
-
const r2 = acquireScopedLock({ scope: 'test', identity: 'token-a', rootDir: testDir })
|
|
35
|
-
expect(r2.acquired).toBe(false)
|
|
36
|
-
expect(r2.existing?.pid).toBe(process.pid)
|
|
37
|
-
|
|
38
|
-
r1.handle?.releaseFn()
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
it('different identities do not collide on same scope', () => {
|
|
42
|
-
const a = acquireScopedLock({ scope: 'test', identity: 'token-a', rootDir: testDir })
|
|
43
|
-
const b = acquireScopedLock({ scope: 'test', identity: 'token-b', rootDir: testDir })
|
|
44
|
-
expect(a.acquired).toBe(true)
|
|
45
|
-
expect(b.acquired).toBe(true)
|
|
46
|
-
a.handle?.releaseFn()
|
|
47
|
-
b.handle?.releaseFn()
|
|
48
|
-
})
|
|
49
|
-
|
|
50
|
-
it('release allows re-acquire', () => {
|
|
51
|
-
const r1 = acquireScopedLock({ scope: 'test', identity: 'token-a', rootDir: testDir })
|
|
52
|
-
expect(r1.acquired).toBe(true)
|
|
53
|
-
r1.handle?.releaseFn()
|
|
54
|
-
|
|
55
|
-
const r2 = acquireScopedLock({ scope: 'test', identity: 'token-a', rootDir: testDir })
|
|
56
|
-
expect(r2.acquired).toBe(true)
|
|
57
|
-
r2.handle?.releaseFn()
|
|
58
|
-
})
|
|
59
|
-
|
|
60
|
-
it('release is idempotent', () => {
|
|
61
|
-
const r1 = acquireScopedLock({ scope: 'test', identity: 'token-a', rootDir: testDir })
|
|
62
|
-
r1.handle?.releaseFn()
|
|
63
|
-
r1.handle?.releaseFn()
|
|
64
|
-
const r2 = acquireScopedLock({ scope: 'test', identity: 'token-a', rootDir: testDir })
|
|
65
|
-
expect(r2.acquired).toBe(true)
|
|
66
|
-
r2.handle?.releaseFn()
|
|
67
|
-
})
|
|
68
|
-
|
|
69
|
-
it('reclaims a stale lock from a dead PID', () => {
|
|
70
|
-
// Manually plant a lock file with a fake PID that won't exist
|
|
71
|
-
const fakePid = 999999
|
|
72
|
-
const lockFile = findLockFile(testDir, 'test', 'token-a')
|
|
73
|
-
writeFileSync(
|
|
74
|
-
lockFile,
|
|
75
|
-
JSON.stringify({
|
|
76
|
-
pid: fakePid,
|
|
77
|
-
scope: 'test',
|
|
78
|
-
identityHash: 'fake',
|
|
79
|
-
startedAt: 0,
|
|
80
|
-
updatedAt: 0,
|
|
81
|
-
ttl: DEFAULT_LOCK_TTL_SECONDS,
|
|
82
|
-
}),
|
|
83
|
-
)
|
|
84
|
-
|
|
85
|
-
const r = acquireScopedLock({ scope: 'test', identity: 'token-a', rootDir: testDir })
|
|
86
|
-
expect(r.acquired).toBe(true)
|
|
87
|
-
r.handle?.releaseFn()
|
|
88
|
-
})
|
|
89
|
-
|
|
90
|
-
it('reclaims a TTL-expired lock from a live process', () => {
|
|
91
|
-
// Plant a lock file with a real PID but ancient updatedAt
|
|
92
|
-
const lockFile = findLockFile(testDir, 'test', 'token-a')
|
|
93
|
-
const veryOld = Math.floor(Date.now() / 1000) - 99999
|
|
94
|
-
writeFileSync(
|
|
95
|
-
lockFile,
|
|
96
|
-
JSON.stringify({
|
|
97
|
-
pid: process.pid,
|
|
98
|
-
scope: 'test',
|
|
99
|
-
identityHash: 'fake',
|
|
100
|
-
startedAt: veryOld,
|
|
101
|
-
updatedAt: veryOld,
|
|
102
|
-
ttl: 60,
|
|
103
|
-
}),
|
|
104
|
-
)
|
|
105
|
-
|
|
106
|
-
const r = acquireScopedLock({ scope: 'test', identity: 'token-a', rootDir: testDir })
|
|
107
|
-
expect(r.acquired).toBe(true)
|
|
108
|
-
r.handle?.releaseFn()
|
|
109
|
-
})
|
|
110
|
-
|
|
111
|
-
it('refreshFn returns true while owner holds the lock', () => {
|
|
112
|
-
const r = acquireScopedLock({ scope: 'test', identity: 'token-a', rootDir: testDir })
|
|
113
|
-
expect(r.handle?.refreshFn()).toBe(true)
|
|
114
|
-
r.handle?.releaseFn()
|
|
115
|
-
})
|
|
116
|
-
|
|
117
|
-
it('refreshFn returns false after release', () => {
|
|
118
|
-
const r = acquireScopedLock({ scope: 'test', identity: 'token-a', rootDir: testDir })
|
|
119
|
-
r.handle?.releaseFn()
|
|
120
|
-
expect(r.handle?.refreshFn()).toBe(false)
|
|
121
|
-
})
|
|
122
|
-
|
|
123
|
-
it('hashes the identity into the lock filename', () => {
|
|
124
|
-
const a = acquireScopedLock({ scope: 'test', identity: 'token-a', rootDir: testDir })
|
|
125
|
-
const b = acquireScopedLock({ scope: 'test', identity: 'token-different', rootDir: testDir })
|
|
126
|
-
expect(a.acquired).toBe(true)
|
|
127
|
-
expect(b.acquired).toBe(true)
|
|
128
|
-
a.handle?.releaseFn()
|
|
129
|
-
b.handle?.releaseFn()
|
|
130
|
-
})
|
|
131
|
-
|
|
132
|
-
it('records stable shape: pid + startedAt + updatedAt + ttl', () => {
|
|
133
|
-
const r = acquireScopedLock({ scope: 'test', identity: 'token-a', rootDir: testDir, ttl: 600 })
|
|
134
|
-
expect(r.acquired).toBe(true)
|
|
135
|
-
r.handle?.releaseFn()
|
|
136
|
-
})
|
|
137
|
-
|
|
138
|
-
it('respects custom rootDir', () => {
|
|
139
|
-
const r = acquireScopedLock({ scope: 'test', identity: 'token-a', rootDir: testDir })
|
|
140
|
-
expect(r.acquired).toBe(true)
|
|
141
|
-
r.handle?.releaseFn()
|
|
142
|
-
})
|
|
143
|
-
})
|
|
144
|
-
|
|
145
|
-
describe('isZombieLinux', () => {
|
|
146
|
-
it('returns false on non-linux platforms', () => {
|
|
147
|
-
if (process.platform === 'linux') return // platform-specific guard
|
|
148
|
-
expect(isZombieLinux(process.pid)).toBe(false)
|
|
149
|
-
})
|
|
150
|
-
|
|
151
|
-
it('returns false for the live current process on linux', () => {
|
|
152
|
-
if (process.platform !== 'linux') return
|
|
153
|
-
expect(isZombieLinux(process.pid)).toBe(false)
|
|
154
|
-
})
|
|
155
|
-
|
|
156
|
-
it('returns false when /proc/<pid>/status is unreadable', () => {
|
|
157
|
-
// pid 999999 vanishingly unlikely to exist; readFile will throw.
|
|
158
|
-
expect(isZombieLinux(999_999)).toBe(false)
|
|
159
|
-
})
|
|
160
|
-
})
|
|
161
|
-
|
|
162
|
-
describe('acquireScopedLock evicts dead-pid lock', () => {
|
|
163
|
-
it('reclaims when the lockfile records a pid that no longer exists', () => {
|
|
164
|
-
// Take the lock, write a tampered record claiming a vanishingly-unlikely
|
|
165
|
-
// pid is the holder, then re-acquire. The stale-detection path
|
|
166
|
-
// (process.kill(pid, 0) → ESRCH) should treat it as gone and reclaim.
|
|
167
|
-
const path = findLockFile(testDir, 'test', 'dead-pid')
|
|
168
|
-
writeFileSync(
|
|
169
|
-
path,
|
|
170
|
-
JSON.stringify({
|
|
171
|
-
pid: 999_998, // unlikely to exist on the test host
|
|
172
|
-
scope: 'test',
|
|
173
|
-
identityHash: 'whatever',
|
|
174
|
-
startedAt: Math.floor(Date.now() / 1000),
|
|
175
|
-
updatedAt: Math.floor(Date.now() / 1000),
|
|
176
|
-
ttl: 300,
|
|
177
|
-
}),
|
|
178
|
-
)
|
|
179
|
-
const r = acquireScopedLock({ scope: 'test', identity: 'dead-pid', rootDir: testDir })
|
|
180
|
-
expect(r.acquired).toBe(true)
|
|
181
|
-
r.handle?.releaseFn()
|
|
182
|
-
})
|
|
183
|
-
})
|
|
184
|
-
|
|
185
|
-
describe('clearStaleScopedLock', () => {
|
|
186
|
-
it('returns no-lock when nothing exists', () => {
|
|
187
|
-
const r = clearStaleScopedLock({ scope: 'test', identity: 'fresh', rootDir: testDir })
|
|
188
|
-
expect(r.cleared).toBe(false)
|
|
189
|
-
expect(r.reason).toBe('no-lock')
|
|
190
|
-
})
|
|
191
|
-
|
|
192
|
-
it('returns alive-pid when a live PID holds the lock', () => {
|
|
193
|
-
const a = acquireScopedLock({ scope: 'test', identity: 'live', rootDir: testDir })
|
|
194
|
-
expect(a.acquired).toBe(true)
|
|
195
|
-
const r = clearStaleScopedLock({ scope: 'test', identity: 'live', rootDir: testDir })
|
|
196
|
-
expect(r.cleared).toBe(false)
|
|
197
|
-
expect(r.reason).toBe('alive-pid')
|
|
198
|
-
a.handle?.releaseFn()
|
|
199
|
-
})
|
|
200
|
-
|
|
201
|
-
it('clears a dead-PID lock and reports cleared-stale', () => {
|
|
202
|
-
const path = findLockFile(testDir, 'test', 'dead')
|
|
203
|
-
writeFileSync(
|
|
204
|
-
path,
|
|
205
|
-
JSON.stringify({
|
|
206
|
-
pid: 999_997,
|
|
207
|
-
scope: 'test',
|
|
208
|
-
identityHash: 'x',
|
|
209
|
-
startedAt: Math.floor(Date.now() / 1000),
|
|
210
|
-
updatedAt: Math.floor(Date.now() / 1000),
|
|
211
|
-
ttl: 600,
|
|
212
|
-
}),
|
|
213
|
-
)
|
|
214
|
-
const r = clearStaleScopedLock({ scope: 'test', identity: 'dead', rootDir: testDir })
|
|
215
|
-
expect(r.cleared).toBe(true)
|
|
216
|
-
expect(r.reason).toBe('cleared-stale')
|
|
217
|
-
// Re-clear should now return no-lock.
|
|
218
|
-
const r2 = clearStaleScopedLock({ scope: 'test', identity: 'dead', rootDir: testDir })
|
|
219
|
-
expect(r2.cleared).toBe(false)
|
|
220
|
-
expect(r2.reason).toBe('no-lock')
|
|
221
|
-
})
|
|
222
|
-
|
|
223
|
-
it('clears a TTL-expired lock and reports cleared-ttl', () => {
|
|
224
|
-
const path = findLockFile(testDir, 'test', 'expired')
|
|
225
|
-
const ancient = Math.floor(Date.now() / 1000) - 9999
|
|
226
|
-
writeFileSync(
|
|
227
|
-
path,
|
|
228
|
-
JSON.stringify({
|
|
229
|
-
pid: process.pid,
|
|
230
|
-
scope: 'test',
|
|
231
|
-
identityHash: 'x',
|
|
232
|
-
startedAt: ancient,
|
|
233
|
-
updatedAt: ancient,
|
|
234
|
-
ttl: 60,
|
|
235
|
-
}),
|
|
236
|
-
)
|
|
237
|
-
const r = clearStaleScopedLock({ scope: 'test', identity: 'expired', rootDir: testDir })
|
|
238
|
-
expect(r.cleared).toBe(true)
|
|
239
|
-
expect(r.reason).toBe('cleared-ttl')
|
|
240
|
-
})
|
|
241
|
-
|
|
242
|
-
it('does not delete a lock owned by current process within TTL', () => {
|
|
243
|
-
const a = acquireScopedLock({ scope: 'test', identity: 'mine', rootDir: testDir })
|
|
244
|
-
expect(a.acquired).toBe(true)
|
|
245
|
-
const r = clearStaleScopedLock({ scope: 'test', identity: 'mine', rootDir: testDir })
|
|
246
|
-
expect(r.cleared).toBe(false)
|
|
247
|
-
expect(r.reason).toBe('alive-pid')
|
|
248
|
-
// Lock still acquireable by us via refresh:
|
|
249
|
-
expect(a.handle?.refreshFn()).toBe(true)
|
|
250
|
-
a.handle?.releaseFn()
|
|
251
|
-
})
|
|
252
|
-
})
|
|
253
|
-
|
|
254
|
-
function findLockFile(dir: string, scope: string, identity: string): string {
|
|
255
|
-
// Mirror the internal hash logic (sha256 first 16 hex chars)
|
|
256
|
-
const { createHash } = require('node:crypto') as typeof import('node:crypto')
|
|
257
|
-
const hash = createHash('sha256').update(identity).digest('hex').slice(0, 16)
|
|
258
|
-
return join(dir, `${scope}-${hash}.lock`)
|
|
259
|
-
}
|
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
|
2
|
-
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
3
|
-
import { tmpdir } from 'node:os'
|
|
4
|
-
import { join } from 'node:path'
|
|
5
|
-
import { discoverMcpServers } from './discovery'
|
|
6
|
-
|
|
7
|
-
let scratch: string
|
|
8
|
-
|
|
9
|
-
beforeEach(async () => {
|
|
10
|
-
scratch = await mkdtemp(join(tmpdir(), 'lyra-mcp-'))
|
|
11
|
-
})
|
|
12
|
-
|
|
13
|
-
afterEach(async () => {
|
|
14
|
-
await rm(scratch, { recursive: true, force: true })
|
|
15
|
-
})
|
|
16
|
-
|
|
17
|
-
describe('discoverMcpServers', () => {
|
|
18
|
-
it('reads ~/.lyra/.mcp.json + ~/.claude/.mcp.json', async () => {
|
|
19
|
-
const lyraPath = join(scratch, 'lyra.mcp.json')
|
|
20
|
-
const claudePath = join(scratch, 'claude.mcp.json')
|
|
21
|
-
await writeFile(
|
|
22
|
-
lyraPath,
|
|
23
|
-
JSON.stringify({
|
|
24
|
-
mcpServers: {
|
|
25
|
-
alpha: { command: 'bun', args: ['run', 'a.ts'] },
|
|
26
|
-
},
|
|
27
|
-
}),
|
|
28
|
-
)
|
|
29
|
-
await writeFile(
|
|
30
|
-
claudePath,
|
|
31
|
-
JSON.stringify({
|
|
32
|
-
mcpServers: {
|
|
33
|
-
beta: { type: 'http', url: 'https://example.com/mcp', headers: { 'X-Foo': '1' } },
|
|
34
|
-
},
|
|
35
|
-
}),
|
|
36
|
-
)
|
|
37
|
-
const out = await discoverMcpServers({
|
|
38
|
-
lyraMcpPath: lyraPath,
|
|
39
|
-
claudeMcpPath: claudePath,
|
|
40
|
-
claudePluginsCacheRoot: join(scratch, 'doesnotexist'),
|
|
41
|
-
importsClaudeCode: true,
|
|
42
|
-
})
|
|
43
|
-
expect(out.servers).toHaveLength(2)
|
|
44
|
-
const alpha = out.servers.find(s => s.name === 'alpha')
|
|
45
|
-
expect(alpha?.type).toBe('stdio')
|
|
46
|
-
if (alpha?.type === 'stdio') {
|
|
47
|
-
expect(alpha.command).toBe('bun')
|
|
48
|
-
expect(alpha.args).toEqual(['run', 'a.ts'])
|
|
49
|
-
}
|
|
50
|
-
const beta = out.servers.find(s => s.name === 'beta')
|
|
51
|
-
expect(beta?.type).toBe('http')
|
|
52
|
-
if (beta?.type === 'http') expect(beta.url).toBe('https://example.com/mcp')
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
it('substitutes ${CLAUDE_PLUGIN_ROOT} from plugin cache layout', async () => {
|
|
56
|
-
const cacheRoot = join(scratch, 'cache')
|
|
57
|
-
const versionDir = join(cacheRoot, 'mp', 'plug', '1.0.0')
|
|
58
|
-
await mkdir(versionDir, { recursive: true })
|
|
59
|
-
await writeFile(
|
|
60
|
-
join(versionDir, '.mcp.json'),
|
|
61
|
-
JSON.stringify({
|
|
62
|
-
mcpServers: {
|
|
63
|
-
plug: {
|
|
64
|
-
command: 'bun',
|
|
65
|
-
args: ['run', '--cwd', '${CLAUDE_PLUGIN_ROOT}', 'start'],
|
|
66
|
-
env: { PLUG_ROOT: '${CLAUDE_PLUGIN_ROOT}/data' },
|
|
67
|
-
},
|
|
68
|
-
},
|
|
69
|
-
}),
|
|
70
|
-
)
|
|
71
|
-
const out = await discoverMcpServers({
|
|
72
|
-
lyraMcpPath: join(scratch, 'doesnotexist'),
|
|
73
|
-
claudeMcpPath: join(scratch, 'doesnotexist'),
|
|
74
|
-
claudePluginsCacheRoot: cacheRoot,
|
|
75
|
-
importsClaudeCode: true,
|
|
76
|
-
})
|
|
77
|
-
expect(out.servers).toHaveLength(1)
|
|
78
|
-
const s = out.servers[0]!
|
|
79
|
-
expect(s.type).toBe('stdio')
|
|
80
|
-
if (s.type === 'stdio') {
|
|
81
|
-
expect(s.args).toEqual(['run', '--cwd', versionDir, 'start'])
|
|
82
|
-
expect(s.env?.PLUG_ROOT).toBe(`${versionDir}/data`)
|
|
83
|
-
}
|
|
84
|
-
})
|
|
85
|
-
|
|
86
|
-
it('honors importsClaudeCode=false (only lyra path scanned)', async () => {
|
|
87
|
-
const claudePath = join(scratch, 'claude.mcp.json')
|
|
88
|
-
await writeFile(claudePath, JSON.stringify({ mcpServers: { foo: { command: 'bun' } } }))
|
|
89
|
-
const out = await discoverMcpServers({
|
|
90
|
-
lyraMcpPath: join(scratch, 'doesnotexist'),
|
|
91
|
-
claudeMcpPath: claudePath,
|
|
92
|
-
claudePluginsCacheRoot: join(scratch, 'doesnotexist'),
|
|
93
|
-
importsClaudeCode: false,
|
|
94
|
-
})
|
|
95
|
-
expect(out.servers).toEqual([])
|
|
96
|
-
})
|
|
97
|
-
})
|