Skip to content

Commit f03ccee

Browse files
committed
feat: add transitive skill synchronization
1 parent 619310a commit f03ccee

9 files changed

Lines changed: 242 additions & 4 deletions

File tree

SKILLS-GENERATION.md

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,37 @@ Then:
219219
- Updated `packages/nuxt-drizzle/skills/rstore-nuxt-drizzle/references/api-allow-tables.md` (behavior, requirements, pitfalls 2-3).
220220
- Reason: real-world incident where adding a new Drizzle table triggered `Collection "<name>" is not allowed.` because the project already used `allowTables` and the new table wasn't registered. Skill failed to anticipate this maintenance step.
221221

222+
## Dependency skill sync (for skills-npm consumers)
223+
224+
`skills-npm` scans the consumer project's top-level `node_modules` for
225+
packages that ship a `skills/` folder. It does **not** walk transitive
226+
deps. If a user installs only `@rstore/nuxt-drizzle`, skills-npm will
227+
not discover `@rstore/nuxt` or `@rstore/vue` skills unless those skill
228+
folders are physically present inside `@rstore/nuxt-drizzle/skills/`.
229+
230+
To fix that, `scripts/sync-dep-skills.mjs` copies each transitive
231+
`@rstore/*` workspace dep's `skills/<skill-name>/` directories into
232+
the consumer package's `skills/` directory. The script is wired into
233+
each wrapper package's `prepack` script so copies are materialized at
234+
publish time.
235+
236+
Canonical skill folders (one per package) are committed:
237+
238+
- `packages/vue/skills/rstore-vue`
239+
- `packages/nuxt/skills/rstore-nuxt`
240+
- `packages/nuxt-drizzle/skills/rstore-nuxt-drizzle`
241+
242+
Copied skill folders are gitignored via per-directory `.gitignore`
243+
files that allowlist only the canonical folder name.
244+
245+
To run the sync manually (local debugging or before testing with
246+
skills-npm from a local install):
247+
248+
```bash
249+
pnpm run sync-skills
250+
```
251+
222252
## Notes
223253

224-
- There is no dedicated generation script in this repository yet.
225-
- Generation is currently a documented manual process with reproducible inspection commands.
254+
- Skill content generation (`SKILL.md`, `references/*.md`) is a documented manual process with reproducible inspection commands.
255+
- Dependency skill copies are generated automatically via `prepack`; do not commit them.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"test:dev": "vitest",
2121
"test:types": "pnpm run --parallel -r test:types",
2222
"copy-readme": "echo ./packages/* | xargs -n 1 cp README.md",
23+
"sync-skills": "node scripts/sync-dep-skills.mjs packages/vue packages/nuxt packages/nuxt-drizzle",
2324
"release": "pnpm run copy-readme && pnpm run lint && pnpm run test && pnpm run test:types && pnpm run build && sheep release -b main --force",
2425
"docs:dev": "vitepress dev docs",
2526
"docs:build": "vitepress build docs",

