Skip to content

Commit 024de86

Browse files
committed
refactor install logic and add tests for incremental updates
1 parent 62292ce commit 024de86

4 files changed

Lines changed: 209 additions & 42 deletions

File tree

packages/installer/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
},
99
"scripts": {
1010
"build": "tsc -p tsconfig.json",
11-
"dev": "tsx src/cli.ts"
11+
"dev": "tsx src/cli.ts",
12+
"test": "vitest run"
1213
},
1314
"devDependencies": {
1415
"typescript": "^5.5.0",

packages/installer/src/cli.ts

Lines changed: 50 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,17 @@ type Manifest = {
3535
agents: Array<{ id: string; path: string }>
3636
}
3737

38+
type InstalledSkill = {
39+
lib: string
40+
version: string
41+
signature: string | null
42+
}
43+
44+
type SkillsLock = {
45+
schema: number
46+
installed: Record<string, InstalledSkill>
47+
}
48+
3849
function arg(name: string) {
3950
const idx = process.argv.indexOf(`--${name}`)
4051
return idx >= 0 ? process.argv[idx + 1] : undefined
@@ -237,14 +248,14 @@ function resolveExactFromPackageJson(
237248
)
238249
}
239250

240-
async function main() {
241-
const cmd = process.argv[2]
242-
if (cmd !== 'install') throw new Error(`Unknown command: ${cmd}`)
243-
244-
const projectArg = arg('project')
245-
if (!projectArg) throw new Error('--project is required')
251+
type InstallOptions = {
252+
projectDir: string
253+
repoRoot: string
254+
buildMissing: boolean
255+
}
246256

247-
const projectDir = resolveProjectDir(projectArg)
257+
export async function installSkills(options: InstallOptions) {
258+
const { projectDir, repoRoot, buildMissing } = options
248259

249260
const pkgPath = path.join(projectDir, 'package.json')
250261
if (!fs.existsSync(pkgPath)) {
@@ -256,10 +267,6 @@ async function main() {
256267

257268
const lock = readPnpmLock(projectDir)
258269

259-
const repoRoot = getRepoRootFromThisFile()
260-
261-
const buildMissing = hasFlag('build-missing')
262-
263270
const manifestPath = path.join(repoRoot, 'registry', 'manifest.json')
264271
if (!fs.existsSync(manifestPath)) {
265272
throw new Error(
@@ -292,19 +299,11 @@ async function main() {
292299
ensureDir(destSkillsDir)
293300

294301
const outLockPath = path.join(projectDir, 'skills.lock.json')
295-
const prevLock = readJsonIfExists<any>(outLockPath)
302+
const prevLock = readJsonIfExists<SkillsLock | Record<string, any>>(
303+
outLockPath,
304+
)
296305
const prevInstalled: Record<string, any> = prevLock?.installed ?? {}
297306

298-
if (
299-
buildMissing &&
300-
prevLock?.registry?.type &&
301-
prevLock.registry.type !== 'local'
302-
) {
303-
throw new Error(
304-
`--build-missing is only supported for local registries (found: ${prevLock.registry.type}).`,
305-
)
306-
}
307-
308307
// Always install the top-level tanstack agent if present
309308
const tanstackAgent = manifest.agents.find((a) => a.id === 'tanstack')
310309
if (tanstackAgent) {
@@ -417,30 +416,40 @@ async function main() {
417416
lib: libId,
418417
version: exactVersion,
419418
signature,
420-
agent_path: agentPath,
421-
skill_dir: skillDirPath,
422-
version_from: versionPkg,
423419
}
424420
}
425421

426422
// write lockfile
427-
fs.writeFileSync(
428-
outLockPath,
429-
JSON.stringify(
430-
{
431-
schema: 1,
432-
registry: { type: 'local', repoRoot },
433-
installed,
434-
},
435-
null,
436-
2,
437-
) + '\n',
438-
)
423+
const lockfile: SkillsLock = {
424+
schema: 1,
425+
installed,
426+
}
427+
fs.writeFileSync(outLockPath, JSON.stringify(lockfile, null, 2) + '\n')
439428

440429
console.log(`Installed: ${libs.join(', ') || '(none)'}`)
441430
}
442431

443-
main().catch((error) => {
444-
console.error(error instanceof Error ? error.message : error)
445-
process.exit(1)
446-
})
432+
async function main() {
433+
const cmd = process.argv[2]
434+
if (cmd !== 'install') throw new Error(`Unknown command: ${cmd}`)
435+
436+
const projectArg = arg('project')
437+
if (!projectArg) throw new Error('--project is required')
438+
439+
const projectDir = resolveProjectDir(projectArg)
440+
const repoRoot = getRepoRootFromThisFile()
441+
const buildMissing = hasFlag('build-missing')
442+
443+
await installSkills({ projectDir, repoRoot, buildMissing })
444+
}
445+
446+
const isDirectRun =
447+
process.argv[1] &&
448+
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
449+
450+
if (isDirectRun) {
451+
main().catch((error) => {
452+
console.error(error instanceof Error ? error.message : error)
453+
process.exit(1)
454+
})
455+
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import fs from 'node:fs'
2+
import os from 'node:os'
3+
import path from 'node:path'
4+
import { describe, expect, test } from 'vitest'
5+
import { installSkills } from '../src/cli.js'
6+
7+
function writeJson(filePath: string, data: unknown) {
8+
fs.mkdirSync(path.dirname(filePath), { recursive: true })
9+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n')
10+
}
11+
12+
function writeFile(filePath: string, content: string) {
13+
fs.mkdirSync(path.dirname(filePath), { recursive: true })
14+
fs.writeFileSync(filePath, content)
15+
}
16+
17+
function createRepoRoot(baseDir: string, signature: string, content: string) {
18+
const repoRoot = path.join(baseDir, 'repo')
19+
const catalog = `schema: 1
20+
libraries:
21+
query:
22+
id: query
23+
packages:
24+
- "@tanstack/query"
25+
version_from:
26+
- "@tanstack/query"
27+
`
28+
29+
writeFile(path.join(repoRoot, 'catalog.yaml'), catalog)
30+
31+
const skillDir = path.join(repoRoot, 'registry', 'skills', 'query', '5.90.21')
32+
33+
writeFile(path.join(skillDir, 'skill.md'), content)
34+
writeFile(path.join(skillDir, 'agent.md'), `# Query\n${content}\n`)
35+
36+
writeJson(path.join(repoRoot, 'registry', 'manifest.json'), {
37+
schema: 1,
38+
skills: [
39+
{
40+
lib: 'query',
41+
version: '5.90.21',
42+
signature,
43+
skill_dir: 'registry/skills/query/5.90.21',
44+
agent_md: 'registry/skills/query/5.90.21/agent.md',
45+
},
46+
],
47+
agents: [],
48+
})
49+
50+
return repoRoot
51+
}
52+
53+
function createProject(baseDir: string) {
54+
const projectDir = path.join(baseDir, 'project')
55+
writeJson(path.join(projectDir, 'package.json'), {
56+
name: 'demo',
57+
version: '0.0.0',
58+
dependencies: {
59+
'@tanstack/query': '5.90.21',
60+
},
61+
})
62+
return projectDir
63+
}
64+
65+
describe('installer incremental updates', () => {
66+
test('unchanged registry is a no-op', async () => {
67+
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'installer-'))
68+
const repoRoot = createRepoRoot(baseDir, 'sig-1', 'content-v1')
69+
const projectDir = createProject(baseDir)
70+
71+
await installSkills({ projectDir, repoRoot, buildMissing: false })
72+
73+
const agentPath = path.join(
74+
projectDir,
75+
'.agents',
76+
'agents',
77+
'tanstack-query.md',
78+
)
79+
const skillPath = path.join(
80+
projectDir,
81+
'.agents',
82+
'skills',
83+
'query',
84+
'skill.md',
85+
)
86+
87+
const agentStat = fs.statSync(agentPath)
88+
const skillStat = fs.statSync(skillPath)
89+
90+
await new Promise((resolve) => setTimeout(resolve, 20))
91+
92+
await installSkills({ projectDir, repoRoot, buildMissing: false })
93+
94+
expect(fs.statSync(agentPath).mtimeMs).toBe(agentStat.mtimeMs)
95+
expect(fs.statSync(skillPath).mtimeMs).toBe(skillStat.mtimeMs)
96+
})
97+
98+
test('changed signature triggers update', async () => {
99+
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'installer-'))
100+
const repoRoot = createRepoRoot(baseDir, 'sig-1', 'content-v1')
101+
const projectDir = createProject(baseDir)
102+
103+
await installSkills({ projectDir, repoRoot, buildMissing: false })
104+
105+
writeFile(
106+
path.join(repoRoot, 'registry', 'skills', 'query', '5.90.21', 'skill.md'),
107+
'content-v2',
108+
)
109+
writeFile(
110+
path.join(repoRoot, 'registry', 'skills', 'query', '5.90.21', 'agent.md'),
111+
'# Query\ncontent-v2\n',
112+
)
113+
114+
writeJson(path.join(repoRoot, 'registry', 'manifest.json'), {
115+
schema: 1,
116+
skills: [
117+
{
118+
lib: 'query',
119+
version: '5.90.21',
120+
signature: 'sig-2',
121+
skill_dir: 'registry/skills/query/5.90.21',
122+
agent_md: 'registry/skills/query/5.90.21/agent.md',
123+
},
124+
],
125+
agents: [],
126+
})
127+
128+
await installSkills({ projectDir, repoRoot, buildMissing: false })
129+
130+
const updatedSkill = fs.readFileSync(
131+
path.join(projectDir, '.agents', 'skills', 'query', 'skill.md'),
132+
'utf8',
133+
)
134+
const updatedLock = JSON.parse(
135+
fs.readFileSync(path.join(projectDir, 'skills.lock.json'), 'utf8'),
136+
)
137+
138+
expect(updatedSkill).toBe('content-v2')
139+
expect(updatedLock).toEqual({
140+
schema: 1,
141+
installed: {
142+
query: {
143+
lib: 'query',
144+
version: '5.90.21',
145+
signature: 'sig-2',
146+
},
147+
},
148+
})
149+
})
150+
})
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { defineConfig } from 'vitest/config'
2+
3+
export default defineConfig({
4+
test: {
5+
include: ['test/**/*.test.ts'],
6+
},
7+
})

0 commit comments

Comments
 (0)