packages/nuxt-drizzle/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@
3737
"dev:build": "nuxi build playground",
3838
"test": "vitest run",
3939
"test:watch": "vitest watch",
40-
"test:types": "vue-tsc --noEmit"
40+
"test:types": "vue-tsc --noEmit",
41+
"prepack": "node ../../scripts/sync-dep-skills.mjs ."
4142
},
4243
"peerDependencies": {
4344
"drizzle-orm": "*"
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
*
2+
!.gitignore
3+
!rstore-nuxt-drizzle
4+
!rstore-nuxt-drizzle/**

packages/nuxt/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@
3939
"client:prepare": "pnpm --filter @rstore/devtools dev:prepare",
4040
"test": "vitest run",
4141
"test:watch": "vitest watch",
42-
"test:types": "vue-tsc --noEmit"
42+
"test:types": "vue-tsc --noEmit",
43+
"prepack": "node ../../scripts/sync-dep-skills.mjs ."
4344
},
4445
"dependencies": {
4546
"@nuxt/devtools-kit": "^2.2.1",

packages/nuxt/skills/.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
*
2+
!.gitignore
3+
!rstore-nuxt
4+
!rstore-nuxt/**

packages/vue/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"scripts": {
3535
"build": "unbuild",
3636
"dev": "unbuild --stub && printf \"export * from '../src/index'\" > ./dist/index.mjs",
37+
"prepack": "node ../../scripts/sync-dep-skills.mjs .",
3738
"test:types": "tsc --noEmit"
3839
},
3940
"peerDependencies": {

packages/vue/skills/.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
*
2+
!.gitignore
3+
!rstore-vue
4+
!rstore-vue/**

scripts/sync-dep-skills.mjs

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
#!/usr/bin/env node
2+
3+
/* eslint-disable no-console */
4+
5+
// Copies `skills/<skill-name>/` folders from every transitive `@rstore/*`
6+
// workspace dependency into the target package's `skills/` folder.
7+
//
8+
// Rationale: skills-npm (https://github.com/antfu/skills-npm) scans the
9+
// consumer project's top-level `node_modules` for packages that ship a
10+
// `skills/` directory. It does NOT walk transitive deps. So a consumer
11+
// that only installs `@rstore/nuxt-drizzle` must find every relevant
12+
// skill directly under `@rstore/nuxt-drizzle/skills/`. This script
13+
// materializes that layout by copying skills from dep packages at
14+
// publish time (via `prepack`).
15+
16+
import fs from 'node:fs/promises'
17+
import path from 'node:path'
18+
import process from 'node:process'
19+
20+
const WORKSPACE_SCOPE = '@rstore/'
21+
22+
/**
23+
* Read and parse a JSON file.
24+
* @param {string} filePath
25+
* @returns {Promise<any>}
26+
*/
27+
async function readJson(filePath) {
28+
return JSON.parse(await fs.readFile(filePath, 'utf8'))
29+
}
30+
31+
/**
32+
* Resolve a workspace-dep package directory by following the pnpm/npm
33+
* symlink inside `<pkgDir>/node_modules/<depName>` to its real path.
34+
* Returns null if the dep is not installed.
35+
* @param {string} depName
36+
* @param {string} fromDir
37+
* @returns {Promise<string | null>}
38+
*/
39+
async function resolveDepDir(depName, fromDir) {
40+
const linkPath = path.join(fromDir, 'node_modules', ...depName.split('/'))
41+
try {
42+
return await fs.realpath(linkPath)
43+
}
44+
catch {
45+
return null
46+
}
47+
}
48+
49+
/**
50+
* Walk the dependency graph starting from `pkgDir`, collecting every
51+
* `@rstore/*` package reachable through `dependencies`.
52+
* @param {string} pkgDir
53+
* @param {Map<string, string>} out name -> absolute package dir
54+
* @returns {Promise<Map<string, string>>}
55+
*/
56+
async function collectRstoreDeps(pkgDir, out = new Map()) {
57+
const pkg = await readJson(path.join(pkgDir, 'package.json'))
58+
if (out.has(pkg.name))
59+
return out
60+
out.set(pkg.name, pkgDir)
61+
62+
const deps = Object.keys(pkg.dependencies || {})
63+
for (const depName of deps) {
64+
if (!depName.startsWith(WORKSPACE_SCOPE))
65+
continue
66+
const depDir = await resolveDepDir(depName, pkgDir)
67+
if (!depDir)
68+
continue
69+
await collectRstoreDeps(depDir, out)
70+
}
71+
return out
72+
}
73+
74+
/**
75+
* Copy each subdirectory of `srcSkillsDir` into `dstSkillsDir`, replacing
76+
* any existing target folder with the same name.
77+
* @param {string} srcSkillsDir
78+
* @param {string} dstSkillsDir
79+
* @returns {Promise<string[]>} list of copied skill names
80+
*/
81+
async function copySkillFolders(srcSkillsDir, dstSkillsDir) {
82+
let entries
83+
try {
84+
entries = await fs.readdir(srcSkillsDir, { withFileTypes: true })
85+
}
86+
catch {
87+
return []
88+
}
89+
90+
await fs.mkdir(dstSkillsDir, { recursive: true })
91+
const copied = []
92+
for (const entry of entries) {
93+
if (!entry.isDirectory())
94+
continue
95+
// Skip skills-npm-managed folders; they're gitignored and
96+
// regenerated per-consumer.
97+
if (entry.name.startsWith('npm-'))
98+
continue
99+
const src = path.join(srcSkillsDir, entry.name)
100+
const dst = path.join(dstSkillsDir, entry.name)
101+
await fs.rm(dst, { recursive: true, force: true })
102+
await fs.cp(src, dst, { recursive: true })
103+
copied.push(entry.name)
104+
}
105+
return copied
106+
}
107+
108+
/**
109+
* Sync skills from every transitive `@rstore/*` workspace dep into
110+
* `<targetDir>/skills/`. Skills owned by the target package itself are
111+
* left untouched.
112+
* @param {string} targetDir
113+
*/
114+
async function syncDepSkills(targetDir) {
115+
const targetPkgPath = path.join(targetDir, 'package.json')
116+
const targetPkg = await readJson(targetPkgPath)
117+
const targetSkillsDir = path.join(targetDir, 'skills')
118+
119+
// Find the target package's own skill folder names so we never
120+
// clobber them.
121+
const ownSkillNames = new Set()
122+
try {
123+
const ownEntries = await fs.readdir(targetSkillsDir, { withFileTypes: true })
124+
for (const e of ownEntries) {
125+
if (!e.isDirectory() || e.name.startsWith('npm-'))
126+
continue
127+
const skillPkgMatches = await isOwnSkill(path.join(targetSkillsDir, e.name), targetPkg.name)
128+
if (skillPkgMatches)
129+
ownSkillNames.add(e.name)
130+
}
131+
}
132+
catch {
133+
// no skills dir yet — fine
134+
}
135+
136+
const depsMap = await collectRstoreDeps(targetDir)
137+
const totals = []
138+
for (const [depName, depDir] of depsMap) {
139+
if (depName === targetPkg.name)
140+
continue
141+
const depSkillsDir = path.join(depDir, 'skills')
142+
const copied = await copySkillFolders(depSkillsDir, targetSkillsDir)
143+
// Do not overwrite own skills (belt-and-suspenders; `collectRstoreDeps`
144+
// already excludes the target, but a dep could ship a skill that
145+
// collides by name).
146+
for (const name of copied) {
147+
if (ownSkillNames.has(name)) {
148+
// Restore: remove the foreign copy we just wrote.
149+
await fs.rm(path.join(targetSkillsDir, name), { recursive: true, force: true })
150+
continue
151+
}
152+
totals.push(`${depName} -> ${name}`)
153+
}
154+
}
155+
156+
if (totals.length === 0)
157+
console.log(`[sync-dep-skills] ${targetPkg.name}: no dep skills to sync`)
158+
else
159+
console.log(`[sync-dep-skills] ${targetPkg.name}: synced\n - ${totals.join('\n - ')}`)
160+
}
161+
162+
/**
163+
* Heuristic: treat a skill folder as "owned" by the package if the
164+
* folder name contains the unscoped package name. Used so that if two
165+
* packages in the graph ship skills with the same folder name, the
166+
* target package's copy always wins.
167+
* @param {string} _skillDir
168+
* @param {string} pkgName
169+
* @returns {Promise<boolean>}
170+
*/
171+
async function isOwnSkill(_skillDir, pkgName) {
172+
const unscoped = pkgName.replace(/^@[^/]+\//, '')
173+
const folderName = path.basename(_skillDir)
174+
return folderName.includes(unscoped)
175+
}
176+
177+
async function main() {
178+
const args = process.argv.slice(2)
179+
if (args.length === 0) {
180+
console.error('Usage: sync-dep-skills.mjs <target-pkg-dir> [<target-pkg-dir> ...]')
181+
process.exit(1)
182+
}
183+
for (const arg of args) {
184+
const abs = path.resolve(arg)
185+
await syncDepSkills(abs)
186+
}
187+
}
188+
189+
main().catch((err) => {
190+
console.error(err)
191+
process.exit(1)
192+
})

0 commit comments

Comments
 (0)