feat(server): routes for settings, catalog, git, scope sync, home cleanup and marketplaces; stable port
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
19957db parent: 8804311 modified
packages/cli/src/main.ts +21 -10 | @@ -8,10 +8,13 @@ import { startServer } from '@skilly/server' | ||
| 8 | 8 | import { openWindow } from './window.ts' |
| 9 | 9 | |
| 10 | 10 | const IDLE_EXIT_MS = 20_000 |
| 11 | +/** Tried first so the origin (and thus localStorage, e.g. the theme) stays the same across launches. */ | |
| 12 | +const PREFERRED_PORT = 47821 | |
| 11 | 13 | |
| 12 | 14 | interface Args { |
| 13 | 15 | open: boolean |
| 14 | - port: number | |
| 16 | + /** Undefined: try the preferred port, then fall back to a free one. */ | |
| 17 | + port: number | undefined | |
| 15 | 18 | help: boolean |
| 16 | 19 | } |
| 17 | 20 | |
| @@ -23,7 +26,7 @@ interface InstanceInfo { | ||
| 23 | 26 | } |
| 24 | 27 | |
| 25 | 28 | function parseArgs(argv: string[]): Args { |
| 26 | - const args: Args = { open: true, port: 0, help: false } | |
| 29 | + const args: Args = { open: true, port: undefined, help: false } | |
| 27 | 30 | for (let i = 0; i < argv.length; i++) { |
| 28 | 31 | const a = argv[i] |
| 29 | 32 | if (a === '--no-open') args.open = false |
| @@ -70,6 +73,20 @@ async function launch(url: string, profileDir: string): Promise<void> { | ||
| 70 | 73 | } |
| 71 | 74 | } |
| 72 | 75 | |
| 76 | +type ServerOptions = Parameters<typeof startServer>[0] | |
| 77 | + | |
| 78 | +/** An explicit port is used as is; otherwise the preferred port is tried first, then any free port. */ | |
| 79 | +async function startOnPreferredPort(options: Omit<ServerOptions, 'port'>, explicit: number | undefined) { | |
| 80 | + if (explicit !== undefined) return startServer({ ...options, port: explicit }) | |
| 81 | + try { | |
| 82 | + return await startServer({ ...options, port: PREFERRED_PORT }) | |
| 83 | + } catch (e) { | |
| 84 | + const code = (e as NodeJS.ErrnoException).code | |
| 85 | + if (code !== 'EADDRINUSE' && code !== 'EACCES') throw e | |
| 86 | + return startServer({ ...options, port: 0 }) | |
| 87 | + } | |
| 88 | +} | |
| 89 | + | |
| 73 | 90 | async function main(): Promise<void> { |
| 74 | 91 | const args = parseArgs(process.argv.slice(2)) |
| 75 | 92 | if (args.help) { |
| @@ -109,14 +126,8 @@ async function main(): Promise<void> { | ||
| 109 | 126 | process.exit(0) |
| 110 | 127 | } |
| 111 | 128 | |
| 112 | - const server = await startServer({ | |
| 113 | - token, | |
| 114 | - port: args.port, | |
| 115 | - configDir, | |
| 116 | - staticDir, | |
| 117 | - exitOnIdleMs: IDLE_EXIT_MS, | |
| 118 | - onIdle: () => void cleanup(), | |
| 119 | - }) | |
| 129 | + const serverOptions = { token, configDir, staticDir, exitOnIdleMs: IDLE_EXIT_MS, onIdle: () => void cleanup() } | |
| 130 | + const server = await startOnPreferredPort(serverOptions, args.port) | |
| 120 | 131 | const info: InstanceInfo = { pid: process.pid, port: server.port, token, startedAt: new Date().toISOString() } |
| 121 | 132 | await writeFile(instanceFile, JSON.stringify(info, null, 2) + '\n', 'utf8') |
| 122 | 133 | process.on('SIGINT', () => void cleanup()) |
| @@ -8,10 +8,13 @@ import { startServer } from '@skilly/server' | |||
| 8 | import { openWindow } from './window.ts' | 8 | import { openWindow } from './window.ts' |
| 9 | 9 | ||
| 10 | const IDLE_EXIT_MS = 20_000 | 10 | const IDLE_EXIT_MS = 20_000 |
| 11 | +/** Tried first so the origin (and thus localStorage, e.g. the theme) stays the same across launches. */ | ||
| 12 | +const PREFERRED_PORT = 47821 | ||
| 11 | 13 | ||
| 12 | interface Args { | 14 | interface Args { |
| 13 | open: boolean | 15 | open: boolean |
| 14 | - port: number | 16 | + /** Undefined: try the preferred port, then fall back to a free one. */ |
| 17 | + port: number | undefined | ||
| 15 | help: boolean | 18 | help: boolean |
| 16 | } | 19 | } |
| 17 | 20 | ||
| @@ -23,7 +26,7 @@ interface InstanceInfo { | |||
| 23 | } | 26 | } |
| 24 | 27 | ||
| 25 | function parseArgs(argv: string[]): Args { | 28 | function parseArgs(argv: string[]): Args { |
| 26 | - const args: Args = { open: true, port: 0, help: false } | 29 | + const args: Args = { open: true, port: undefined, help: false } |
| 27 | for (let i = 0; i < argv.length; i++) { | 30 | for (let i = 0; i < argv.length; i++) { |
| 28 | const a = argv[i] | 31 | const a = argv[i] |
| 29 | if (a === '--no-open') args.open = false | 32 | if (a === '--no-open') args.open = false |
| @@ -70,6 +73,20 @@ async function launch(url: string, profileDir: string): Promise<void> { | |||
| 70 | } | 73 | } |
| 71 | } | 74 | } |
| 72 | 75 | ||
| 76 | +type ServerOptions = Parameters<typeof startServer>[0] | ||
| 77 | + | ||
| 78 | +/** An explicit port is used as is; otherwise the preferred port is tried first, then any free port. */ | ||
| 79 | +async function startOnPreferredPort(options: Omit<ServerOptions, 'port'>, explicit: number | undefined) { | ||
| 80 | + if (explicit !== undefined) return startServer({ ...options, port: explicit }) | ||
| 81 | + try { | ||
| 82 | + return await startServer({ ...options, port: PREFERRED_PORT }) | ||
| 83 | + } catch (e) { | ||
| 84 | + const code = (e as NodeJS.ErrnoException).code | ||
| 85 | + if (code !== 'EADDRINUSE' && code !== 'EACCES') throw e | ||
| 86 | + return startServer({ ...options, port: 0 }) | ||
| 87 | + } | ||
| 88 | +} | ||
| 89 | + | ||
| 73 | async function main(): Promise<void> { | 90 | async function main(): Promise<void> { |
| 74 | const args = parseArgs(process.argv.slice(2)) | 91 | const args = parseArgs(process.argv.slice(2)) |
| 75 | if (args.help) { | 92 | if (args.help) { |
| @@ -109,14 +126,8 @@ async function main(): Promise<void> { | |||
| 109 | process.exit(0) | 126 | process.exit(0) |
| 110 | } | 127 | } |
| 111 | 128 | ||
| 112 | - const server = await startServer({ | 129 | + const serverOptions = { token, configDir, staticDir, exitOnIdleMs: IDLE_EXIT_MS, onIdle: () => void cleanup() } |
| 113 | - token, | 130 | + const server = await startOnPreferredPort(serverOptions, args.port) |
| 114 | - port: args.port, | ||
| 115 | - configDir, | ||
| 116 | - staticDir, | ||
| 117 | - exitOnIdleMs: IDLE_EXIT_MS, | ||
| 118 | - onIdle: () => void cleanup(), | ||
| 119 | - }) | ||
| 120 | const info: InstanceInfo = { pid: process.pid, port: server.port, token, startedAt: new Date().toISOString() } | 131 | const info: InstanceInfo = { pid: process.pid, port: server.port, token, startedAt: new Date().toISOString() } |
| 121 | await writeFile(instanceFile, JSON.stringify(info, null, 2) + '\n', 'utf8') | 132 | await writeFile(instanceFile, JSON.stringify(info, null, 2) + '\n', 'utf8') |
| 122 | process.on('SIGINT', () => void cleanup()) | 133 | process.on('SIGINT', () => void cleanup()) |
added
packages/server/src/catalog-routes.ts +291 -0 | new file mode 100644 | ||
| @@ -0,0 +1,291 @@ | ||
| 1 | +import { | |
| 2 | + CATALOG_ITEM_TYPES, | |
| 3 | + type CatalogItemType, | |
| 4 | + type CatalogRef, | |
| 5 | + type CatalogResponse, | |
| 6 | + type GitStatus, | |
| 7 | + type ImportBody, | |
| 8 | + type ImportResult, | |
| 9 | + type Preset, | |
| 10 | + type SaveItemInput, | |
| 11 | + type ScopeConfig, | |
| 12 | + type Settings, | |
| 13 | +} from '@skilly/core' | |
| 14 | +import type { Hono } from 'hono' | |
| 15 | +import path from 'node:path' | |
| 16 | +import { HttpError, isDirectory, isStringArray, readJsonBody, type AppContext } from './context.ts' | |
| 17 | + | |
| 18 | +const EMPTY_GIT: GitStatus = { isRepo: false, ahead: 0, behind: 0, dirty: [], conflicts: [] } | |
| 19 | + | |
| 20 | +function parseType(raw: string): CatalogItemType { | |
| 21 | + if (!(CATALOG_ITEM_TYPES as readonly string[]).includes(raw)) throw new HttpError(400, `Unknown item type "${raw}"`) | |
| 22 | + return raw as CatalogItemType | |
| 23 | +} | |
| 24 | + | |
| 25 | +function requireUrlLike(v: unknown, label: string): string { | |
| 26 | + if (typeof v !== 'string' || !v.trim()) throw new HttpError(400, `"${label}" is required`) | |
| 27 | + if (v.trim().startsWith('-')) throw new HttpError(400, `Invalid ${label}`) | |
| 28 | + return v.trim() | |
| 29 | +} | |
| 30 | + | |
| 31 | +function normalizePreset(body: Record<string, unknown>, name: string): Preset { | |
| 32 | + if (body.includes !== undefined && !isStringArray(body.includes)) throw new HttpError(400, '"includes" must be a string array') | |
| 33 | + if (body.description !== undefined && typeof body.description !== 'string') throw new HttpError(400, '"description" must be a string') | |
| 34 | + const items: Preset['items'] = {} | |
| 35 | + const rawItems = body.items ?? {} | |
| 36 | + if (typeof rawItems !== 'object' || rawItems === null || Array.isArray(rawItems)) throw new HttpError(400, '"items" must be an object') | |
| 37 | + for (const [type, list] of Object.entries(rawItems)) { | |
| 38 | + parseType(type) | |
| 39 | + if (!isStringArray(list)) throw new HttpError(400, `"items.${type}" must be a string array`) | |
| 40 | + items[type as CatalogItemType] = list | |
| 41 | + } | |
| 42 | + const preset: Preset = { name, includes: (body.includes as string[] | undefined) ?? [], items } | |
| 43 | + if (body.description) preset.description = body.description as string | |
| 44 | + return preset | |
| 45 | +} | |
| 46 | + | |
| 47 | +/** Scopes (project ids or `home`) whose config references each catalog item / preset. */ | |
| 48 | +async function computeUsage(ctx: AppContext): Promise<Record<string, string[]>> { | |
| 49 | + const usage: Record<string, string[]> = {} | |
| 50 | + const add = (key: string, scope: string): void => { | |
| 51 | + const list = (usage[key] ??= []) | |
| 52 | + if (!list.includes(scope)) list.push(scope) | |
| 53 | + } | |
| 54 | + const snapshot = await (await ctx.catalog()).snapshot() | |
| 55 | + const presets = new Map(snapshot.presets.map((p) => [p.name, p])) | |
| 56 | + const scopeIds = ['home', ...(await ctx.store.list()).map((p) => p.id)] | |
| 57 | + for (const id of scopeIds) { | |
| 58 | + let config: ScopeConfig | undefined | |
| 59 | + try { | |
| 60 | + const scope = await ctx.scope(id) | |
| 61 | + config = await ctx.syncEngine.readConfig(scope.target) | |
| 62 | + } catch { | |
| 63 | + continue | |
| 64 | + } | |
| 65 | + if (!config) continue | |
| 66 | + for (const [type, names] of Object.entries(config.add ?? {})) for (const n of names ?? []) add(`${type}/${n}`, id) | |
| 67 | + const seen = new Set<string>() | |
| 68 | + const visit = (name: string): void => { | |
| 69 | + if (seen.has(name)) return | |
| 70 | + seen.add(name) | |
| 71 | + add(`preset/${name}`, id) | |
| 72 | + const preset = presets.get(name) | |
| 73 | + if (!preset) return | |
| 74 | + for (const [type, names] of Object.entries(preset.items)) for (const n of names ?? []) add(`${type}/${n}`, id) | |
| 75 | + for (const inc of preset.includes) visit(inc) | |
| 76 | + } | |
| 77 | + for (const name of config.presets ?? []) visit(name) | |
| 78 | + } | |
| 79 | + return usage | |
| 80 | +} | |
| 81 | + | |
| 82 | +function usedMessage(key: string, scopes: string[]): string { | |
| 83 | + return `${key} is used by ${scopes.length} scope${scopes.length === 1 ? '' : 's'} (${scopes.join(', ')}); use ?force=1 to delete anyway` | |
| 84 | +} | |
| 85 | + | |
| 86 | +export function registerCatalogRoutes(app: Hono, ctx: AppContext): void { | |
| 87 | + // ---- settings ---- | |
| 88 | + app.get('/api/settings', async (c) => c.json(await ctx.settings())) | |
| 89 | + | |
| 90 | + app.put('/api/settings', async (c) => { | |
| 91 | + const body = await readJsonBody(c) | |
| 92 | + const current = await ctx.settings() | |
| 93 | + const next: Settings = { ...current } | |
| 94 | + if (body.catalogPath !== undefined) { | |
| 95 | + if (typeof body.catalogPath !== 'string' || !path.isAbsolute(body.catalogPath) || body.catalogPath.includes('\0')) { | |
| 96 | + throw new HttpError(400, '"catalogPath" must be an absolute path') | |
| 97 | + } | |
| 98 | + next.catalogPath = path.resolve(body.catalogPath) | |
| 99 | + } | |
| 100 | + if (body.autoSync !== undefined) { | |
| 101 | + if (typeof body.autoSync !== 'boolean') throw new HttpError(400, '"autoSync" must be a boolean') | |
| 102 | + next.autoSync = body.autoSync | |
| 103 | + } | |
| 104 | + await ctx.store.setSettings(next) | |
| 105 | + return c.json(await ctx.settings()) | |
| 106 | + }) | |
| 107 | + | |
| 108 | + // ---- catalog ---- | |
| 109 | + async function catalogResponse(): Promise<CatalogResponse> { | |
| 110 | + const catalog = await ctx.catalog() | |
| 111 | + const git = await ctx.git() | |
| 112 | + const exists = await catalog.exists() | |
| 113 | + const [snapshot, gitStatus, usage] = await Promise.all([ | |
| 114 | + catalog.snapshot(), | |
| 115 | + exists ? git.status() : Promise.resolve(EMPTY_GIT), | |
| 116 | + computeUsage(ctx), | |
| 117 | + ]) | |
| 118 | + return { snapshot, git: gitStatus, usage } | |
| 119 | + } | |
| 120 | + | |
| 121 | + app.get('/api/catalog', async (c) => c.json(await catalogResponse())) | |
| 122 | + | |
| 123 | + app.post('/api/catalog/init', async (c) => { | |
| 124 | + const catalog = await ctx.catalog() | |
| 125 | + await catalog.init() | |
| 126 | + const git = await ctx.git() | |
| 127 | + if (!(await git.status()).isRepo) await git.init() | |
| 128 | + return c.json(await catalogResponse()) | |
| 129 | + }) | |
| 130 | + | |
| 131 | + app.get('/api/catalog/items/:type/:name', async (c) => { | |
| 132 | + const ref: CatalogRef = { type: parseType(c.req.param('type')), name: c.req.param('name') } | |
| 133 | + const detail = await (await ctx.catalog()).getItem(ref) | |
| 134 | + if (!detail) throw new HttpError(404, `${ref.type} "${ref.name}" not found in the catalog`) | |
| 135 | + return c.json(detail) | |
| 136 | + }) | |
| 137 | + | |
| 138 | + app.put('/api/catalog/items/:type/:name', async (c) => { | |
| 139 | + const type = parseType(c.req.param('type')) | |
| 140 | + const name = c.req.param('name') | |
| 141 | + const body = await readJsonBody(c) | |
| 142 | + if (typeof body.content !== 'string') throw new HttpError(400, '"content" must be a string') | |
| 143 | + if (body.files !== undefined) { | |
| 144 | + const f = body.files | |
| 145 | + if (!f || typeof f !== 'object' || Array.isArray(f) || !Object.values(f).every((v) => typeof v === 'string')) { | |
| 146 | + throw new HttpError(400, '"files" must map relative paths to strings') | |
| 147 | + } | |
| 148 | + } | |
| 149 | + if (body.deleteFiles !== undefined && !isStringArray(body.deleteFiles)) throw new HttpError(400, '"deleteFiles" must be a string array') | |
| 150 | + if (body.tags !== undefined && !isStringArray(body.tags)) throw new HttpError(400, '"tags" must be a string array') | |
| 151 | + if (body.previousName !== undefined && typeof body.previousName !== 'string') throw new HttpError(400, '"previousName" must be a string') | |
| 152 | + const input: SaveItemInput = { | |
| 153 | + type, | |
| 154 | + name, | |
| 155 | + content: body.content, | |
| 156 | + files: body.files as Record<string, string> | undefined, | |
| 157 | + deleteFiles: body.deleteFiles as string[] | undefined, | |
| 158 | + tags: body.tags as string[] | undefined, | |
| 159 | + previousName: body.previousName as string | undefined, | |
| 160 | + } | |
| 161 | + const catalog = await ctx.catalog() | |
| 162 | + if (!(await catalog.exists())) await catalog.init() | |
| 163 | + const item = await catalog.saveItem(input) | |
| 164 | + ctx.catalogChanged() | |
| 165 | + return c.json(item) | |
| 166 | + }) | |
| 167 | + | |
| 168 | + app.delete('/api/catalog/items/:type/:name', async (c) => { | |
| 169 | + const ref: CatalogRef = { type: parseType(c.req.param('type')), name: c.req.param('name') } | |
| 170 | + if (c.req.query('force') !== '1') { | |
| 171 | + const key = `${ref.type}/${ref.name}` | |
| 172 | + const scopes = (await computeUsage(ctx))[key] | |
| 173 | + if (scopes?.length) throw new HttpError(409, usedMessage(key, scopes)) | |
| 174 | + } | |
| 175 | + await (await ctx.catalog()).deleteItem(ref) | |
| 176 | + ctx.catalogChanged() | |
| 177 | + return c.body(null, 204) | |
| 178 | + }) | |
| 179 | + | |
| 180 | + app.put('/api/catalog/items/:type/:name/tags', async (c) => { | |
| 181 | + const ref: CatalogRef = { type: parseType(c.req.param('type')), name: c.req.param('name') } | |
| 182 | + const body = await readJsonBody(c) | |
| 183 | + if (!isStringArray(body.tags)) throw new HttpError(400, '"tags" must be a string array') | |
| 184 | + await (await ctx.catalog()).setTags(ref, body.tags) | |
| 185 | + ctx.catalogChanged() | |
| 186 | + return c.body(null, 204) | |
| 187 | + }) | |
| 188 | + | |
| 189 | + app.post('/api/catalog/import', async (c) => { | |
| 190 | + const body = (await readJsonBody(c)) as Partial<ImportBody> | |
| 191 | + if (typeof body.scope !== 'string' || !body.scope) throw new HttpError(400, '"scope" is required') | |
| 192 | + if (!isStringArray(body.itemIds)) throw new HttpError(400, '"itemIds" must be a string array') | |
| 193 | + if (body.tags !== undefined && !isStringArray(body.tags)) throw new HttpError(400, '"tags" must be a string array') | |
| 194 | + if (body.overwrite !== undefined && typeof body.overwrite !== 'boolean') throw new HttpError(400, '"overwrite" must be a boolean') | |
| 195 | + const scope = await ctx.scope(body.scope) | |
| 196 | + const inventory = await scope.scan() | |
| 197 | + const byId = new Map(inventory.items.map((i) => [i.id, i])) | |
| 198 | + const catalog = await ctx.catalog() | |
| 199 | + if (!(await catalog.exists())) await catalog.init() | |
| 200 | + const result: ImportResult = { imported: [], skipped: [] } | |
| 201 | + for (const id of body.itemIds) { | |
| 202 | + const item = byId.get(id) | |
| 203 | + if (!item) { | |
| 204 | + result.skipped.push({ id, reason: 'Item not found in the scope inventory' }) | |
| 205 | + continue | |
| 206 | + } | |
| 207 | + if (item.type === 'instructions') { | |
| 208 | + result.skipped.push({ id, reason: 'Instructions files (CLAUDE.md / AGENTS.md) cannot be imported' }) | |
| 209 | + continue | |
| 210 | + } | |
| 211 | + try { | |
| 212 | + result.imported.push(await catalog.importFromInventory(item, { tags: body.tags, overwrite: body.overwrite })) | |
| 213 | + } catch (e) { | |
| 214 | + result.skipped.push({ id, reason: e instanceof Error ? e.message : String(e) }) | |
| 215 | + } | |
| 216 | + } | |
| 217 | + if (result.imported.length > 0) ctx.catalogChanged() | |
| 218 | + return c.json(result) | |
| 219 | + }) | |
| 220 | + | |
| 221 | + app.put('/api/catalog/presets/:name', async (c) => { | |
| 222 | + const name = c.req.param('name') | |
| 223 | + const body = await readJsonBody(c) | |
| 224 | + if (body.previousName !== undefined && typeof body.previousName !== 'string') throw new HttpError(400, '"previousName" must be a string') | |
| 225 | + const catalog = await ctx.catalog() | |
| 226 | + if (!(await catalog.exists())) await catalog.init() | |
| 227 | + const saved = await catalog.savePreset(normalizePreset(body, name), body.previousName as string | undefined) | |
| 228 | + ctx.catalogChanged() | |
| 229 | + return c.json(saved) | |
| 230 | + }) | |
| 231 | + | |
| 232 | + app.delete('/api/catalog/presets/:name', async (c) => { | |
| 233 | + const name = c.req.param('name') | |
| 234 | + if (c.req.query('force') !== '1') { | |
| 235 | + const key = `preset/${name}` | |
| 236 | + const scopes = (await computeUsage(ctx))[key] | |
| 237 | + if (scopes?.length) throw new HttpError(409, usedMessage(key, scopes)) | |
| 238 | + } | |
| 239 | + await (await ctx.catalog()).deletePreset(name) | |
| 240 | + ctx.catalogChanged() | |
| 241 | + return c.body(null, 204) | |
| 242 | + }) | |
| 243 | + | |
| 244 | + // ---- catalog git ---- | |
| 245 | + app.get('/api/catalog/git', async (c) => { | |
| 246 | + const catalog = await ctx.catalog() | |
| 247 | + if (!(await catalog.exists())) return c.json(EMPTY_GIT) | |
| 248 | + return c.json(await (await ctx.git()).status()) | |
| 249 | + }) | |
| 250 | + | |
| 251 | + async function gitOp<T>(run: (git: Awaited<ReturnType<AppContext['git']>>) => Promise<T>): Promise<GitStatus> { | |
| 252 | + const catalog = await ctx.catalog() | |
| 253 | + if (!(await isDirectory(catalog.root))) throw new HttpError(400, 'The catalog folder does not exist yet; initialize it first') | |
| 254 | + const git = await ctx.git() | |
| 255 | + await run(git) | |
| 256 | + return git.status() | |
| 257 | + } | |
| 258 | + | |
| 259 | + app.post('/api/catalog/git/remote', async (c) => { | |
| 260 | + const url = requireUrlLike((await readJsonBody(c)).url, 'url') | |
| 261 | + return c.json(await gitOp((g) => g.setRemote(url))) | |
| 262 | + }) | |
| 263 | + | |
| 264 | + app.post('/api/catalog/git/commit', async (c) => { | |
| 265 | + const message = (await readJsonBody(c)).message | |
| 266 | + if (typeof message !== 'string' || !message.trim()) throw new HttpError(400, '"message" is required') | |
| 267 | + return c.json(await gitOp((g) => g.commitAll(message.trim()))) | |
| 268 | + }) | |
| 269 | + | |
| 270 | + // pull / push / sync return their own status (conflicts are reported there). | |
| 271 | + const returning = (run: (g: Awaited<ReturnType<AppContext['git']>>) => Promise<GitStatus>) => async (c: import('hono').Context) => { | |
| 272 | + const catalog = await ctx.catalog() | |
| 273 | + if (!(await isDirectory(catalog.root))) throw new HttpError(400, 'The catalog folder does not exist yet; initialize it first') | |
| 274 | + const status = await run(await ctx.git()) | |
| 275 | + return c.json(status) | |
| 276 | + } | |
| 277 | + app.post('/api/catalog/git/pull', returning((g) => g.pull())) | |
| 278 | + app.post('/api/catalog/git/push', returning((g) => g.push())) | |
| 279 | + app.post('/api/catalog/git/sync', returning((g) => g.sync())) | |
| 280 | + | |
| 281 | + app.post('/api/catalog/git/resolve', async (c) => { | |
| 282 | + const body = await readJsonBody(c) | |
| 283 | + if (typeof body.path !== 'string' || !body.path) throw new HttpError(400, '"path" is required') | |
| 284 | + if (body.side !== 'ours' && body.side !== 'theirs') throw new HttpError(400, '"side" must be "ours" or "theirs"') | |
| 285 | + const file = body.path | |
| 286 | + const side = body.side | |
| 287 | + const catalog = await ctx.catalog() | |
| 288 | + if (!(await isDirectory(catalog.root))) throw new HttpError(400, 'The catalog folder does not exist yet; initialize it first') | |
| 289 | + return c.json(await (await ctx.git()).resolve(file, side)) | |
| 290 | + }) | |
| 291 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,291 @@ | |||
| 1 | +import { | ||
| 2 | + CATALOG_ITEM_TYPES, | ||
| 3 | + type CatalogItemType, | ||
| 4 | + type CatalogRef, | ||
| 5 | + type CatalogResponse, | ||
| 6 | + type GitStatus, | ||
| 7 | + type ImportBody, | ||
| 8 | + type ImportResult, | ||
| 9 | + type Preset, | ||
| 10 | + type SaveItemInput, | ||
| 11 | + type ScopeConfig, | ||
| 12 | + type Settings, | ||
| 13 | +} from '@skilly/core' | ||
| 14 | +import type { Hono } from 'hono' | ||
| 15 | +import path from 'node:path' | ||
| 16 | +import { HttpError, isDirectory, isStringArray, readJsonBody, type AppContext } from './context.ts' | ||
| 17 | + | ||
| 18 | +const EMPTY_GIT: GitStatus = { isRepo: false, ahead: 0, behind: 0, dirty: [], conflicts: [] } | ||
| 19 | + | ||
| 20 | +function parseType(raw: string): CatalogItemType { | ||
| 21 | + if (!(CATALOG_ITEM_TYPES as readonly string[]).includes(raw)) throw new HttpError(400, `Unknown item type "${raw}"`) | ||
| 22 | + return raw as CatalogItemType | ||
| 23 | +} | ||
| 24 | + | ||
| 25 | +function requireUrlLike(v: unknown, label: string): string { | ||
| 26 | + if (typeof v !== 'string' || !v.trim()) throw new HttpError(400, `"${label}" is required`) | ||
| 27 | + if (v.trim().startsWith('-')) throw new HttpError(400, `Invalid ${label}`) | ||
| 28 | + return v.trim() | ||
| 29 | +} | ||
| 30 | + | ||
| 31 | +function normalizePreset(body: Record<string, unknown>, name: string): Preset { | ||
| 32 | + if (body.includes !== undefined && !isStringArray(body.includes)) throw new HttpError(400, '"includes" must be a string array') | ||
| 33 | + if (body.description !== undefined && typeof body.description !== 'string') throw new HttpError(400, '"description" must be a string') | ||
| 34 | + const items: Preset['items'] = {} | ||
| 35 | + const rawItems = body.items ?? {} | ||
| 36 | + if (typeof rawItems !== 'object' || rawItems === null || Array.isArray(rawItems)) throw new HttpError(400, '"items" must be an object') | ||
| 37 | + for (const [type, list] of Object.entries(rawItems)) { | ||
| 38 | + parseType(type) | ||
| 39 | + if (!isStringArray(list)) throw new HttpError(400, `"items.${type}" must be a string array`) | ||
| 40 | + items[type as CatalogItemType] = list | ||
| 41 | + } | ||
| 42 | + const preset: Preset = { name, includes: (body.includes as string[] | undefined) ?? [], items } | ||
| 43 | + if (body.description) preset.description = body.description as string | ||
| 44 | + return preset | ||
| 45 | +} | ||
| 46 | + | ||
| 47 | +/** Scopes (project ids or `home`) whose config references each catalog item / preset. */ | ||
| 48 | +async function computeUsage(ctx: AppContext): Promise<Record<string, string[]>> { | ||
| 49 | + const usage: Record<string, string[]> = {} | ||
| 50 | + const add = (key: string, scope: string): void => { | ||
| 51 | + const list = (usage[key] ??= []) | ||
| 52 | + if (!list.includes(scope)) list.push(scope) | ||
| 53 | + } | ||
| 54 | + const snapshot = await (await ctx.catalog()).snapshot() | ||
| 55 | + const presets = new Map(snapshot.presets.map((p) => [p.name, p])) | ||
| 56 | + const scopeIds = ['home', ...(await ctx.store.list()).map((p) => p.id)] | ||
| 57 | + for (const id of scopeIds) { | ||
| 58 | + let config: ScopeConfig | undefined | ||
| 59 | + try { | ||
| 60 | + const scope = await ctx.scope(id) | ||
| 61 | + config = await ctx.syncEngine.readConfig(scope.target) | ||
| 62 | + } catch { | ||
| 63 | + continue | ||
| 64 | + } | ||
| 65 | + if (!config) continue | ||
| 66 | + for (const [type, names] of Object.entries(config.add ?? {})) for (const n of names ?? []) add(`${type}/${n}`, id) | ||
| 67 | + const seen = new Set<string>() | ||
| 68 | + const visit = (name: string): void => { | ||
| 69 | + if (seen.has(name)) return | ||
| 70 | + seen.add(name) | ||
| 71 | + add(`preset/${name}`, id) | ||
| 72 | + const preset = presets.get(name) | ||
| 73 | + if (!preset) return | ||
| 74 | + for (const [type, names] of Object.entries(preset.items)) for (const n of names ?? []) add(`${type}/${n}`, id) | ||
| 75 | + for (const inc of preset.includes) visit(inc) | ||
| 76 | + } | ||
| 77 | + for (const name of config.presets ?? []) visit(name) | ||
| 78 | + } | ||
| 79 | + return usage | ||
| 80 | +} | ||
| 81 | + | ||
| 82 | +function usedMessage(key: string, scopes: string[]): string { | ||
| 83 | + return `${key} is used by ${scopes.length} scope${scopes.length === 1 ? '' : 's'} (${scopes.join(', ')}); use ?force=1 to delete anyway` | ||
| 84 | +} | ||
| 85 | + | ||
| 86 | +export function registerCatalogRoutes(app: Hono, ctx: AppContext): void { | ||
| 87 | + // ---- settings ---- | ||
| 88 | + app.get('/api/settings', async (c) => c.json(await ctx.settings())) | ||
| 89 | + | ||
| 90 | + app.put('/api/settings', async (c) => { | ||
| 91 | + const body = await readJsonBody(c) | ||
| 92 | + const current = await ctx.settings() | ||
| 93 | + const next: Settings = { ...current } | ||
| 94 | + if (body.catalogPath !== undefined) { | ||
| 95 | + if (typeof body.catalogPath !== 'string' || !path.isAbsolute(body.catalogPath) || body.catalogPath.includes('\0')) { | ||
| 96 | + throw new HttpError(400, '"catalogPath" must be an absolute path') | ||
| 97 | + } | ||
| 98 | + next.catalogPath = path.resolve(body.catalogPath) | ||
| 99 | + } | ||
| 100 | + if (body.autoSync !== undefined) { | ||
| 101 | + if (typeof body.autoSync !== 'boolean') throw new HttpError(400, '"autoSync" must be a boolean') | ||
| 102 | + next.autoSync = body.autoSync | ||
| 103 | + } | ||
| 104 | + await ctx.store.setSettings(next) | ||
| 105 | + return c.json(await ctx.settings()) | ||
| 106 | + }) | ||
| 107 | + | ||
| 108 | + // ---- catalog ---- | ||
| 109 | + async function catalogResponse(): Promise<CatalogResponse> { | ||
| 110 | + const catalog = await ctx.catalog() | ||
| 111 | + const git = await ctx.git() | ||
| 112 | + const exists = await catalog.exists() | ||
| 113 | + const [snapshot, gitStatus, usage] = await Promise.all([ | ||
| 114 | + catalog.snapshot(), | ||
| 115 | + exists ? git.status() : Promise.resolve(EMPTY_GIT), | ||
| 116 | + computeUsage(ctx), | ||
| 117 | + ]) | ||
| 118 | + return { snapshot, git: gitStatus, usage } | ||
| 119 | + } | ||
| 120 | + | ||
| 121 | + app.get('/api/catalog', async (c) => c.json(await catalogResponse())) | ||
| 122 | + | ||
| 123 | + app.post('/api/catalog/init', async (c) => { | ||
| 124 | + const catalog = await ctx.catalog() | ||
| 125 | + await catalog.init() | ||
| 126 | + const git = await ctx.git() | ||
| 127 | + if (!(await git.status()).isRepo) await git.init() | ||
| 128 | + return c.json(await catalogResponse()) | ||
| 129 | + }) | ||
| 130 | + | ||
| 131 | + app.get('/api/catalog/items/:type/:name', async (c) => { | ||
| 132 | + const ref: CatalogRef = { type: parseType(c.req.param('type')), name: c.req.param('name') } | ||
| 133 | + const detail = await (await ctx.catalog()).getItem(ref) | ||
| 134 | + if (!detail) throw new HttpError(404, `${ref.type} "${ref.name}" not found in the catalog`) | ||
| 135 | + return c.json(detail) | ||
| 136 | + }) | ||
| 137 | + | ||
| 138 | + app.put('/api/catalog/items/:type/:name', async (c) => { | ||
| 139 | + const type = parseType(c.req.param('type')) | ||
| 140 | + const name = c.req.param('name') | ||
| 141 | + const body = await readJsonBody(c) | ||
| 142 | + if (typeof body.content !== 'string') throw new HttpError(400, '"content" must be a string') | ||
| 143 | + if (body.files !== undefined) { | ||
| 144 | + const f = body.files | ||
| 145 | + if (!f || typeof f !== 'object' || Array.isArray(f) || !Object.values(f).every((v) => typeof v === 'string')) { | ||
| 146 | + throw new HttpError(400, '"files" must map relative paths to strings') | ||
| 147 | + } | ||
| 148 | + } | ||
| 149 | + if (body.deleteFiles !== undefined && !isStringArray(body.deleteFiles)) throw new HttpError(400, '"deleteFiles" must be a string array') | ||
| 150 | + if (body.tags !== undefined && !isStringArray(body.tags)) throw new HttpError(400, '"tags" must be a string array') | ||
| 151 | + if (body.previousName !== undefined && typeof body.previousName !== 'string') throw new HttpError(400, '"previousName" must be a string') | ||
| 152 | + const input: SaveItemInput = { | ||
| 153 | + type, | ||
| 154 | + name, | ||
| 155 | + content: body.content, | ||
| 156 | + files: body.files as Record<string, string> | undefined, | ||
| 157 | + deleteFiles: body.deleteFiles as string[] | undefined, | ||
| 158 | + tags: body.tags as string[] | undefined, | ||
| 159 | + previousName: body.previousName as string | undefined, | ||
| 160 | + } | ||
| 161 | + const catalog = await ctx.catalog() | ||
| 162 | + if (!(await catalog.exists())) await catalog.init() | ||
| 163 | + const item = await catalog.saveItem(input) | ||
| 164 | + ctx.catalogChanged() | ||
| 165 | + return c.json(item) | ||
| 166 | + }) | ||
| 167 | + | ||
| 168 | + app.delete('/api/catalog/items/:type/:name', async (c) => { | ||
| 169 | + const ref: CatalogRef = { type: parseType(c.req.param('type')), name: c.req.param('name') } | ||
| 170 | + if (c.req.query('force') !== '1') { | ||
| 171 | + const key = `${ref.type}/${ref.name}` | ||
| 172 | + const scopes = (await computeUsage(ctx))[key] | ||
| 173 | + if (scopes?.length) throw new HttpError(409, usedMessage(key, scopes)) | ||
| 174 | + } | ||
| 175 | + await (await ctx.catalog()).deleteItem(ref) | ||
| 176 | + ctx.catalogChanged() | ||
| 177 | + return c.body(null, 204) | ||
| 178 | + }) | ||
| 179 | + | ||
| 180 | + app.put('/api/catalog/items/:type/:name/tags', async (c) => { | ||
| 181 | + const ref: CatalogRef = { type: parseType(c.req.param('type')), name: c.req.param('name') } | ||
| 182 | + const body = await readJsonBody(c) | ||
| 183 | + if (!isStringArray(body.tags)) throw new HttpError(400, '"tags" must be a string array') | ||
| 184 | + await (await ctx.catalog()).setTags(ref, body.tags) | ||
| 185 | + ctx.catalogChanged() | ||
| 186 | + return c.body(null, 204) | ||
| 187 | + }) | ||
| 188 | + | ||
| 189 | + app.post('/api/catalog/import', async (c) => { | ||
| 190 | + const body = (await readJsonBody(c)) as Partial<ImportBody> | ||
| 191 | + if (typeof body.scope !== 'string' || !body.scope) throw new HttpError(400, '"scope" is required') | ||
| 192 | + if (!isStringArray(body.itemIds)) throw new HttpError(400, '"itemIds" must be a string array') | ||
| 193 | + if (body.tags !== undefined && !isStringArray(body.tags)) throw new HttpError(400, '"tags" must be a string array') | ||
| 194 | + if (body.overwrite !== undefined && typeof body.overwrite !== 'boolean') throw new HttpError(400, '"overwrite" must be a boolean') | ||
| 195 | + const scope = await ctx.scope(body.scope) | ||
| 196 | + const inventory = await scope.scan() | ||
| 197 | + const byId = new Map(inventory.items.map((i) => [i.id, i])) | ||
| 198 | + const catalog = await ctx.catalog() | ||
| 199 | + if (!(await catalog.exists())) await catalog.init() | ||
| 200 | + const result: ImportResult = { imported: [], skipped: [] } | ||
| 201 | + for (const id of body.itemIds) { | ||
| 202 | + const item = byId.get(id) | ||
| 203 | + if (!item) { | ||
| 204 | + result.skipped.push({ id, reason: 'Item not found in the scope inventory' }) | ||
| 205 | + continue | ||
| 206 | + } | ||
| 207 | + if (item.type === 'instructions') { | ||
| 208 | + result.skipped.push({ id, reason: 'Instructions files (CLAUDE.md / AGENTS.md) cannot be imported' }) | ||
| 209 | + continue | ||
| 210 | + } | ||
| 211 | + try { | ||
| 212 | + result.imported.push(await catalog.importFromInventory(item, { tags: body.tags, overwrite: body.overwrite })) | ||
| 213 | + } catch (e) { | ||
| 214 | + result.skipped.push({ id, reason: e instanceof Error ? e.message : String(e) }) | ||
| 215 | + } | ||
| 216 | + } | ||
| 217 | + if (result.imported.length > 0) ctx.catalogChanged() | ||
| 218 | + return c.json(result) | ||
| 219 | + }) | ||
| 220 | + | ||
| 221 | + app.put('/api/catalog/presets/:name', async (c) => { | ||
| 222 | + const name = c.req.param('name') | ||
| 223 | + const body = await readJsonBody(c) | ||
| 224 | + if (body.previousName !== undefined && typeof body.previousName !== 'string') throw new HttpError(400, '"previousName" must be a string') | ||
| 225 | + const catalog = await ctx.catalog() | ||
| 226 | + if (!(await catalog.exists())) await catalog.init() | ||
| 227 | + const saved = await catalog.savePreset(normalizePreset(body, name), body.previousName as string | undefined) | ||
| 228 | + ctx.catalogChanged() | ||
| 229 | + return c.json(saved) | ||
| 230 | + }) | ||
| 231 | + | ||
| 232 | + app.delete('/api/catalog/presets/:name', async (c) => { | ||
| 233 | + const name = c.req.param('name') | ||
| 234 | + if (c.req.query('force') !== '1') { | ||
| 235 | + const key = `preset/${name}` | ||
| 236 | + const scopes = (await computeUsage(ctx))[key] | ||
| 237 | + if (scopes?.length) throw new HttpError(409, usedMessage(key, scopes)) | ||
| 238 | + } | ||
| 239 | + await (await ctx.catalog()).deletePreset(name) | ||
| 240 | + ctx.catalogChanged() | ||
| 241 | + return c.body(null, 204) | ||
| 242 | + }) | ||
| 243 | + | ||
| 244 | + // ---- catalog git ---- | ||
| 245 | + app.get('/api/catalog/git', async (c) => { | ||
| 246 | + const catalog = await ctx.catalog() | ||
| 247 | + if (!(await catalog.exists())) return c.json(EMPTY_GIT) | ||
| 248 | + return c.json(await (await ctx.git()).status()) | ||
| 249 | + }) | ||
| 250 | + | ||
| 251 | + async function gitOp<T>(run: (git: Awaited<ReturnType<AppContext['git']>>) => Promise<T>): Promise<GitStatus> { | ||
| 252 | + const catalog = await ctx.catalog() | ||
| 253 | + if (!(await isDirectory(catalog.root))) throw new HttpError(400, 'The catalog folder does not exist yet; initialize it first') | ||
| 254 | + const git = await ctx.git() | ||
| 255 | + await run(git) | ||
| 256 | + return git.status() | ||
| 257 | + } | ||
| 258 | + | ||
| 259 | + app.post('/api/catalog/git/remote', async (c) => { | ||
| 260 | + const url = requireUrlLike((await readJsonBody(c)).url, 'url') | ||
| 261 | + return c.json(await gitOp((g) => g.setRemote(url))) | ||
| 262 | + }) | ||
| 263 | + | ||
| 264 | + app.post('/api/catalog/git/commit', async (c) => { | ||
| 265 | + const message = (await readJsonBody(c)).message | ||
| 266 | + if (typeof message !== 'string' || !message.trim()) throw new HttpError(400, '"message" is required') | ||
| 267 | + return c.json(await gitOp((g) => g.commitAll(message.trim()))) | ||
| 268 | + }) | ||
| 269 | + | ||
| 270 | + // pull / push / sync return their own status (conflicts are reported there). | ||
| 271 | + const returning = (run: (g: Awaited<ReturnType<AppContext['git']>>) => Promise<GitStatus>) => async (c: import('hono').Context) => { | ||
| 272 | + const catalog = await ctx.catalog() | ||
| 273 | + if (!(await isDirectory(catalog.root))) throw new HttpError(400, 'The catalog folder does not exist yet; initialize it first') | ||
| 274 | + const status = await run(await ctx.git()) | ||
| 275 | + return c.json(status) | ||
| 276 | + } | ||
| 277 | + app.post('/api/catalog/git/pull', returning((g) => g.pull())) | ||
| 278 | + app.post('/api/catalog/git/push', returning((g) => g.push())) | ||
| 279 | + app.post('/api/catalog/git/sync', returning((g) => g.sync())) | ||
| 280 | + | ||
| 281 | + app.post('/api/catalog/git/resolve', async (c) => { | ||
| 282 | + const body = await readJsonBody(c) | ||
| 283 | + if (typeof body.path !== 'string' || !body.path) throw new HttpError(400, '"path" is required') | ||
| 284 | + if (body.side !== 'ours' && body.side !== 'theirs') throw new HttpError(400, '"side" must be "ours" or "theirs"') | ||
| 285 | + const file = body.path | ||
| 286 | + const side = body.side | ||
| 287 | + const catalog = await ctx.catalog() | ||
| 288 | + if (!(await isDirectory(catalog.root))) throw new HttpError(400, 'The catalog folder does not exist yet; initialize it first') | ||
| 289 | + return c.json(await (await ctx.git()).resolve(file, side)) | ||
| 290 | + }) | ||
| 291 | +} | ||
added
packages/server/src/context.ts +103 -0 | new file mode 100644 | ||
| @@ -0,0 +1,103 @@ | ||
| 1 | +import { stat } from 'node:fs/promises' | |
| 2 | +import path from 'node:path' | |
| 3 | +import { | |
| 4 | + scanHome, | |
| 5 | + scanProject, | |
| 6 | + type CatalogGit, | |
| 7 | + type CatalogStore, | |
| 8 | + type CreateMarketplace, | |
| 9 | + type OpenCatalog, | |
| 10 | + type OpenCatalogGit, | |
| 11 | + type ScanContext, | |
| 12 | + type ScopeInventory, | |
| 13 | + type Settings, | |
| 14 | + type SyncEngine, | |
| 15 | + type SyncTarget, | |
| 16 | +} from '@skilly/core' | |
| 17 | +import type { Context } from 'hono' | |
| 18 | +import type { ProjectStore } from './store.ts' | |
| 19 | + | |
| 20 | +/** Error carrying the HTTP status to answer with. */ | |
| 21 | +export class HttpError extends Error { | |
| 22 | + constructor( | |
| 23 | + readonly status: 400 | 404 | 409, | |
| 24 | + message: string, | |
| 25 | + ) { | |
| 26 | + super(message) | |
| 27 | + } | |
| 28 | +} | |
| 29 | + | |
| 30 | +export interface ScopeRef { | |
| 31 | + id: string | |
| 32 | + kind: 'home' | 'project' | |
| 33 | + target: SyncTarget | |
| 34 | + scan(): Promise<ScopeInventory> | |
| 35 | +} | |
| 36 | + | |
| 37 | +export interface AppContext { | |
| 38 | + configDir: string | |
| 39 | + scanContext: ScanContext | |
| 40 | + store: ProjectStore | |
| 41 | + syncEngine: SyncEngine | |
| 42 | + openCatalog: OpenCatalog | |
| 43 | + openCatalogGit: OpenCatalogGit | |
| 44 | + createMarketplace: CreateMarketplace | |
| 45 | + marketplaceCacheDir: string | |
| 46 | + settings(): Promise<Settings> | |
| 47 | + catalog(): Promise<CatalogStore> | |
| 48 | + git(): Promise<CatalogGit> | |
| 49 | + /** Throws HttpError 404 for an unknown scope. */ | |
| 50 | + scope(id: string): Promise<ScopeRef> | |
| 51 | + /** Drops cached project scans after a write. */ | |
| 52 | + invalidateScan(id?: string): void | |
| 53 | + /** Call after any catalog write; commits / syncs the catalog later when `autoSync` is on. */ | |
| 54 | + catalogChanged(): void | |
| 55 | +} | |
| 56 | + | |
| 57 | +export async function isDirectory(p: string): Promise<boolean> { | |
| 58 | + try { | |
| 59 | + return (await stat(p)).isDirectory() | |
| 60 | + } catch { | |
| 61 | + return false | |
| 62 | + } | |
| 63 | +} | |
| 64 | + | |
| 65 | +export async function readJsonBody(c: Context): Promise<Record<string, unknown>> { | |
| 66 | + let body: unknown | |
| 67 | + try { | |
| 68 | + body = await c.req.json() | |
| 69 | + } catch { | |
| 70 | + throw new HttpError(400, 'Invalid JSON body') | |
| 71 | + } | |
| 72 | + if (!body || typeof body !== 'object' || Array.isArray(body)) throw new HttpError(400, 'A JSON object body is required') | |
| 73 | + return body as Record<string, unknown> | |
| 74 | +} | |
| 75 | + | |
| 76 | +export function isStringArray(v: unknown): v is string[] { | |
| 77 | + return Array.isArray(v) && v.every((x) => typeof x === 'string') | |
| 78 | +} | |
| 79 | + | |
| 80 | +export function makeScopeRef( | |
| 81 | + id: string, | |
| 82 | + kind: 'home' | 'project', | |
| 83 | + root: string, | |
| 84 | + scanContext: ScanContext, | |
| 85 | + configDir: string, | |
| 86 | +): ScopeRef { | |
| 87 | + const base = { homeDir: scanContext.homeDir, codexHome: scanContext.codexHome } | |
| 88 | + if (kind === 'home') { | |
| 89 | + return { | |
| 90 | + id, | |
| 91 | + kind, | |
| 92 | + target: { kind: 'home', root: scanContext.homeDir, ...base, configDir }, | |
| 93 | + scan: () => scanHome(scanContext), | |
| 94 | + } | |
| 95 | + } | |
| 96 | + const abs = path.resolve(root) | |
| 97 | + return { | |
| 98 | + id, | |
| 99 | + kind, | |
| 100 | + target: { kind: 'project', root: abs, ...base, configDir: abs }, | |
| 101 | + scan: () => scanProject(abs, scanContext), | |
| 102 | + } | |
| 103 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,103 @@ | |||
| 1 | +import { stat } from 'node:fs/promises' | ||
| 2 | +import path from 'node:path' | ||
| 3 | +import { | ||
| 4 | + scanHome, | ||
| 5 | + scanProject, | ||
| 6 | + type CatalogGit, | ||
| 7 | + type CatalogStore, | ||
| 8 | + type CreateMarketplace, | ||
| 9 | + type OpenCatalog, | ||
| 10 | + type OpenCatalogGit, | ||
| 11 | + type ScanContext, | ||
| 12 | + type ScopeInventory, | ||
| 13 | + type Settings, | ||
| 14 | + type SyncEngine, | ||
| 15 | + type SyncTarget, | ||
| 16 | +} from '@skilly/core' | ||
| 17 | +import type { Context } from 'hono' | ||
| 18 | +import type { ProjectStore } from './store.ts' | ||
| 19 | + | ||
| 20 | +/** Error carrying the HTTP status to answer with. */ | ||
| 21 | +export class HttpError extends Error { | ||
| 22 | + constructor( | ||
| 23 | + readonly status: 400 | 404 | 409, | ||
| 24 | + message: string, | ||
| 25 | + ) { | ||
| 26 | + super(message) | ||
| 27 | + } | ||
| 28 | +} | ||
| 29 | + | ||
| 30 | +export interface ScopeRef { | ||
| 31 | + id: string | ||
| 32 | + kind: 'home' | 'project' | ||
| 33 | + target: SyncTarget | ||
| 34 | + scan(): Promise<ScopeInventory> | ||
| 35 | +} | ||
| 36 | + | ||
| 37 | +export interface AppContext { | ||
| 38 | + configDir: string | ||
| 39 | + scanContext: ScanContext | ||
| 40 | + store: ProjectStore | ||
| 41 | + syncEngine: SyncEngine | ||
| 42 | + openCatalog: OpenCatalog | ||
| 43 | + openCatalogGit: OpenCatalogGit | ||
| 44 | + createMarketplace: CreateMarketplace | ||
| 45 | + marketplaceCacheDir: string | ||
| 46 | + settings(): Promise<Settings> | ||
| 47 | + catalog(): Promise<CatalogStore> | ||
| 48 | + git(): Promise<CatalogGit> | ||
| 49 | + /** Throws HttpError 404 for an unknown scope. */ | ||
| 50 | + scope(id: string): Promise<ScopeRef> | ||
| 51 | + /** Drops cached project scans after a write. */ | ||
| 52 | + invalidateScan(id?: string): void | ||
| 53 | + /** Call after any catalog write; commits / syncs the catalog later when `autoSync` is on. */ | ||
| 54 | + catalogChanged(): void | ||
| 55 | +} | ||
| 56 | + | ||
| 57 | +export async function isDirectory(p: string): Promise<boolean> { | ||
| 58 | + try { | ||
| 59 | + return (await stat(p)).isDirectory() | ||
| 60 | + } catch { | ||
| 61 | + return false | ||
| 62 | + } | ||
| 63 | +} | ||
| 64 | + | ||
| 65 | +export async function readJsonBody(c: Context): Promise<Record<string, unknown>> { | ||
| 66 | + let body: unknown | ||
| 67 | + try { | ||
| 68 | + body = await c.req.json() | ||
| 69 | + } catch { | ||
| 70 | + throw new HttpError(400, 'Invalid JSON body') | ||
| 71 | + } | ||
| 72 | + if (!body || typeof body !== 'object' || Array.isArray(body)) throw new HttpError(400, 'A JSON object body is required') | ||
| 73 | + return body as Record<string, unknown> | ||
| 74 | +} | ||
| 75 | + | ||
| 76 | +export function isStringArray(v: unknown): v is string[] { | ||
| 77 | + return Array.isArray(v) && v.every((x) => typeof x === 'string') | ||
| 78 | +} | ||
| 79 | + | ||
| 80 | +export function makeScopeRef( | ||
| 81 | + id: string, | ||
| 82 | + kind: 'home' | 'project', | ||
| 83 | + root: string, | ||
| 84 | + scanContext: ScanContext, | ||
| 85 | + configDir: string, | ||
| 86 | +): ScopeRef { | ||
| 87 | + const base = { homeDir: scanContext.homeDir, codexHome: scanContext.codexHome } | ||
| 88 | + if (kind === 'home') { | ||
| 89 | + return { | ||
| 90 | + id, | ||
| 91 | + kind, | ||
| 92 | + target: { kind: 'home', root: scanContext.homeDir, ...base, configDir }, | ||
| 93 | + scan: () => scanHome(scanContext), | ||
| 94 | + } | ||
| 95 | + } | ||
| 96 | + const abs = path.resolve(root) | ||
| 97 | + return { | ||
| 98 | + id, | ||
| 99 | + kind, | ||
| 100 | + target: { kind: 'project', root: abs, ...base, configDir: abs }, | ||
| 101 | + scan: () => scanProject(abs, scanContext), | ||
| 102 | + } | ||
| 103 | +} | ||
modified
packages/server/src/index.ts +110 -10 | @@ -8,16 +8,28 @@ import { serve } from '@hono/node-server' | ||
| 8 | 8 | import { |
| 9 | 9 | ITEM_TYPES, |
| 10 | 10 | defaultScanContext, |
| 11 | + createMarketplace as realCreateMarketplace, | |
| 12 | + openCatalog as realOpenCatalog, | |
| 13 | + openCatalogGit as realOpenCatalogGit, | |
| 14 | + syncEngine as realSyncEngine, | |
| 11 | 15 | scanHome, |
| 12 | 16 | scanProject, |
| 13 | 17 | type AddProjectBody, |
| 14 | 18 | type ApiError, |
| 19 | + type CreateMarketplace, | |
| 15 | 20 | type ItemType, |
| 21 | + type OpenCatalog, | |
| 22 | + type OpenCatalogGit, | |
| 23 | + type SyncEngine, | |
| 16 | 24 | type ProjectSummary, |
| 17 | 25 | type ScanContext, |
| 18 | 26 | type ScopeInventory, |
| 19 | 27 | } from '@skilly/core' |
| 20 | 28 | import { Hono } from 'hono' |
| 29 | +import { registerCatalogRoutes } from './catalog-routes.ts' | |
| 30 | +import { HttpError, isDirectory, makeScopeRef, type AppContext } from './context.ts' | |
| 31 | +import { registerMarketplaceRoutes } from './marketplace-routes.ts' | |
| 32 | +import { registerScopeRoutes } from './scope-routes.ts' | |
| 21 | 33 | import { FileAccessError, isInside, readAllowedFile } from './files.ts' |
| 22 | 34 | import { listDirectory, listRoots } from './fslist.ts' |
| 23 | 35 | import { ProjectStore, type StoredProject } from './store.ts' |
| @@ -32,6 +44,16 @@ export interface AppOptions { | ||
| 32 | 44 | /** Defaults to `~/.skilly`. */ |
| 33 | 45 | configDir?: string |
| 34 | 46 | scanContext?: ScanContext |
| 47 | + /** Sync engine (defaults to the real one); injectable for tests. */ | |
| 48 | + syncEngine?: SyncEngine | |
| 49 | + /** Catalog factory (defaults to the real one). */ | |
| 50 | + catalog?: OpenCatalog | |
| 51 | + /** Catalog git factory (defaults to the real one). */ | |
| 52 | + catalogGit?: OpenCatalogGit | |
| 53 | + /** Marketplace factory (defaults to the real one). */ | |
| 54 | + marketplace?: CreateMarketplace | |
| 55 | + /** Debounce before the catalog auto-sync runs after a write (default 3000 ms). */ | |
| 56 | + autoSyncDelayMs?: number | |
| 35 | 57 | /** Built UI folder served at `/`. */ |
| 36 | 58 | staticDir?: string |
| 37 | 59 | /** Called on every `POST /api/heartbeat`. */ |
| @@ -98,13 +120,7 @@ async function pathKind(p: string): Promise<'dir' | 'file' | 'missing'> { | ||
| 98 | 120 | } |
| 99 | 121 | } |
| 100 | 122 | |
| 101 | -async function isDir(p: string): Promise<boolean> { | |
| 102 | - try { | |
| 103 | - return (await stat(p)).isDirectory() | |
| 104 | - } catch { | |
| 105 | - return false | |
| 106 | - } | |
| 107 | -} | |
| 123 | +const isDir = isDirectory | |
| 108 | 124 | |
| 109 | 125 | async function readStatic(root: string, urlPath: string): Promise<{ body: Buffer; type: string } | undefined> { |
| 110 | 126 | let rel: string |
| @@ -125,7 +141,17 @@ async function readStatic(root: string, urlPath: string): Promise<{ body: Buffer | ||
| 125 | 141 | } |
| 126 | 142 | } |
| 127 | 143 | |
| 144 | +export interface AppHandle { | |
| 145 | + app: Hono | |
| 146 | + /** Cancels pending timers (auto-sync). */ | |
| 147 | + dispose(): void | |
| 148 | +} | |
| 149 | + | |
| 128 | 150 | export function createApp(options: AppOptions): Hono { |
| 151 | + return createAppHandle(options).app | |
| 152 | +} | |
| 153 | + | |
| 154 | +export function createAppHandle(options: AppOptions): AppHandle { | |
| 129 | 155 | const { token } = options |
| 130 | 156 | const configDir = options.configDir ?? path.join(os.homedir(), '.skilly') |
| 131 | 157 | const ctx = options.scanContext ?? defaultScanContext() |
| @@ -133,6 +159,63 @@ export function createApp(options: AppOptions): Hono { | ||
| 133 | 159 | const scanCache = new Map<string, { at: number; inventory: ScopeInventory }>() |
| 134 | 160 | const app = new Hono() |
| 135 | 161 | |
| 162 | + const syncEngine = options.syncEngine ?? realSyncEngine | |
| 163 | + const openCatalog = options.catalog ?? realOpenCatalog | |
| 164 | + const openCatalogGit = options.catalogGit ?? realOpenCatalogGit | |
| 165 | + const createMarketplace = options.marketplace ?? realCreateMarketplace | |
| 166 | + const marketplaceCacheDir = path.join(configDir, 'cache', 'marketplaces') | |
| 167 | + | |
| 168 | + let syncTimer: NodeJS.Timeout | undefined | |
| 169 | + let syncChain: Promise<void> = Promise.resolve() | |
| 170 | + | |
| 171 | + const runAutoSync = async (): Promise<void> => { | |
| 172 | + const settings = await store.getSettings() | |
| 173 | + if (!settings.autoSync) return | |
| 174 | + const git = openCatalogGit(settings.catalogPath) | |
| 175 | + const status = await git.status() | |
| 176 | + if (!status.isRepo) return | |
| 177 | + if (status.remote) await git.sync() | |
| 178 | + else await git.commitAll('skilly: update catalog') | |
| 179 | + } | |
| 180 | + | |
| 181 | + const appCtx: AppContext = { | |
| 182 | + configDir, | |
| 183 | + scanContext: ctx, | |
| 184 | + store, | |
| 185 | + syncEngine, | |
| 186 | + openCatalog, | |
| 187 | + openCatalogGit, | |
| 188 | + createMarketplace, | |
| 189 | + marketplaceCacheDir, | |
| 190 | + settings: () => store.getSettings(), | |
| 191 | + catalog: async () => openCatalog((await store.getSettings()).catalogPath), | |
| 192 | + git: async () => openCatalogGit((await store.getSettings()).catalogPath), | |
| 193 | + async scope(id) { | |
| 194 | + if (id === 'home') return makeScopeRef('home', 'home', ctx.homeDir, ctx, configDir) | |
| 195 | + const project = (await store.list()).find((p) => p.id === id) | |
| 196 | + if (!project) throw new HttpError(404, 'Unknown scope') | |
| 197 | + if (!(await isDirectory(project.path))) throw new HttpError(404, 'Project folder no longer exists') | |
| 198 | + return makeScopeRef(id, 'project', project.path, ctx, configDir) | |
| 199 | + }, | |
| 200 | + invalidateScan(id) { | |
| 201 | + if (id) scanCache.delete(id) | |
| 202 | + else scanCache.clear() | |
| 203 | + }, | |
| 204 | + catalogChanged() { | |
| 205 | + void store.getSettings().then((s) => { | |
| 206 | + if (!s.autoSync) return | |
| 207 | + if (syncTimer) clearTimeout(syncTimer) | |
| 208 | + syncTimer = setTimeout(() => { | |
| 209 | + syncTimer = undefined | |
| 210 | + syncChain = syncChain | |
| 211 | + .then(runAutoSync) | |
| 212 | + .catch((e: unknown) => console.error('skilly: catalog auto-sync failed:', e instanceof Error ? e.message : e)) | |
| 213 | + }, options.autoSyncDelayMs ?? 3000) | |
| 214 | + syncTimer.unref() | |
| 215 | + }) | |
| 216 | + }, | |
| 217 | + } | |
| 218 | + | |
| 136 | 219 | const err = (message: string): ApiError => ({ error: message }) |
| 137 | 220 | |
| 138 | 221 | // DNS rebinding protection: only loopback host names are served. |
| @@ -147,6 +230,12 @@ export function createApp(options: AppOptions): Hono { | ||
| 147 | 230 | await next() |
| 148 | 231 | }) |
| 149 | 232 | |
| 233 | + // Validation errors and core errors carry user-facing messages; stack traces are never sent. | |
| 234 | + app.onError((e, c) => { | |
| 235 | + if (e instanceof HttpError) return c.json(err(e.message), e.status) | |
| 236 | + return c.json(err(e instanceof Error && e.message ? e.message : 'Request failed'), 400) | |
| 237 | + }) | |
| 238 | + | |
| 150 | 239 | async function summarize(p: StoredProject, useCache: boolean): Promise<ProjectSummary> { |
| 151 | 240 | const base = { id: p.id, name: p.name, path: p.path, addedAt: p.addedAt } |
| 152 | 241 | if (!(await isDir(p.path))) return { ...base, exists: false, agentsDetected: [], counts: emptyCounts() } |
| @@ -229,7 +318,7 @@ export function createApp(options: AppOptions): Hono { | ||
| 229 | 318 | const file = await readAllowedFile(p, { |
| 230 | 319 | homeDir: ctx.homeDir, |
| 231 | 320 | codexHome: ctx.codexHome, |
| 232 | - projectRoots: projects.map((x) => x.path), | |
| 321 | + projectRoots: [...projects.map((x) => x.path), (await store.getSettings()).catalogPath, marketplaceCacheDir], | |
| 233 | 322 | }) |
| 234 | 323 | return c.json(file) |
| 235 | 324 | } catch (e) { |
| @@ -238,6 +327,10 @@ export function createApp(options: AppOptions): Hono { | ||
| 238 | 327 | } |
| 239 | 328 | }) |
| 240 | 329 | |
| 330 | + registerCatalogRoutes(app, appCtx) | |
| 331 | + registerScopeRoutes(app, appCtx) | |
| 332 | + registerMarketplaceRoutes(app, appCtx) | |
| 333 | + | |
| 241 | 334 | app.all('/api/*', (c) => c.json(err('Not found'), 404)) |
| 242 | 335 | |
| 243 | 336 | const staticDir = options.staticDir ? path.resolve(options.staticDir) : undefined |
| @@ -254,7 +347,13 @@ export function createApp(options: AppOptions): Hono { | ||
| 254 | 347 | }) |
| 255 | 348 | } |
| 256 | 349 | |
| 257 | - return app | |
| 350 | + return { | |
| 351 | + app, | |
| 352 | + dispose() { | |
| 353 | + if (syncTimer) clearTimeout(syncTimer) | |
| 354 | + syncTimer = undefined | |
| 355 | + }, | |
| 356 | + } | |
| 258 | 357 | } |
| 259 | 358 | |
| 260 | 359 | export async function startServer(options: ServerOptions): Promise<RunningServer> { |
| @@ -269,7 +368,7 @@ export async function startServer(options: ServerOptions): Promise<RunningServer | ||
| 269 | 368 | idleTimer.unref() |
| 270 | 369 | } |
| 271 | 370 | |
| 272 | - const app = createApp({ | |
| 371 | + const { app, dispose } = createAppHandle({ | |
| 273 | 372 | ...options, |
| 274 | 373 | onHeartbeat: () => armWatchdog(options.exitOnIdleMs ?? 0), |
| 275 | 374 | }) |
| @@ -288,6 +387,7 @@ export async function startServer(options: ServerOptions): Promise<RunningServer | ||
| 288 | 387 | close: () => |
| 289 | 388 | new Promise<void>((resolve) => { |
| 290 | 389 | closed = true |
| 390 | + dispose() | |
| 291 | 391 | if (idleTimer) clearTimeout(idleTimer) |
| 292 | 392 | server.close(() => resolve()) |
| 293 | 393 | ;(server as unknown as Server).closeAllConnections?.() |
| @@ -8,16 +8,28 @@ import { serve } from '@hono/node-server' | |||
| 8 | import { | 8 | import { |
| 9 | ITEM_TYPES, | 9 | ITEM_TYPES, |
| 10 | defaultScanContext, | 10 | defaultScanContext, |
| 11 | + createMarketplace as realCreateMarketplace, | ||
| 12 | + openCatalog as realOpenCatalog, | ||
| 13 | + openCatalogGit as realOpenCatalogGit, | ||
| 14 | + syncEngine as realSyncEngine, | ||
| 11 | scanHome, | 15 | scanHome, |
| 12 | scanProject, | 16 | scanProject, |
| 13 | type AddProjectBody, | 17 | type AddProjectBody, |
| 14 | type ApiError, | 18 | type ApiError, |
| 19 | + type CreateMarketplace, | ||
| 15 | type ItemType, | 20 | type ItemType, |
| 21 | + type OpenCatalog, | ||
| 22 | + type OpenCatalogGit, | ||
| 23 | + type SyncEngine, | ||
| 16 | type ProjectSummary, | 24 | type ProjectSummary, |
| 17 | type ScanContext, | 25 | type ScanContext, |
| 18 | type ScopeInventory, | 26 | type ScopeInventory, |
| 19 | } from '@skilly/core' | 27 | } from '@skilly/core' |
| 20 | import { Hono } from 'hono' | 28 | import { Hono } from 'hono' |
| 29 | +import { registerCatalogRoutes } from './catalog-routes.ts' | ||
| 30 | +import { HttpError, isDirectory, makeScopeRef, type AppContext } from './context.ts' | ||
| 31 | +import { registerMarketplaceRoutes } from './marketplace-routes.ts' | ||
| 32 | +import { registerScopeRoutes } from './scope-routes.ts' | ||
| 21 | import { FileAccessError, isInside, readAllowedFile } from './files.ts' | 33 | import { FileAccessError, isInside, readAllowedFile } from './files.ts' |
| 22 | import { listDirectory, listRoots } from './fslist.ts' | 34 | import { listDirectory, listRoots } from './fslist.ts' |
| 23 | import { ProjectStore, type StoredProject } from './store.ts' | 35 | import { ProjectStore, type StoredProject } from './store.ts' |
| @@ -32,6 +44,16 @@ export interface AppOptions { | |||
| 32 | /** Defaults to `~/.skilly`. */ | 44 | /** Defaults to `~/.skilly`. */ |
| 33 | configDir?: string | 45 | configDir?: string |
| 34 | scanContext?: ScanContext | 46 | scanContext?: ScanContext |
| 47 | + /** Sync engine (defaults to the real one); injectable for tests. */ | ||
| 48 | + syncEngine?: SyncEngine | ||
| 49 | + /** Catalog factory (defaults to the real one). */ | ||
| 50 | + catalog?: OpenCatalog | ||
| 51 | + /** Catalog git factory (defaults to the real one). */ | ||
| 52 | + catalogGit?: OpenCatalogGit | ||
| 53 | + /** Marketplace factory (defaults to the real one). */ | ||
| 54 | + marketplace?: CreateMarketplace | ||
| 55 | + /** Debounce before the catalog auto-sync runs after a write (default 3000 ms). */ | ||
| 56 | + autoSyncDelayMs?: number | ||
| 35 | /** Built UI folder served at `/`. */ | 57 | /** Built UI folder served at `/`. */ |
| 36 | staticDir?: string | 58 | staticDir?: string |
| 37 | /** Called on every `POST /api/heartbeat`. */ | 59 | /** Called on every `POST /api/heartbeat`. */ |
| @@ -98,13 +120,7 @@ async function pathKind(p: string): Promise<'dir' | 'file' | 'missing'> { | |||
| 98 | } | 120 | } |
| 99 | } | 121 | } |
| 100 | 122 | ||
| 101 | -async function isDir(p: string): Promise<boolean> { | 123 | +const isDir = isDirectory |
| 102 | - try { | ||
| 103 | - return (await stat(p)).isDirectory() | ||
| 104 | - } catch { | ||
| 105 | - return false | ||
| 106 | - } | ||
| 107 | -} | ||
| 108 | 124 | ||
| 109 | async function readStatic(root: string, urlPath: string): Promise<{ body: Buffer; type: string } | undefined> { | 125 | async function readStatic(root: string, urlPath: string): Promise<{ body: Buffer; type: string } | undefined> { |
| 110 | let rel: string | 126 | let rel: string |
| @@ -125,7 +141,17 @@ async function readStatic(root: string, urlPath: string): Promise<{ body: Buffer | |||
| 125 | } | 141 | } |
| 126 | } | 142 | } |
| 127 | 143 | ||
| 144 | +export interface AppHandle { | ||
| 145 | + app: Hono | ||
| 146 | + /** Cancels pending timers (auto-sync). */ | ||
| 147 | + dispose(): void | ||
| 148 | +} | ||
| 149 | + | ||
| 128 | export function createApp(options: AppOptions): Hono { | 150 | export function createApp(options: AppOptions): Hono { |
| 151 | + return createAppHandle(options).app | ||
| 152 | +} | ||
| 153 | + | ||
| 154 | +export function createAppHandle(options: AppOptions): AppHandle { | ||
| 129 | const { token } = options | 155 | const { token } = options |
| 130 | const configDir = options.configDir ?? path.join(os.homedir(), '.skilly') | 156 | const configDir = options.configDir ?? path.join(os.homedir(), '.skilly') |
| 131 | const ctx = options.scanContext ?? defaultScanContext() | 157 | const ctx = options.scanContext ?? defaultScanContext() |
| @@ -133,6 +159,63 @@ export function createApp(options: AppOptions): Hono { | |||
| 133 | const scanCache = new Map<string, { at: number; inventory: ScopeInventory }>() | 159 | const scanCache = new Map<string, { at: number; inventory: ScopeInventory }>() |
| 134 | const app = new Hono() | 160 | const app = new Hono() |
| 135 | 161 | ||
| 162 | + const syncEngine = options.syncEngine ?? realSyncEngine | ||
| 163 | + const openCatalog = options.catalog ?? realOpenCatalog | ||
| 164 | + const openCatalogGit = options.catalogGit ?? realOpenCatalogGit | ||
| 165 | + const createMarketplace = options.marketplace ?? realCreateMarketplace | ||
| 166 | + const marketplaceCacheDir = path.join(configDir, 'cache', 'marketplaces') | ||
| 167 | + | ||
| 168 | + let syncTimer: NodeJS.Timeout | undefined | ||
| 169 | + let syncChain: Promise<void> = Promise.resolve() | ||
| 170 | + | ||
| 171 | + const runAutoSync = async (): Promise<void> => { | ||
| 172 | + const settings = await store.getSettings() | ||
| 173 | + if (!settings.autoSync) return | ||
| 174 | + const git = openCatalogGit(settings.catalogPath) | ||
| 175 | + const status = await git.status() | ||
| 176 | + if (!status.isRepo) return | ||
| 177 | + if (status.remote) await git.sync() | ||
| 178 | + else await git.commitAll('skilly: update catalog') | ||
| 179 | + } | ||
| 180 | + | ||
| 181 | + const appCtx: AppContext = { | ||
| 182 | + configDir, | ||
| 183 | + scanContext: ctx, | ||
| 184 | + store, | ||
| 185 | + syncEngine, | ||
| 186 | + openCatalog, | ||
| 187 | + openCatalogGit, | ||
| 188 | + createMarketplace, | ||
| 189 | + marketplaceCacheDir, | ||
| 190 | + settings: () => store.getSettings(), | ||
| 191 | + catalog: async () => openCatalog((await store.getSettings()).catalogPath), | ||
| 192 | + git: async () => openCatalogGit((await store.getSettings()).catalogPath), | ||
| 193 | + async scope(id) { | ||
| 194 | + if (id === 'home') return makeScopeRef('home', 'home', ctx.homeDir, ctx, configDir) | ||
| 195 | + const project = (await store.list()).find((p) => p.id === id) | ||
| 196 | + if (!project) throw new HttpError(404, 'Unknown scope') | ||
| 197 | + if (!(await isDirectory(project.path))) throw new HttpError(404, 'Project folder no longer exists') | ||
| 198 | + return makeScopeRef(id, 'project', project.path, ctx, configDir) | ||
| 199 | + }, | ||
| 200 | + invalidateScan(id) { | ||
| 201 | + if (id) scanCache.delete(id) | ||
| 202 | + else scanCache.clear() | ||
| 203 | + }, | ||
| 204 | + catalogChanged() { | ||
| 205 | + void store.getSettings().then((s) => { | ||
| 206 | + if (!s.autoSync) return | ||
| 207 | + if (syncTimer) clearTimeout(syncTimer) | ||
| 208 | + syncTimer = setTimeout(() => { | ||
| 209 | + syncTimer = undefined | ||
| 210 | + syncChain = syncChain | ||
| 211 | + .then(runAutoSync) | ||
| 212 | + .catch((e: unknown) => console.error('skilly: catalog auto-sync failed:', e instanceof Error ? e.message : e)) | ||
| 213 | + }, options.autoSyncDelayMs ?? 3000) | ||
| 214 | + syncTimer.unref() | ||
| 215 | + }) | ||
| 216 | + }, | ||
| 217 | + } | ||
| 218 | + | ||
| 136 | const err = (message: string): ApiError => ({ error: message }) | 219 | const err = (message: string): ApiError => ({ error: message }) |
| 137 | 220 | ||
| 138 | // DNS rebinding protection: only loopback host names are served. | 221 | // DNS rebinding protection: only loopback host names are served. |
| @@ -147,6 +230,12 @@ export function createApp(options: AppOptions): Hono { | |||
| 147 | await next() | 230 | await next() |
| 148 | }) | 231 | }) |
| 149 | 232 | ||
| 233 | + // Validation errors and core errors carry user-facing messages; stack traces are never sent. | ||
| 234 | + app.onError((e, c) => { | ||
| 235 | + if (e instanceof HttpError) return c.json(err(e.message), e.status) | ||
| 236 | + return c.json(err(e instanceof Error && e.message ? e.message : 'Request failed'), 400) | ||
| 237 | + }) | ||
| 238 | + | ||
| 150 | async function summarize(p: StoredProject, useCache: boolean): Promise<ProjectSummary> { | 239 | async function summarize(p: StoredProject, useCache: boolean): Promise<ProjectSummary> { |
| 151 | const base = { id: p.id, name: p.name, path: p.path, addedAt: p.addedAt } | 240 | const base = { id: p.id, name: p.name, path: p.path, addedAt: p.addedAt } |
| 152 | if (!(await isDir(p.path))) return { ...base, exists: false, agentsDetected: [], counts: emptyCounts() } | 241 | if (!(await isDir(p.path))) return { ...base, exists: false, agentsDetected: [], counts: emptyCounts() } |
| @@ -229,7 +318,7 @@ export function createApp(options: AppOptions): Hono { | |||
| 229 | const file = await readAllowedFile(p, { | 318 | const file = await readAllowedFile(p, { |
| 230 | homeDir: ctx.homeDir, | 319 | homeDir: ctx.homeDir, |
| 231 | codexHome: ctx.codexHome, | 320 | codexHome: ctx.codexHome, |
| 232 | - projectRoots: projects.map((x) => x.path), | 321 | + projectRoots: [...projects.map((x) => x.path), (await store.getSettings()).catalogPath, marketplaceCacheDir], |
| 233 | }) | 322 | }) |
| 234 | return c.json(file) | 323 | return c.json(file) |
| 235 | } catch (e) { | 324 | } catch (e) { |
| @@ -238,6 +327,10 @@ export function createApp(options: AppOptions): Hono { | |||
| 238 | } | 327 | } |
| 239 | }) | 328 | }) |
| 240 | 329 | ||
| 330 | + registerCatalogRoutes(app, appCtx) | ||
| 331 | + registerScopeRoutes(app, appCtx) | ||
| 332 | + registerMarketplaceRoutes(app, appCtx) | ||
| 333 | + | ||
| 241 | app.all('/api/*', (c) => c.json(err('Not found'), 404)) | 334 | app.all('/api/*', (c) => c.json(err('Not found'), 404)) |
| 242 | 335 | ||
| 243 | const staticDir = options.staticDir ? path.resolve(options.staticDir) : undefined | 336 | const staticDir = options.staticDir ? path.resolve(options.staticDir) : undefined |
| @@ -254,7 +347,13 @@ export function createApp(options: AppOptions): Hono { | |||
| 254 | }) | 347 | }) |
| 255 | } | 348 | } |
| 256 | 349 | ||
| 257 | - return app | 350 | + return { |
| 351 | + app, | ||
| 352 | + dispose() { | ||
| 353 | + if (syncTimer) clearTimeout(syncTimer) | ||
| 354 | + syncTimer = undefined | ||
| 355 | + }, | ||
| 356 | + } | ||
| 258 | } | 357 | } |
| 259 | 358 | ||
| 260 | export async function startServer(options: ServerOptions): Promise<RunningServer> { | 359 | export async function startServer(options: ServerOptions): Promise<RunningServer> { |
| @@ -269,7 +368,7 @@ export async function startServer(options: ServerOptions): Promise<RunningServer | |||
| 269 | idleTimer.unref() | 368 | idleTimer.unref() |
| 270 | } | 369 | } |
| 271 | 370 | ||
| 272 | - const app = createApp({ | 371 | + const { app, dispose } = createAppHandle({ |
| 273 | ...options, | 372 | ...options, |
| 274 | onHeartbeat: () => armWatchdog(options.exitOnIdleMs ?? 0), | 373 | onHeartbeat: () => armWatchdog(options.exitOnIdleMs ?? 0), |
| 275 | }) | 374 | }) |
| @@ -288,6 +387,7 @@ export async function startServer(options: ServerOptions): Promise<RunningServer | |||
| 288 | close: () => | 387 | close: () => |
| 289 | new Promise<void>((resolve) => { | 388 | new Promise<void>((resolve) => { |
| 290 | closed = true | 389 | closed = true |
| 390 | + dispose() | ||
| 291 | if (idleTimer) clearTimeout(idleTimer) | 391 | if (idleTimer) clearTimeout(idleTimer) |
| 292 | server.close(() => resolve()) | 392 | server.close(() => resolve()) |
| 293 | ;(server as unknown as Server).closeAllConnections?.() | 393 | ;(server as unknown as Server).closeAllConnections?.() |
added
packages/server/src/marketplace-routes.ts +150 -0 | new file mode 100644 | ||
| @@ -0,0 +1,150 @@ | ||
| 1 | +import { readFile, readdir, rm } from 'node:fs/promises' | |
| 2 | +import path from 'node:path' | |
| 3 | +import type { ImportResult, MarketplaceSkillDetail, MarketplaceSource } from '@skilly/core' | |
| 4 | +import type { Hono } from 'hono' | |
| 5 | +import { HttpError, isStringArray, readJsonBody, type AppContext } from './context.ts' | |
| 6 | + | |
| 7 | +const MAX_FILES = 200 | |
| 8 | + | |
| 9 | +function slugify(name: string): string { | |
| 10 | + return name | |
| 11 | + .toLowerCase() | |
| 12 | + .replace(/[^a-z0-9]+/g, '-') | |
| 13 | + .replace(/^-+|-+$/g, '') | |
| 14 | +} | |
| 15 | + | |
| 16 | +function optionalArg(v: unknown, label: string): string | undefined { | |
| 17 | + if (v === undefined || v === null || v === '') return undefined | |
| 18 | + if (typeof v !== 'string' || v.startsWith('-') || v.includes('\0')) throw new HttpError(400, `Invalid "${label}"`) | |
| 19 | + return v.trim() | |
| 20 | +} | |
| 21 | + | |
| 22 | +async function listFiles(dir: string, rel = ''): Promise<string[]> { | |
| 23 | + const out: string[] = [] | |
| 24 | + const entries = await readdir(path.join(dir, rel), { withFileTypes: true }) | |
| 25 | + entries.sort((a, b) => a.name.localeCompare(b.name)) | |
| 26 | + for (const e of entries) { | |
| 27 | + if (out.length >= MAX_FILES) break | |
| 28 | + if (e.name === '.git') continue | |
| 29 | + const childRel = rel ? `${rel}/${e.name}` : e.name | |
| 30 | + if (e.isDirectory()) out.push(...(await listFiles(dir, childRel))) | |
| 31 | + else if (e.isFile()) out.push(childRel) | |
| 32 | + } | |
| 33 | + return out.slice(0, MAX_FILES) | |
| 34 | +} | |
| 35 | + | |
| 36 | +function frontmatterField(md: string, key: string): string | undefined { | |
| 37 | + const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(md.replace(/^/, '')) | |
| 38 | + if (!fm) return undefined | |
| 39 | + const m = new RegExp(`^${key}:[ \\t]*(.*)$`, 'm').exec(fm[1]!) | |
| 40 | + const v = m?.[1]?.trim().replace(/^(['"])(.*)\1$/, '$2') | |
| 41 | + return v || undefined | |
| 42 | +} | |
| 43 | + | |
| 44 | +export function registerMarketplaceRoutes(app: Hono, ctx: AppContext): void { | |
| 45 | + async function marketplace() { | |
| 46 | + return ctx.createMarketplace({ cacheDir: ctx.marketplaceCacheDir, userSources: await ctx.store.listMarketplaces() }) | |
| 47 | + } | |
| 48 | + | |
| 49 | + async function requireSource(id: string) { | |
| 50 | + const mkt = await marketplace() | |
| 51 | + if (!mkt.sources().some((s) => s.id === id)) throw new HttpError(404, 'Unknown marketplace source') | |
| 52 | + return mkt | |
| 53 | + } | |
| 54 | + | |
| 55 | + app.get('/api/marketplaces', async (c) => c.json((await marketplace()).sources())) | |
| 56 | + | |
| 57 | + app.post('/api/marketplaces', async (c) => { | |
| 58 | + const body = await readJsonBody(c) | |
| 59 | + if (typeof body.name !== 'string' || !body.name.trim() || body.name.length > 100) throw new HttpError(400, '"name" is required') | |
| 60 | + if (body.kind !== 'git' && body.kind !== 'claude-marketplace') throw new HttpError(400, '"kind" must be "git" or "claude-marketplace"') | |
| 61 | + if (typeof body.url !== 'string' || !body.url.trim() || body.url.trim().startsWith('-') || body.url.includes('\0')) { | |
| 62 | + throw new HttpError(400, '"url" is required') | |
| 63 | + } | |
| 64 | + const ref = optionalArg(body.ref, 'ref') | |
| 65 | + const id = slugify(body.name) | |
| 66 | + if (!id) throw new HttpError(400, 'The name must contain letters or digits') | |
| 67 | + if ((await marketplace()).sources().some((s) => s.id === id)) throw new HttpError(409, `A source with id "${id}" already exists`) | |
| 68 | + const source: MarketplaceSource = { id, name: body.name.trim(), kind: body.kind, url: body.url.trim() } | |
| 69 | + if (ref) source.ref = ref | |
| 70 | + const added = await ctx.store.mutateMarketplaces((list) => { | |
| 71 | + if (list.some((s) => s.id === id)) return false | |
| 72 | + list.push(source) | |
| 73 | + return true | |
| 74 | + }) | |
| 75 | + if (!added) throw new HttpError(409, `A source with id "${id}" already exists`) | |
| 76 | + const created = (await marketplace()).sources().find((s) => s.id === id) | |
| 77 | + return c.json(created ?? source, 201) | |
| 78 | + }) | |
| 79 | + | |
| 80 | + app.delete('/api/marketplaces/:id', async (c) => { | |
| 81 | + const id = c.req.param('id') | |
| 82 | + const source = (await marketplace()).sources().find((s) => s.id === id) | |
| 83 | + if (!source) throw new HttpError(404, 'Unknown marketplace source') | |
| 84 | + if (source.builtIn) throw new HttpError(400, 'Built-in sources cannot be removed') | |
| 85 | + await ctx.store.mutateMarketplaces((list) => { | |
| 86 | + const i = list.findIndex((s) => s.id === id) | |
| 87 | + if (i >= 0) list.splice(i, 1) | |
| 88 | + }) | |
| 89 | + await rm(path.join(ctx.marketplaceCacheDir, id), { recursive: true, force: true, maxRetries: 3 }).catch(() => undefined) | |
| 90 | + await rm(path.join(ctx.marketplaceCacheDir, `${id}.json`), { force: true }).catch(() => undefined) | |
| 91 | + return c.body(null, 204) | |
| 92 | + }) | |
| 93 | + | |
| 94 | + app.post('/api/marketplaces/:id/refresh', async (c) => { | |
| 95 | + const mkt = await requireSource(c.req.param('id')) | |
| 96 | + return c.json(await mkt.refresh(c.req.param('id'))) | |
| 97 | + }) | |
| 98 | + | |
| 99 | + app.get('/api/marketplaces/:id/skills', async (c) => { | |
| 100 | + const mkt = await requireSource(c.req.param('id')) | |
| 101 | + const catalog = await ctx.catalog() | |
| 102 | + return c.json(await mkt.listSkills(c.req.param('id'), catalog)) | |
| 103 | + }) | |
| 104 | + | |
| 105 | + app.get('/api/marketplaces/:id/skill', async (c) => { | |
| 106 | + const mkt = await requireSource(c.req.param('id')) | |
| 107 | + const skillPath = c.req.query('path') | |
| 108 | + if (!skillPath) throw new HttpError(400, 'Missing "path"') | |
| 109 | + const dir = await mkt.skillDir(c.req.param('id'), skillPath) | |
| 110 | + const content = await readFile(path.join(dir, 'SKILL.md'), 'utf8') | |
| 111 | + const detail: MarketplaceSkillDetail = { | |
| 112 | + path: skillPath, | |
| 113 | + name: frontmatterField(content, 'name') ?? path.basename(dir), | |
| 114 | + description: frontmatterField(content, 'description'), | |
| 115 | + content, | |
| 116 | + files: await listFiles(dir), | |
| 117 | + } | |
| 118 | + return c.json(detail) | |
| 119 | + }) | |
| 120 | + | |
| 121 | + app.post('/api/marketplaces/:id/import', async (c) => { | |
| 122 | + const id = c.req.param('id') | |
| 123 | + const mkt = await requireSource(id) | |
| 124 | + const body = await readJsonBody(c) | |
| 125 | + if (!isStringArray(body.paths)) throw new HttpError(400, '"paths" must be a string array') | |
| 126 | + if (body.overwrite !== undefined && typeof body.overwrite !== 'boolean') throw new HttpError(400, '"overwrite" must be a boolean') | |
| 127 | + const catalog = await ctx.catalog() | |
| 128 | + if (!(await catalog.exists())) await catalog.init() | |
| 129 | + const names = new Map((await mkt.listSkills(id)).map((s) => [s.path, s.name])) | |
| 130 | + const result: ImportResult = { imported: [], skipped: [] } | |
| 131 | + for (const p of body.paths) { | |
| 132 | + try { | |
| 133 | + const dir = await mkt.skillDir(id, p) | |
| 134 | + const name = names.get(p) ?? path.basename(dir) | |
| 135 | + result.imported.push( | |
| 136 | + await catalog.importSkillDirectory( | |
| 137 | + dir, | |
| 138 | + name, | |
| 139 | + { kind: 'marketplace', source: id, sourcePath: p, importedAt: new Date().toISOString() }, | |
| 140 | + { overwrite: body.overwrite }, | |
| 141 | + ), | |
| 142 | + ) | |
| 143 | + } catch (e) { | |
| 144 | + result.skipped.push({ id: p, reason: e instanceof Error ? e.message : String(e) }) | |
| 145 | + } | |
| 146 | + } | |
| 147 | + if (result.imported.length > 0) ctx.catalogChanged() | |
| 148 | + return c.json(result) | |
| 149 | + }) | |
| 150 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,150 @@ | |||
| 1 | +import { readFile, readdir, rm } from 'node:fs/promises' | ||
| 2 | +import path from 'node:path' | ||
| 3 | +import type { ImportResult, MarketplaceSkillDetail, MarketplaceSource } from '@skilly/core' | ||
| 4 | +import type { Hono } from 'hono' | ||
| 5 | +import { HttpError, isStringArray, readJsonBody, type AppContext } from './context.ts' | ||
| 6 | + | ||
| 7 | +const MAX_FILES = 200 | ||
| 8 | + | ||
| 9 | +function slugify(name: string): string { | ||
| 10 | + return name | ||
| 11 | + .toLowerCase() | ||
| 12 | + .replace(/[^a-z0-9]+/g, '-') | ||
| 13 | + .replace(/^-+|-+$/g, '') | ||
| 14 | +} | ||
| 15 | + | ||
| 16 | +function optionalArg(v: unknown, label: string): string | undefined { | ||
| 17 | + if (v === undefined || v === null || v === '') return undefined | ||
| 18 | + if (typeof v !== 'string' || v.startsWith('-') || v.includes('\0')) throw new HttpError(400, `Invalid "${label}"`) | ||
| 19 | + return v.trim() | ||
| 20 | +} | ||
| 21 | + | ||
| 22 | +async function listFiles(dir: string, rel = ''): Promise<string[]> { | ||
| 23 | + const out: string[] = [] | ||
| 24 | + const entries = await readdir(path.join(dir, rel), { withFileTypes: true }) | ||
| 25 | + entries.sort((a, b) => a.name.localeCompare(b.name)) | ||
| 26 | + for (const e of entries) { | ||
| 27 | + if (out.length >= MAX_FILES) break | ||
| 28 | + if (e.name === '.git') continue | ||
| 29 | + const childRel = rel ? `${rel}/${e.name}` : e.name | ||
| 30 | + if (e.isDirectory()) out.push(...(await listFiles(dir, childRel))) | ||
| 31 | + else if (e.isFile()) out.push(childRel) | ||
| 32 | + } | ||
| 33 | + return out.slice(0, MAX_FILES) | ||
| 34 | +} | ||
| 35 | + | ||
| 36 | +function frontmatterField(md: string, key: string): string | undefined { | ||
| 37 | + const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(md.replace(/^/, '')) | ||
| 38 | + if (!fm) return undefined | ||
| 39 | + const m = new RegExp(`^${key}:[ \\t]*(.*)$`, 'm').exec(fm[1]!) | ||
| 40 | + const v = m?.[1]?.trim().replace(/^(['"])(.*)\1$/, '$2') | ||
| 41 | + return v || undefined | ||
| 42 | +} | ||
| 43 | + | ||
| 44 | +export function registerMarketplaceRoutes(app: Hono, ctx: AppContext): void { | ||
| 45 | + async function marketplace() { | ||
| 46 | + return ctx.createMarketplace({ cacheDir: ctx.marketplaceCacheDir, userSources: await ctx.store.listMarketplaces() }) | ||
| 47 | + } | ||
| 48 | + | ||
| 49 | + async function requireSource(id: string) { | ||
| 50 | + const mkt = await marketplace() | ||
| 51 | + if (!mkt.sources().some((s) => s.id === id)) throw new HttpError(404, 'Unknown marketplace source') | ||
| 52 | + return mkt | ||
| 53 | + } | ||
| 54 | + | ||
| 55 | + app.get('/api/marketplaces', async (c) => c.json((await marketplace()).sources())) | ||
| 56 | + | ||
| 57 | + app.post('/api/marketplaces', async (c) => { | ||
| 58 | + const body = await readJsonBody(c) | ||
| 59 | + if (typeof body.name !== 'string' || !body.name.trim() || body.name.length > 100) throw new HttpError(400, '"name" is required') | ||
| 60 | + if (body.kind !== 'git' && body.kind !== 'claude-marketplace') throw new HttpError(400, '"kind" must be "git" or "claude-marketplace"') | ||
| 61 | + if (typeof body.url !== 'string' || !body.url.trim() || body.url.trim().startsWith('-') || body.url.includes('\0')) { | ||
| 62 | + throw new HttpError(400, '"url" is required') | ||
| 63 | + } | ||
| 64 | + const ref = optionalArg(body.ref, 'ref') | ||
| 65 | + const id = slugify(body.name) | ||
| 66 | + if (!id) throw new HttpError(400, 'The name must contain letters or digits') | ||
| 67 | + if ((await marketplace()).sources().some((s) => s.id === id)) throw new HttpError(409, `A source with id "${id}" already exists`) | ||
| 68 | + const source: MarketplaceSource = { id, name: body.name.trim(), kind: body.kind, url: body.url.trim() } | ||
| 69 | + if (ref) source.ref = ref | ||
| 70 | + const added = await ctx.store.mutateMarketplaces((list) => { | ||
| 71 | + if (list.some((s) => s.id === id)) return false | ||
| 72 | + list.push(source) | ||
| 73 | + return true | ||
| 74 | + }) | ||
| 75 | + if (!added) throw new HttpError(409, `A source with id "${id}" already exists`) | ||
| 76 | + const created = (await marketplace()).sources().find((s) => s.id === id) | ||
| 77 | + return c.json(created ?? source, 201) | ||
| 78 | + }) | ||
| 79 | + | ||
| 80 | + app.delete('/api/marketplaces/:id', async (c) => { | ||
| 81 | + const id = c.req.param('id') | ||
| 82 | + const source = (await marketplace()).sources().find((s) => s.id === id) | ||
| 83 | + if (!source) throw new HttpError(404, 'Unknown marketplace source') | ||
| 84 | + if (source.builtIn) throw new HttpError(400, 'Built-in sources cannot be removed') | ||
| 85 | + await ctx.store.mutateMarketplaces((list) => { | ||
| 86 | + const i = list.findIndex((s) => s.id === id) | ||
| 87 | + if (i >= 0) list.splice(i, 1) | ||
| 88 | + }) | ||
| 89 | + await rm(path.join(ctx.marketplaceCacheDir, id), { recursive: true, force: true, maxRetries: 3 }).catch(() => undefined) | ||
| 90 | + await rm(path.join(ctx.marketplaceCacheDir, `${id}.json`), { force: true }).catch(() => undefined) | ||
| 91 | + return c.body(null, 204) | ||
| 92 | + }) | ||
| 93 | + | ||
| 94 | + app.post('/api/marketplaces/:id/refresh', async (c) => { | ||
| 95 | + const mkt = await requireSource(c.req.param('id')) | ||
| 96 | + return c.json(await mkt.refresh(c.req.param('id'))) | ||
| 97 | + }) | ||
| 98 | + | ||
| 99 | + app.get('/api/marketplaces/:id/skills', async (c) => { | ||
| 100 | + const mkt = await requireSource(c.req.param('id')) | ||
| 101 | + const catalog = await ctx.catalog() | ||
| 102 | + return c.json(await mkt.listSkills(c.req.param('id'), catalog)) | ||
| 103 | + }) | ||
| 104 | + | ||
| 105 | + app.get('/api/marketplaces/:id/skill', async (c) => { | ||
| 106 | + const mkt = await requireSource(c.req.param('id')) | ||
| 107 | + const skillPath = c.req.query('path') | ||
| 108 | + if (!skillPath) throw new HttpError(400, 'Missing "path"') | ||
| 109 | + const dir = await mkt.skillDir(c.req.param('id'), skillPath) | ||
| 110 | + const content = await readFile(path.join(dir, 'SKILL.md'), 'utf8') | ||
| 111 | + const detail: MarketplaceSkillDetail = { | ||
| 112 | + path: skillPath, | ||
| 113 | + name: frontmatterField(content, 'name') ?? path.basename(dir), | ||
| 114 | + description: frontmatterField(content, 'description'), | ||
| 115 | + content, | ||
| 116 | + files: await listFiles(dir), | ||
| 117 | + } | ||
| 118 | + return c.json(detail) | ||
| 119 | + }) | ||
| 120 | + | ||
| 121 | + app.post('/api/marketplaces/:id/import', async (c) => { | ||
| 122 | + const id = c.req.param('id') | ||
| 123 | + const mkt = await requireSource(id) | ||
| 124 | + const body = await readJsonBody(c) | ||
| 125 | + if (!isStringArray(body.paths)) throw new HttpError(400, '"paths" must be a string array') | ||
| 126 | + if (body.overwrite !== undefined && typeof body.overwrite !== 'boolean') throw new HttpError(400, '"overwrite" must be a boolean') | ||
| 127 | + const catalog = await ctx.catalog() | ||
| 128 | + if (!(await catalog.exists())) await catalog.init() | ||
| 129 | + const names = new Map((await mkt.listSkills(id)).map((s) => [s.path, s.name])) | ||
| 130 | + const result: ImportResult = { imported: [], skipped: [] } | ||
| 131 | + for (const p of body.paths) { | ||
| 132 | + try { | ||
| 133 | + const dir = await mkt.skillDir(id, p) | ||
| 134 | + const name = names.get(p) ?? path.basename(dir) | ||
| 135 | + result.imported.push( | ||
| 136 | + await catalog.importSkillDirectory( | ||
| 137 | + dir, | ||
| 138 | + name, | ||
| 139 | + { kind: 'marketplace', source: id, sourcePath: p, importedAt: new Date().toISOString() }, | ||
| 140 | + { overwrite: body.overwrite }, | ||
| 141 | + ), | ||
| 142 | + ) | ||
| 143 | + } catch (e) { | ||
| 144 | + result.skipped.push({ id: p, reason: e instanceof Error ? e.message : String(e) }) | ||
| 145 | + } | ||
| 146 | + } | ||
| 147 | + if (result.imported.length > 0) ctx.catalogChanged() | ||
| 148 | + return c.json(result) | ||
| 149 | + }) | ||
| 150 | +} | ||
added
packages/server/src/scope-routes.ts +250 -0 | new file mode 100644 | ||
| @@ -0,0 +1,250 @@ | ||
| 1 | +import { | |
| 2 | + AGENTS, | |
| 3 | + CATALOG_ITEM_TYPES, | |
| 4 | + type AgentId, | |
| 5 | + type ApplyBody, | |
| 6 | + type ApplyResponse, | |
| 7 | + type ApplyResult, | |
| 8 | + type CatalogRef, | |
| 9 | + type CatalogStore, | |
| 10 | + type CatalogItemType, | |
| 11 | + type ConflictResolution, | |
| 12 | + type InventoryItem, | |
| 13 | + type RemoveFromGlobalResult, | |
| 14 | + type ScopeConfig, | |
| 15 | + type ScopeInventory, | |
| 16 | + type ScopeState, | |
| 17 | + type SyncPlan, | |
| 18 | +} from '@skilly/core' | |
| 19 | +import type { Hono } from 'hono' | |
| 20 | +import { HttpError, isStringArray, readJsonBody, type AppContext, type ScopeRef } from './context.ts' | |
| 21 | + | |
| 22 | +const RESOLUTIONS: readonly string[] = ['keep-local', 'take-catalog', 'push-to-catalog'] | |
| 23 | +const HOME_TYPES = new Set(['skill', 'rule', 'agent']) | |
| 24 | + | |
| 25 | +function typedLists(v: unknown, label: string): Partial<Record<CatalogItemType, string[]>> { | |
| 26 | + if (v === undefined) return {} | |
| 27 | + if (!v || typeof v !== 'object' || Array.isArray(v)) throw new HttpError(400, `"${label}" must be an object`) | |
| 28 | + const out: Partial<Record<CatalogItemType, string[]>> = {} | |
| 29 | + for (const [type, list] of Object.entries(v)) { | |
| 30 | + if (!(CATALOG_ITEM_TYPES as readonly string[]).includes(type)) throw new HttpError(400, `"${label}": unknown item type "${type}"`) | |
| 31 | + if (!isStringArray(list) || list.some((s) => !s.trim())) throw new HttpError(400, `"${label}.${type}" must be an array of non-empty strings`) | |
| 32 | + if (list.length > 0) out[type as CatalogItemType] = list | |
| 33 | + } | |
| 34 | + return out | |
| 35 | +} | |
| 36 | + | |
| 37 | +function stringLists<K extends string>(v: unknown, label: string, keys: readonly K[]): Record<K, string[]> { | |
| 38 | + const out = Object.fromEntries(keys.map((k) => [k, [] as string[]])) as Record<K, string[]> | |
| 39 | + if (v === undefined) return out | |
| 40 | + if (!v || typeof v !== 'object' || Array.isArray(v)) throw new HttpError(400, `"${label}" must be an object`) | |
| 41 | + const rec = v as Record<string, unknown> | |
| 42 | + for (const k of keys) { | |
| 43 | + if (rec[k] === undefined) continue | |
| 44 | + if (!isStringArray(rec[k])) throw new HttpError(400, `"${label}.${k}" must be a string array`) | |
| 45 | + out[k] = rec[k] as string[] | |
| 46 | + } | |
| 47 | + return out | |
| 48 | +} | |
| 49 | + | |
| 50 | +/** Validates and normalizes a client-provided ScopeConfig. */ | |
| 51 | +export function parseScopeConfig(body: Record<string, unknown>): ScopeConfig { | |
| 52 | + if (body.version !== undefined && body.version !== 1) throw new HttpError(400, 'Unsupported config version') | |
| 53 | + if (!isStringArray(body.targets)) throw new HttpError(400, '"targets" must be an array of agent ids') | |
| 54 | + for (const t of body.targets) { | |
| 55 | + if (!(AGENTS as readonly string[]).includes(t)) throw new HttpError(400, `Unknown agent "${t}"`) | |
| 56 | + } | |
| 57 | + if (body.presets !== undefined && !isStringArray(body.presets)) throw new HttpError(400, '"presets" must be a string array') | |
| 58 | + return { | |
| 59 | + version: 1, | |
| 60 | + targets: [...new Set(body.targets as AgentId[])], | |
| 61 | + presets: (body.presets as string[] | undefined) ?? [], | |
| 62 | + add: typedLists(body.add, 'add'), | |
| 63 | + exclude: typedLists(body.exclude, 'exclude'), | |
| 64 | + onDemand: stringLists(body.onDemand, 'onDemand', ['skills'] as const), | |
| 65 | + hideGlobal: stringLists(body.hideGlobal, 'hideGlobal', ['skills', 'mcp'] as const), | |
| 66 | + } | |
| 67 | +} | |
| 68 | + | |
| 69 | +function defaultConfig(inventory: ScopeInventory): ScopeConfig { | |
| 70 | + return { | |
| 71 | + version: 1, | |
| 72 | + targets: inventory.agentsDetected.length > 0 ? [...inventory.agentsDetected] : [...AGENTS], | |
| 73 | + presets: [], | |
| 74 | + add: {}, | |
| 75 | + exclude: {}, | |
| 76 | + onDemand: { skills: [] }, | |
| 77 | + hideGlobal: { skills: [], mcp: [] }, | |
| 78 | + } | |
| 79 | +} | |
| 80 | + | |
| 81 | +export function registerScopeRoutes(app: Hono, ctx: AppContext): void { | |
| 82 | + const { syncEngine: engine } = ctx | |
| 83 | + | |
| 84 | + async function buildState(scope: ScopeRef): Promise<ScopeState> { | |
| 85 | + const catalog = await ctx.catalog() | |
| 86 | + const catalogExists = await catalog.exists() | |
| 87 | + const inventory = await scope.scan() | |
| 88 | + const config = (await engine.readConfig(scope.target)) ?? null | |
| 89 | + const lock = (await engine.readLock(scope.target)) ?? null | |
| 90 | + let plan: SyncPlan | null = null | |
| 91 | + if (config) { | |
| 92 | + try { | |
| 93 | + plan = await engine.plan(scope.target, config, catalog) | |
| 94 | + } catch (e) { | |
| 95 | + // A missing catalog folder is not an error for the UI: there is just nothing to plan against yet. | |
| 96 | + if (catalogExists) throw e | |
| 97 | + } | |
| 98 | + } | |
| 99 | + let statuses: ScopeState['statuses'] = {} | |
| 100 | + try { | |
| 101 | + statuses = await engine.annotate(inventory, catalog, lock ?? undefined) | |
| 102 | + } catch (e) { | |
| 103 | + if (catalogExists) throw e | |
| 104 | + } | |
| 105 | + return { scope: scope.id, config, inventory, statuses, plan, lock } | |
| 106 | + } | |
| 107 | + | |
| 108 | + async function planAndApply( | |
| 109 | + scope: ScopeRef, | |
| 110 | + config: ScopeConfig, | |
| 111 | + catalog: CatalogStore, | |
| 112 | + resolutions: Record<string, ConflictResolution>, | |
| 113 | + ): Promise<ApplyResult> { | |
| 114 | + const plan = await engine.plan(scope.target, config, catalog) | |
| 115 | + try { | |
| 116 | + return await engine.apply(scope.target, config, catalog, plan, resolutions) | |
| 117 | + } finally { | |
| 118 | + ctx.invalidateScan(scope.id) | |
| 119 | + } | |
| 120 | + } | |
| 121 | + | |
| 122 | + app.get('/api/scopes/:scope/state', async (c) => c.json(await buildState(await ctx.scope(c.req.param('scope'))))) | |
| 123 | + | |
| 124 | + app.put('/api/scopes/:scope/config', async (c) => { | |
| 125 | + const scope = await ctx.scope(c.req.param('scope')) | |
| 126 | + const config = parseScopeConfig(await readJsonBody(c)) | |
| 127 | + await engine.writeConfig(scope.target, config) | |
| 128 | + return c.json(await buildState(scope)) | |
| 129 | + }) | |
| 130 | + | |
| 131 | + app.post('/api/scopes/:scope/apply', async (c) => { | |
| 132 | + const scope = await ctx.scope(c.req.param('scope')) | |
| 133 | + const body = (await readJsonBody(c)) as Partial<ApplyBody> | |
| 134 | + const resolutions: Record<string, ConflictResolution> = {} | |
| 135 | + if (body.resolutions !== undefined) { | |
| 136 | + if (!body.resolutions || typeof body.resolutions !== 'object' || Array.isArray(body.resolutions)) { | |
| 137 | + throw new HttpError(400, '"resolutions" must be an object') | |
| 138 | + } | |
| 139 | + for (const [id, r] of Object.entries(body.resolutions)) { | |
| 140 | + if (typeof r !== 'string' || !RESOLUTIONS.includes(r)) throw new HttpError(400, `Invalid resolution for "${id}"`) | |
| 141 | + resolutions[id] = r as ConflictResolution | |
| 142 | + } | |
| 143 | + } | |
| 144 | + const config = await engine.readConfig(scope.target) | |
| 145 | + if (!config) throw new HttpError(400, 'This scope has no configuration yet') | |
| 146 | + const result = await planAndApply(scope, config, await ctx.catalog(), resolutions) | |
| 147 | + if (Object.values(resolutions).includes('push-to-catalog')) ctx.catalogChanged() | |
| 148 | + const response: ApplyResponse = { result, state: await buildState(scope) } | |
| 149 | + return c.json(response) | |
| 150 | + }) | |
| 151 | + | |
| 152 | + app.post('/api/home/remove-from-global', async (c) => { | |
| 153 | + const body = await readJsonBody(c) | |
| 154 | + if (!isStringArray(body.itemIds) || body.itemIds.length === 0) throw new HttpError(400, '"itemIds" must be a non-empty string array') | |
| 155 | + if (!isStringArray(body.enableIn)) throw new HttpError(400, '"enableIn" must be a string array') | |
| 156 | + const itemIds = [...new Set(body.itemIds)] | |
| 157 | + const enableIn = [...new Set(body.enableIn)] | |
| 158 | + const home = await ctx.scope('home') | |
| 159 | + const projects: ScopeRef[] = [] | |
| 160 | + for (const id of enableIn) { | |
| 161 | + if (id === 'home') throw new HttpError(400, 'Cannot enable items in Home while removing them from Home') | |
| 162 | + projects.push(await ctx.scope(id)) | |
| 163 | + } | |
| 164 | + | |
| 165 | + const result: RemoveFromGlobalResult = { removed: [], imported: [], errors: [] } | |
| 166 | + const failed = new Map<string, string>() | |
| 167 | + const inventory = await home.scan() | |
| 168 | + const byId = new Map(inventory.items.map((i) => [i.id, i])) | |
| 169 | + const catalog = await ctx.catalog() | |
| 170 | + if (!(await catalog.exists())) await catalog.init() | |
| 171 | + const snapshot = await catalog.snapshot() | |
| 172 | + | |
| 173 | + // 1. Import into the catalog. | |
| 174 | + const refs = new Map<string, CatalogRef>() | |
| 175 | + const toRemove: InventoryItem[] = [] | |
| 176 | + for (const id of itemIds) { | |
| 177 | + const item = byId.get(id) | |
| 178 | + if (!item) { | |
| 179 | + failed.set(id, 'Item not found in Home') | |
| 180 | + continue | |
| 181 | + } | |
| 182 | + if (!HOME_TYPES.has(item.type)) { | |
| 183 | + failed.set(id, `Items of type "${item.type}" cannot be removed from Home`) | |
| 184 | + continue | |
| 185 | + } | |
| 186 | + try { | |
| 187 | + const type = item.type as CatalogItemType | |
| 188 | + const same = snapshot.items.find((i) => i.type === type && item.hash && i.hash === item.hash) | |
| 189 | + const sameName = snapshot.items.find((i) => i.type === type && i.name === item.name) | |
| 190 | + if (same) refs.set(id, { type, name: same.name }) | |
| 191 | + else if (sameName) throw new Error(`A different ${type} named "${item.name}" already exists in the catalog`) | |
| 192 | + else { | |
| 193 | + const imported = await catalog.importFromInventory(item) | |
| 194 | + refs.set(id, { type: imported.type, name: imported.name }) | |
| 195 | + snapshot.items.push(imported) | |
| 196 | + ctx.catalogChanged() | |
| 197 | + } | |
| 198 | + toRemove.push(item) | |
| 199 | + } catch (e) { | |
| 200 | + failed.set(id, e instanceof Error ? e.message : String(e)) | |
| 201 | + } | |
| 202 | + } | |
| 203 | + | |
| 204 | + for (const ref of refs.values()) { | |
| 205 | + if (!result.imported.some((r) => r.type === ref.type && r.name === ref.name)) result.imported.push(ref) | |
| 206 | + } | |
| 207 | + | |
| 208 | + // 2. Enable in the target projects, then apply them. | |
| 209 | + for (const project of projects) { | |
| 210 | + const names = toRemove.map((i) => refs.get(i.id)).filter((r): r is CatalogRef => r !== undefined) | |
| 211 | + if (names.length === 0) break | |
| 212 | + try { | |
| 213 | + const config = (await engine.readConfig(project.target)) ?? defaultConfig(await project.scan()) | |
| 214 | + for (const ref of names) { | |
| 215 | + const list = (config.add[ref.type] ??= []) | |
| 216 | + if (!list.includes(ref.name)) list.push(ref.name) | |
| 217 | + } | |
| 218 | + await engine.writeConfig(project.target, config) | |
| 219 | + const applied = await planAndApply(project, config, catalog, {}) | |
| 220 | + for (const item of toRemove) { | |
| 221 | + const ref = refs.get(item.id) | |
| 222 | + if (!ref || failed.has(item.id)) continue | |
| 223 | + const suffix = `:${ref.type}/${ref.name}` | |
| 224 | + const err = applied.errors.find((e) => e.actionId.endsWith(suffix)) | |
| 225 | + if (err) failed.set(item.id, `Could not enable in project ${project.id}: ${err.message}`) | |
| 226 | + else if (applied.skipped.some((s) => s.endsWith(suffix))) { | |
| 227 | + failed.set(item.id, `Could not enable in project ${project.id}: a conflict needs a resolution`) | |
| 228 | + } | |
| 229 | + } | |
| 230 | + } catch (e) { | |
| 231 | + const msg = `Could not enable in project ${project.id}: ${e instanceof Error ? e.message : String(e)}` | |
| 232 | + for (const item of toRemove) if (!failed.has(item.id)) failed.set(item.id, msg) | |
| 233 | + } | |
| 234 | + } | |
| 235 | + | |
| 236 | + // 3. Remove from Home only the items that went through everything. | |
| 237 | + for (const item of toRemove) { | |
| 238 | + if (failed.has(item.id)) continue | |
| 239 | + try { | |
| 240 | + await engine.removeHomeItem(home.target, item) | |
| 241 | + result.removed.push(item.id) | |
| 242 | + } catch (e) { | |
| 243 | + failed.set(item.id, e instanceof Error ? e.message : String(e)) | |
| 244 | + } | |
| 245 | + } | |
| 246 | + ctx.invalidateScan() | |
| 247 | + for (const [id, message] of failed) result.errors.push({ id, message }) | |
| 248 | + return c.json(result) | |
| 249 | + }) | |
| 250 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,250 @@ | |||
| 1 | +import { | ||
| 2 | + AGENTS, | ||
| 3 | + CATALOG_ITEM_TYPES, | ||
| 4 | + type AgentId, | ||
| 5 | + type ApplyBody, | ||
| 6 | + type ApplyResponse, | ||
| 7 | + type ApplyResult, | ||
| 8 | + type CatalogRef, | ||
| 9 | + type CatalogStore, | ||
| 10 | + type CatalogItemType, | ||
| 11 | + type ConflictResolution, | ||
| 12 | + type InventoryItem, | ||
| 13 | + type RemoveFromGlobalResult, | ||
| 14 | + type ScopeConfig, | ||
| 15 | + type ScopeInventory, | ||
| 16 | + type ScopeState, | ||
| 17 | + type SyncPlan, | ||
| 18 | +} from '@skilly/core' | ||
| 19 | +import type { Hono } from 'hono' | ||
| 20 | +import { HttpError, isStringArray, readJsonBody, type AppContext, type ScopeRef } from './context.ts' | ||
| 21 | + | ||
| 22 | +const RESOLUTIONS: readonly string[] = ['keep-local', 'take-catalog', 'push-to-catalog'] | ||
| 23 | +const HOME_TYPES = new Set(['skill', 'rule', 'agent']) | ||
| 24 | + | ||
| 25 | +function typedLists(v: unknown, label: string): Partial<Record<CatalogItemType, string[]>> { | ||
| 26 | + if (v === undefined) return {} | ||
| 27 | + if (!v || typeof v !== 'object' || Array.isArray(v)) throw new HttpError(400, `"${label}" must be an object`) | ||
| 28 | + const out: Partial<Record<CatalogItemType, string[]>> = {} | ||
| 29 | + for (const [type, list] of Object.entries(v)) { | ||
| 30 | + if (!(CATALOG_ITEM_TYPES as readonly string[]).includes(type)) throw new HttpError(400, `"${label}": unknown item type "${type}"`) | ||
| 31 | + if (!isStringArray(list) || list.some((s) => !s.trim())) throw new HttpError(400, `"${label}.${type}" must be an array of non-empty strings`) | ||
| 32 | + if (list.length > 0) out[type as CatalogItemType] = list | ||
| 33 | + } | ||
| 34 | + return out | ||
| 35 | +} | ||
| 36 | + | ||
| 37 | +function stringLists<K extends string>(v: unknown, label: string, keys: readonly K[]): Record<K, string[]> { | ||
| 38 | + const out = Object.fromEntries(keys.map((k) => [k, [] as string[]])) as Record<K, string[]> | ||
| 39 | + if (v === undefined) return out | ||
| 40 | + if (!v || typeof v !== 'object' || Array.isArray(v)) throw new HttpError(400, `"${label}" must be an object`) | ||
| 41 | + const rec = v as Record<string, unknown> | ||
| 42 | + for (const k of keys) { | ||
| 43 | + if (rec[k] === undefined) continue | ||
| 44 | + if (!isStringArray(rec[k])) throw new HttpError(400, `"${label}.${k}" must be a string array`) | ||
| 45 | + out[k] = rec[k] as string[] | ||
| 46 | + } | ||
| 47 | + return out | ||
| 48 | +} | ||
| 49 | + | ||
| 50 | +/** Validates and normalizes a client-provided ScopeConfig. */ | ||
| 51 | +export function parseScopeConfig(body: Record<string, unknown>): ScopeConfig { | ||
| 52 | + if (body.version !== undefined && body.version !== 1) throw new HttpError(400, 'Unsupported config version') | ||
| 53 | + if (!isStringArray(body.targets)) throw new HttpError(400, '"targets" must be an array of agent ids') | ||
| 54 | + for (const t of body.targets) { | ||
| 55 | + if (!(AGENTS as readonly string[]).includes(t)) throw new HttpError(400, `Unknown agent "${t}"`) | ||
| 56 | + } | ||
| 57 | + if (body.presets !== undefined && !isStringArray(body.presets)) throw new HttpError(400, '"presets" must be a string array') | ||
| 58 | + return { | ||
| 59 | + version: 1, | ||
| 60 | + targets: [...new Set(body.targets as AgentId[])], | ||
| 61 | + presets: (body.presets as string[] | undefined) ?? [], | ||
| 62 | + add: typedLists(body.add, 'add'), | ||
| 63 | + exclude: typedLists(body.exclude, 'exclude'), | ||
| 64 | + onDemand: stringLists(body.onDemand, 'onDemand', ['skills'] as const), | ||
| 65 | + hideGlobal: stringLists(body.hideGlobal, 'hideGlobal', ['skills', 'mcp'] as const), | ||
| 66 | + } | ||
| 67 | +} | ||
| 68 | + | ||
| 69 | +function defaultConfig(inventory: ScopeInventory): ScopeConfig { | ||
| 70 | + return { | ||
| 71 | + version: 1, | ||
| 72 | + targets: inventory.agentsDetected.length > 0 ? [...inventory.agentsDetected] : [...AGENTS], | ||
| 73 | + presets: [], | ||
| 74 | + add: {}, | ||
| 75 | + exclude: {}, | ||
| 76 | + onDemand: { skills: [] }, | ||
| 77 | + hideGlobal: { skills: [], mcp: [] }, | ||
| 78 | + } | ||
| 79 | +} | ||
| 80 | + | ||
| 81 | +export function registerScopeRoutes(app: Hono, ctx: AppContext): void { | ||
| 82 | + const { syncEngine: engine } = ctx | ||
| 83 | + | ||
| 84 | + async function buildState(scope: ScopeRef): Promise<ScopeState> { | ||
| 85 | + const catalog = await ctx.catalog() | ||
| 86 | + const catalogExists = await catalog.exists() | ||
| 87 | + const inventory = await scope.scan() | ||
| 88 | + const config = (await engine.readConfig(scope.target)) ?? null | ||
| 89 | + const lock = (await engine.readLock(scope.target)) ?? null | ||
| 90 | + let plan: SyncPlan | null = null | ||
| 91 | + if (config) { | ||
| 92 | + try { | ||
| 93 | + plan = await engine.plan(scope.target, config, catalog) | ||
| 94 | + } catch (e) { | ||
| 95 | + // A missing catalog folder is not an error for the UI: there is just nothing to plan against yet. | ||
| 96 | + if (catalogExists) throw e | ||
| 97 | + } | ||
| 98 | + } | ||
| 99 | + let statuses: ScopeState['statuses'] = {} | ||
| 100 | + try { | ||
| 101 | + statuses = await engine.annotate(inventory, catalog, lock ?? undefined) | ||
| 102 | + } catch (e) { | ||
| 103 | + if (catalogExists) throw e | ||
| 104 | + } | ||
| 105 | + return { scope: scope.id, config, inventory, statuses, plan, lock } | ||
| 106 | + } | ||
| 107 | + | ||
| 108 | + async function planAndApply( | ||
| 109 | + scope: ScopeRef, | ||
| 110 | + config: ScopeConfig, | ||
| 111 | + catalog: CatalogStore, | ||
| 112 | + resolutions: Record<string, ConflictResolution>, | ||
| 113 | + ): Promise<ApplyResult> { | ||
| 114 | + const plan = await engine.plan(scope.target, config, catalog) | ||
| 115 | + try { | ||
| 116 | + return await engine.apply(scope.target, config, catalog, plan, resolutions) | ||
| 117 | + } finally { | ||
| 118 | + ctx.invalidateScan(scope.id) | ||
| 119 | + } | ||
| 120 | + } | ||
| 121 | + | ||
| 122 | + app.get('/api/scopes/:scope/state', async (c) => c.json(await buildState(await ctx.scope(c.req.param('scope'))))) | ||
| 123 | + | ||
| 124 | + app.put('/api/scopes/:scope/config', async (c) => { | ||
| 125 | + const scope = await ctx.scope(c.req.param('scope')) | ||
| 126 | + const config = parseScopeConfig(await readJsonBody(c)) | ||
| 127 | + await engine.writeConfig(scope.target, config) | ||
| 128 | + return c.json(await buildState(scope)) | ||
| 129 | + }) | ||
| 130 | + | ||
| 131 | + app.post('/api/scopes/:scope/apply', async (c) => { | ||
| 132 | + const scope = await ctx.scope(c.req.param('scope')) | ||
| 133 | + const body = (await readJsonBody(c)) as Partial<ApplyBody> | ||
| 134 | + const resolutions: Record<string, ConflictResolution> = {} | ||
| 135 | + if (body.resolutions !== undefined) { | ||
| 136 | + if (!body.resolutions || typeof body.resolutions !== 'object' || Array.isArray(body.resolutions)) { | ||
| 137 | + throw new HttpError(400, '"resolutions" must be an object') | ||
| 138 | + } | ||
| 139 | + for (const [id, r] of Object.entries(body.resolutions)) { | ||
| 140 | + if (typeof r !== 'string' || !RESOLUTIONS.includes(r)) throw new HttpError(400, `Invalid resolution for "${id}"`) | ||
| 141 | + resolutions[id] = r as ConflictResolution | ||
| 142 | + } | ||
| 143 | + } | ||
| 144 | + const config = await engine.readConfig(scope.target) | ||
| 145 | + if (!config) throw new HttpError(400, 'This scope has no configuration yet') | ||
| 146 | + const result = await planAndApply(scope, config, await ctx.catalog(), resolutions) | ||
| 147 | + if (Object.values(resolutions).includes('push-to-catalog')) ctx.catalogChanged() | ||
| 148 | + const response: ApplyResponse = { result, state: await buildState(scope) } | ||
| 149 | + return c.json(response) | ||
| 150 | + }) | ||
| 151 | + | ||
| 152 | + app.post('/api/home/remove-from-global', async (c) => { | ||
| 153 | + const body = await readJsonBody(c) | ||
| 154 | + if (!isStringArray(body.itemIds) || body.itemIds.length === 0) throw new HttpError(400, '"itemIds" must be a non-empty string array') | ||
| 155 | + if (!isStringArray(body.enableIn)) throw new HttpError(400, '"enableIn" must be a string array') | ||
| 156 | + const itemIds = [...new Set(body.itemIds)] | ||
| 157 | + const enableIn = [...new Set(body.enableIn)] | ||
| 158 | + const home = await ctx.scope('home') | ||
| 159 | + const projects: ScopeRef[] = [] | ||
| 160 | + for (const id of enableIn) { | ||
| 161 | + if (id === 'home') throw new HttpError(400, 'Cannot enable items in Home while removing them from Home') | ||
| 162 | + projects.push(await ctx.scope(id)) | ||
| 163 | + } | ||
| 164 | + | ||
| 165 | + const result: RemoveFromGlobalResult = { removed: [], imported: [], errors: [] } | ||
| 166 | + const failed = new Map<string, string>() | ||
| 167 | + const inventory = await home.scan() | ||
| 168 | + const byId = new Map(inventory.items.map((i) => [i.id, i])) | ||
| 169 | + const catalog = await ctx.catalog() | ||
| 170 | + if (!(await catalog.exists())) await catalog.init() | ||
| 171 | + const snapshot = await catalog.snapshot() | ||
| 172 | + | ||
| 173 | + // 1. Import into the catalog. | ||
| 174 | + const refs = new Map<string, CatalogRef>() | ||
| 175 | + const toRemove: InventoryItem[] = [] | ||
| 176 | + for (const id of itemIds) { | ||
| 177 | + const item = byId.get(id) | ||
| 178 | + if (!item) { | ||
| 179 | + failed.set(id, 'Item not found in Home') | ||
| 180 | + continue | ||
| 181 | + } | ||
| 182 | + if (!HOME_TYPES.has(item.type)) { | ||
| 183 | + failed.set(id, `Items of type "${item.type}" cannot be removed from Home`) | ||
| 184 | + continue | ||
| 185 | + } | ||
| 186 | + try { | ||
| 187 | + const type = item.type as CatalogItemType | ||
| 188 | + const same = snapshot.items.find((i) => i.type === type && item.hash && i.hash === item.hash) | ||
| 189 | + const sameName = snapshot.items.find((i) => i.type === type && i.name === item.name) | ||
| 190 | + if (same) refs.set(id, { type, name: same.name }) | ||
| 191 | + else if (sameName) throw new Error(`A different ${type} named "${item.name}" already exists in the catalog`) | ||
| 192 | + else { | ||
| 193 | + const imported = await catalog.importFromInventory(item) | ||
| 194 | + refs.set(id, { type: imported.type, name: imported.name }) | ||
| 195 | + snapshot.items.push(imported) | ||
| 196 | + ctx.catalogChanged() | ||
| 197 | + } | ||
| 198 | + toRemove.push(item) | ||
| 199 | + } catch (e) { | ||
| 200 | + failed.set(id, e instanceof Error ? e.message : String(e)) | ||
| 201 | + } | ||
| 202 | + } | ||
| 203 | + | ||
| 204 | + for (const ref of refs.values()) { | ||
| 205 | + if (!result.imported.some((r) => r.type === ref.type && r.name === ref.name)) result.imported.push(ref) | ||
| 206 | + } | ||
| 207 | + | ||
| 208 | + // 2. Enable in the target projects, then apply them. | ||
| 209 | + for (const project of projects) { | ||
| 210 | + const names = toRemove.map((i) => refs.get(i.id)).filter((r): r is CatalogRef => r !== undefined) | ||
| 211 | + if (names.length === 0) break | ||
| 212 | + try { | ||
| 213 | + const config = (await engine.readConfig(project.target)) ?? defaultConfig(await project.scan()) | ||
| 214 | + for (const ref of names) { | ||
| 215 | + const list = (config.add[ref.type] ??= []) | ||
| 216 | + if (!list.includes(ref.name)) list.push(ref.name) | ||
| 217 | + } | ||
| 218 | + await engine.writeConfig(project.target, config) | ||
| 219 | + const applied = await planAndApply(project, config, catalog, {}) | ||
| 220 | + for (const item of toRemove) { | ||
| 221 | + const ref = refs.get(item.id) | ||
| 222 | + if (!ref || failed.has(item.id)) continue | ||
| 223 | + const suffix = `:${ref.type}/${ref.name}` | ||
| 224 | + const err = applied.errors.find((e) => e.actionId.endsWith(suffix)) | ||
| 225 | + if (err) failed.set(item.id, `Could not enable in project ${project.id}: ${err.message}`) | ||
| 226 | + else if (applied.skipped.some((s) => s.endsWith(suffix))) { | ||
| 227 | + failed.set(item.id, `Could not enable in project ${project.id}: a conflict needs a resolution`) | ||
| 228 | + } | ||
| 229 | + } | ||
| 230 | + } catch (e) { | ||
| 231 | + const msg = `Could not enable in project ${project.id}: ${e instanceof Error ? e.message : String(e)}` | ||
| 232 | + for (const item of toRemove) if (!failed.has(item.id)) failed.set(item.id, msg) | ||
| 233 | + } | ||
| 234 | + } | ||
| 235 | + | ||
| 236 | + // 3. Remove from Home only the items that went through everything. | ||
| 237 | + for (const item of toRemove) { | ||
| 238 | + if (failed.has(item.id)) continue | ||
| 239 | + try { | ||
| 240 | + await engine.removeHomeItem(home.target, item) | ||
| 241 | + result.removed.push(item.id) | ||
| 242 | + } catch (e) { | ||
| 243 | + failed.set(item.id, e instanceof Error ? e.message : String(e)) | ||
| 244 | + } | ||
| 245 | + } | ||
| 246 | + ctx.invalidateScan() | ||
| 247 | + for (const [id, message] of failed) result.errors.push({ id, message }) | ||
| 248 | + return c.json(result) | ||
| 249 | + }) | ||
| 250 | +} | ||
modified
packages/server/src/store.ts +50 -11 | @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { createHash } from 'node:crypto' |
| 2 | 2 | import { mkdir, readFile, rename, writeFile } from 'node:fs/promises' |
| 3 | 3 | import path from 'node:path' |
| 4 | -import { normalizePath } from '@skilly/core' | |
| 4 | +import { normalizePath, type MarketplaceSource, type Settings } from '@skilly/core' | |
| 5 | 5 | |
| 6 | 6 | export interface StoredProject { |
| 7 | 7 | id: string |
| @@ -13,6 +13,8 @@ export interface StoredProject { | ||
| 13 | 13 | interface ConfigFile { |
| 14 | 14 | version: 1 |
| 15 | 15 | projects: StoredProject[] |
| 16 | + settings?: Partial<Settings> | |
| 17 | + marketplaces?: MarketplaceSource[] | |
| 16 | 18 | } |
| 17 | 19 | |
| 18 | 20 | export function projectId(p: string): string { |
| @@ -27,33 +29,70 @@ export class ProjectStore { | ||
| 27 | 29 | this.file = path.join(configDir, 'config.json') |
| 28 | 30 | } |
| 29 | 31 | |
| 30 | - async list(): Promise<StoredProject[]> { | |
| 32 | + private async read(): Promise<ConfigFile> { | |
| 31 | 33 | try { |
| 32 | 34 | const text = (await readFile(this.file, 'utf8')).replace(/^/, '') |
| 33 | 35 | const data = JSON.parse(text) as Partial<ConfigFile> |
| 34 | - return Array.isArray(data.projects) ? data.projects : [] | |
| 36 | + return { | |
| 37 | + ...data, | |
| 38 | + version: 1, | |
| 39 | + projects: Array.isArray(data.projects) ? data.projects : [], | |
| 40 | + marketplaces: Array.isArray(data.marketplaces) ? data.marketplaces : [], | |
| 41 | + settings: data.settings && typeof data.settings === 'object' ? data.settings : {}, | |
| 42 | + } | |
| 35 | 43 | } catch { |
| 36 | - return [] | |
| 44 | + return { version: 1, projects: [], marketplaces: [], settings: {} } | |
| 45 | + } | |
| 46 | + } | |
| 47 | + | |
| 48 | + async list(): Promise<StoredProject[]> { | |
| 49 | + return (await this.read()).projects | |
| 50 | + } | |
| 51 | + | |
| 52 | + /** Effective settings: stored values over the defaults. */ | |
| 53 | + async getSettings(): Promise<Settings> { | |
| 54 | + const s = (await this.read()).settings ?? {} | |
| 55 | + return { | |
| 56 | + catalogPath: typeof s.catalogPath === 'string' && s.catalogPath ? s.catalogPath : path.join(this.configDir, 'catalog'), | |
| 57 | + autoSync: typeof s.autoSync === 'boolean' ? s.autoSync : false, | |
| 37 | 58 | } |
| 38 | 59 | } |
| 39 | 60 | |
| 61 | + setSettings(settings: Settings): Promise<void> { | |
| 62 | + return this.mutateConfig((cfg) => { | |
| 63 | + cfg.settings = { ...settings } | |
| 64 | + }) | |
| 65 | + } | |
| 66 | + | |
| 67 | + /** User marketplace sources (built-in ones live in core). */ | |
| 68 | + async listMarketplaces(): Promise<MarketplaceSource[]> { | |
| 69 | + return (await this.read()).marketplaces ?? [] | |
| 70 | + } | |
| 71 | + | |
| 72 | + mutateMarketplaces<T>(fn: (sources: MarketplaceSource[]) => T): Promise<T> { | |
| 73 | + return this.mutateConfig((cfg) => fn((cfg.marketplaces ??= []))) | |
| 74 | + } | |
| 75 | + | |
| 40 | 76 | /** Serializes read-modify-write cycles so concurrent requests cannot lose updates. */ |
| 41 | - private mutate<T>(fn: (projects: StoredProject[]) => T): Promise<T> { | |
| 77 | + private mutateConfig<T>(fn: (cfg: ConfigFile) => T): Promise<T> { | |
| 42 | 78 | const run = this.queue.then(async () => { |
| 43 | - const projects = await this.list() | |
| 44 | - const result = fn(projects) | |
| 45 | - await this.save(projects) | |
| 79 | + const cfg = await this.read() | |
| 80 | + const result = fn(cfg) | |
| 81 | + await this.save(cfg) | |
| 46 | 82 | return result |
| 47 | 83 | }) |
| 48 | 84 | this.queue = run.catch(() => undefined) |
| 49 | 85 | return run |
| 50 | 86 | } |
| 51 | 87 | |
| 52 | - private async save(projects: StoredProject[]): Promise<void> { | |
| 88 | + private mutate<T>(fn: (projects: StoredProject[]) => T): Promise<T> { | |
| 89 | + return this.mutateConfig((cfg) => fn(cfg.projects)) | |
| 90 | + } | |
| 91 | + | |
| 92 | + private async save(cfg: ConfigFile): Promise<void> { | |
| 53 | 93 | await mkdir(this.configDir, { recursive: true }) |
| 54 | 94 | const tmp = `${this.file}.${process.pid}.${Date.now()}.tmp` |
| 55 | - const body: ConfigFile = { version: 1, projects } | |
| 56 | - await writeFile(tmp, JSON.stringify(body, null, 2) + '\n', 'utf8') | |
| 95 | + await writeFile(tmp, JSON.stringify(cfg, null, 2) + '\n', 'utf8') | |
| 57 | 96 | await rename(tmp, this.file) |
| 58 | 97 | } |
| 59 | 98 | |
| @@ -1,7 +1,7 @@ | |||
| 1 | import { createHash } from 'node:crypto' | 1 | import { createHash } from 'node:crypto' |
| 2 | import { mkdir, readFile, rename, writeFile } from 'node:fs/promises' | 2 | import { mkdir, readFile, rename, writeFile } from 'node:fs/promises' |
| 3 | import path from 'node:path' | 3 | import path from 'node:path' |
| 4 | -import { normalizePath } from '@skilly/core' | 4 | +import { normalizePath, type MarketplaceSource, type Settings } from '@skilly/core' |
| 5 | 5 | ||
| 6 | export interface StoredProject { | 6 | export interface StoredProject { |
| 7 | id: string | 7 | id: string |
| @@ -13,6 +13,8 @@ export interface StoredProject { | |||
| 13 | interface ConfigFile { | 13 | interface ConfigFile { |
| 14 | version: 1 | 14 | version: 1 |
| 15 | projects: StoredProject[] | 15 | projects: StoredProject[] |
| 16 | + settings?: Partial<Settings> | ||
| 17 | + marketplaces?: MarketplaceSource[] | ||
| 16 | } | 18 | } |
| 17 | 19 | ||
| 18 | export function projectId(p: string): string { | 20 | export function projectId(p: string): string { |
| @@ -27,33 +29,70 @@ export class ProjectStore { | |||
| 27 | this.file = path.join(configDir, 'config.json') | 29 | this.file = path.join(configDir, 'config.json') |
| 28 | } | 30 | } |
| 29 | 31 | ||
| 30 | - async list(): Promise<StoredProject[]> { | 32 | + private async read(): Promise<ConfigFile> { |
| 31 | try { | 33 | try { |
| 32 | const text = (await readFile(this.file, 'utf8')).replace(/^/, '') | 34 | const text = (await readFile(this.file, 'utf8')).replace(/^/, '') |
| 33 | const data = JSON.parse(text) as Partial<ConfigFile> | 35 | const data = JSON.parse(text) as Partial<ConfigFile> |
| 34 | - return Array.isArray(data.projects) ? data.projects : [] | 36 | + return { |
| 37 | + ...data, | ||
| 38 | + version: 1, | ||
| 39 | + projects: Array.isArray(data.projects) ? data.projects : [], | ||
| 40 | + marketplaces: Array.isArray(data.marketplaces) ? data.marketplaces : [], | ||
| 41 | + settings: data.settings && typeof data.settings === 'object' ? data.settings : {}, | ||
| 42 | + } | ||
| 35 | } catch { | 43 | } catch { |
| 36 | - return [] | 44 | + return { version: 1, projects: [], marketplaces: [], settings: {} } |
| 45 | + } | ||
| 46 | + } | ||
| 47 | + | ||
| 48 | + async list(): Promise<StoredProject[]> { | ||
| 49 | + return (await this.read()).projects | ||
| 50 | + } | ||
| 51 | + | ||
| 52 | + /** Effective settings: stored values over the defaults. */ | ||
| 53 | + async getSettings(): Promise<Settings> { | ||
| 54 | + const s = (await this.read()).settings ?? {} | ||
| 55 | + return { | ||
| 56 | + catalogPath: typeof s.catalogPath === 'string' && s.catalogPath ? s.catalogPath : path.join(this.configDir, 'catalog'), | ||
| 57 | + autoSync: typeof s.autoSync === 'boolean' ? s.autoSync : false, | ||
| 37 | } | 58 | } |
| 38 | } | 59 | } |
| 39 | 60 | ||
| 61 | + setSettings(settings: Settings): Promise<void> { | ||
| 62 | + return this.mutateConfig((cfg) => { | ||
| 63 | + cfg.settings = { ...settings } | ||
| 64 | + }) | ||
| 65 | + } | ||
| 66 | + | ||
| 67 | + /** User marketplace sources (built-in ones live in core). */ | ||
| 68 | + async listMarketplaces(): Promise<MarketplaceSource[]> { | ||
| 69 | + return (await this.read()).marketplaces ?? [] | ||
| 70 | + } | ||
| 71 | + | ||
| 72 | + mutateMarketplaces<T>(fn: (sources: MarketplaceSource[]) => T): Promise<T> { | ||
| 73 | + return this.mutateConfig((cfg) => fn((cfg.marketplaces ??= []))) | ||
| 74 | + } | ||
| 75 | + | ||
| 40 | /** Serializes read-modify-write cycles so concurrent requests cannot lose updates. */ | 76 | /** Serializes read-modify-write cycles so concurrent requests cannot lose updates. */ |
| 41 | - private mutate<T>(fn: (projects: StoredProject[]) => T): Promise<T> { | 77 | + private mutateConfig<T>(fn: (cfg: ConfigFile) => T): Promise<T> { |
| 42 | const run = this.queue.then(async () => { | 78 | const run = this.queue.then(async () => { |
| 43 | - const projects = await this.list() | 79 | + const cfg = await this.read() |
| 44 | - const result = fn(projects) | 80 | + const result = fn(cfg) |
| 45 | - await this.save(projects) | 81 | + await this.save(cfg) |
| 46 | return result | 82 | return result |
| 47 | }) | 83 | }) |
| 48 | this.queue = run.catch(() => undefined) | 84 | this.queue = run.catch(() => undefined) |
| 49 | return run | 85 | return run |
| 50 | } | 86 | } |
| 51 | 87 | ||
| 52 | - private async save(projects: StoredProject[]): Promise<void> { | 88 | + private mutate<T>(fn: (projects: StoredProject[]) => T): Promise<T> { |
| 89 | + return this.mutateConfig((cfg) => fn(cfg.projects)) | ||
| 90 | + } | ||
| 91 | + | ||
| 92 | + private async save(cfg: ConfigFile): Promise<void> { | ||
| 53 | await mkdir(this.configDir, { recursive: true }) | 93 | await mkdir(this.configDir, { recursive: true }) |
| 54 | const tmp = `${this.file}.${process.pid}.${Date.now()}.tmp` | 94 | const tmp = `${this.file}.${process.pid}.${Date.now()}.tmp` |
| 55 | - const body: ConfigFile = { version: 1, projects } | 95 | + await writeFile(tmp, JSON.stringify(cfg, null, 2) + '\n', 'utf8') |
| 56 | - await writeFile(tmp, JSON.stringify(body, null, 2) + '\n', 'utf8') | ||
| 57 | await rename(tmp, this.file) | 96 | await rename(tmp, this.file) |
| 58 | } | 97 | } |
| 59 | 98 | ||
added
packages/server/test/catalog.test.ts +213 -0 | new file mode 100644 | ||
| @@ -0,0 +1,213 @@ | ||
| 1 | +import { execFileSync } from 'node:child_process' | |
| 2 | +import { mkdir, readFile } from 'node:fs/promises' | |
| 3 | +import path from 'node:path' | |
| 4 | +import type { CatalogItem, CatalogItemDetail, CatalogResponse, FileContent, GitStatus, ImportResult, Settings } from '@skilly/core' | |
| 5 | +import { afterEach, beforeEach, describe, expect, it } from 'vitest' | |
| 6 | +import { fakeEngine, git, makeEnv, put, skillMd, type Env } from './helpers.ts' | |
| 7 | + | |
| 8 | +let env: Env | |
| 9 | + | |
| 10 | +beforeEach(async () => { | |
| 11 | + env = await makeEnv({ syncEngine: fakeEngine() }) | |
| 12 | +}) | |
| 13 | + | |
| 14 | +afterEach(() => env.cleanup()) | |
| 15 | + | |
| 16 | +describe('settings', () => { | |
| 17 | + it('defaults, validates and persists', async () => { | |
| 18 | + const s = (await (await env.req('/api/settings')).json()) as Settings | |
| 19 | + expect(s).toEqual({ catalogPath: env.catalogDir, autoSync: false }) | |
| 20 | + expect((await env.send('PUT', '/api/settings', { catalogPath: 'relative' })).status).toBe(400) | |
| 21 | + expect((await env.send('PUT', '/api/settings', { autoSync: 'yes' })).status).toBe(400) | |
| 22 | + const other = path.join(env.tmp, 'other-catalog') | |
| 23 | + const res = await env.send('PUT', '/api/settings', { catalogPath: other, autoSync: true }) | |
| 24 | + expect(await res.json()).toEqual({ catalogPath: other, autoSync: true }) | |
| 25 | + expect(JSON.parse(await readFile(path.join(env.configDir, 'config.json'), 'utf8')).settings.catalogPath).toBe(other) | |
| 26 | + }) | |
| 27 | + | |
| 28 | + it('keeps projects when settings are written (and vice versa)', async () => { | |
| 29 | + await env.send('POST', '/api/projects', { path: env.project }) | |
| 30 | + await env.send('PUT', '/api/settings', { autoSync: true }) | |
| 31 | + expect(((await (await env.req('/api/projects')).json()) as unknown[]).length).toBe(1) | |
| 32 | + }) | |
| 33 | + | |
| 34 | + it('re-opens the catalog at the new path', async () => { | |
| 35 | + const other = path.join(env.tmp, 'other-catalog') | |
| 36 | + await env.send('PUT', '/api/settings', { catalogPath: other }) | |
| 37 | + await env.send('POST', '/api/catalog/init') | |
| 38 | + const cat = (await (await env.req('/api/catalog')).json()) as CatalogResponse | |
| 39 | + expect(cat.snapshot.root).toBe(other) | |
| 40 | + expect(cat.snapshot.exists).toBe(true) | |
| 41 | + }) | |
| 42 | +}) | |
| 43 | + | |
| 44 | +describe('catalog', () => { | |
| 45 | + it('reports a missing catalog without failing', async () => { | |
| 46 | + const res = await env.req('/api/catalog') | |
| 47 | + expect(res.status).toBe(200) | |
| 48 | + const cat = (await res.json()) as CatalogResponse | |
| 49 | + expect(cat.snapshot.exists).toBe(false) | |
| 50 | + expect(cat.git.isRepo).toBe(false) | |
| 51 | + }) | |
| 52 | + | |
| 53 | + it('init creates the layout and a git repo', async () => { | |
| 54 | + const cat = (await (await env.send('POST', '/api/catalog/init')).json()) as CatalogResponse | |
| 55 | + expect(cat.snapshot.exists).toBe(true) | |
| 56 | + expect(cat.git.isRepo).toBe(true) | |
| 57 | + // idempotent | |
| 58 | + expect((await env.send('POST', '/api/catalog/init')).status).toBe(200) | |
| 59 | + }) | |
| 60 | + | |
| 61 | + it('saves, reads, tags, renames and deletes items', async () => { | |
| 62 | + await env.send('POST', '/api/catalog/init') | |
| 63 | + const saved = await env.send('PUT', '/api/catalog/items/skill/alpha', { content: skillMd('alpha'), tags: ['a'] }) | |
| 64 | + expect(saved.status).toBe(200) | |
| 65 | + expect(((await saved.json()) as CatalogItem).tags).toEqual(['a']) | |
| 66 | + const detail = (await (await env.req('/api/catalog/items/skill/alpha')).json()) as CatalogItemDetail | |
| 67 | + expect(detail.content).toContain('Body of alpha') | |
| 68 | + expect((await env.send('PUT', '/api/catalog/items/skill/alpha/tags', { tags: ['x', 'y'] })).status).toBe(204) | |
| 69 | + const renamed = await env.send('PUT', '/api/catalog/items/skill/beta', { content: skillMd('beta'), previousName: 'alpha' }) | |
| 70 | + expect(renamed.status).toBe(200) | |
| 71 | + expect((await env.req('/api/catalog/items/skill/alpha')).status).toBe(404) | |
| 72 | + expect((await env.send('DELETE', '/api/catalog/items/skill/beta')).status).toBe(204) | |
| 73 | + }) | |
| 74 | + | |
| 75 | + it('answers 400 with the core message on invalid input, never a stack', async () => { | |
| 76 | + await env.send('POST', '/api/catalog/init') | |
| 77 | + const bad = await env.send('PUT', '/api/catalog/items/skill/Bad_Name', { content: 'x' }) | |
| 78 | + expect(bad.status).toBe(400) | |
| 79 | + const body = (await bad.json()) as { error: string } | |
| 80 | + expect(body.error).toBeTruthy() | |
| 81 | + expect(body.error).not.toContain(' at ') | |
| 82 | + expect((await env.send('PUT', '/api/catalog/items/widget/x', { content: 'x' })).status).toBe(400) | |
| 83 | + expect((await env.send('PUT', '/api/catalog/items/skill/x', { content: 5 })).status).toBe(400) | |
| 84 | + expect((await env.send('PUT', '/api/catalog/items/mcp/x', { content: 'transport: [' })).status).toBe(400) | |
| 85 | + }) | |
| 86 | + | |
| 87 | + it('saves and deletes presets', async () => { | |
| 88 | + await env.send('POST', '/api/catalog/init') | |
| 89 | + await env.send('PUT', '/api/catalog/items/skill/a', { content: skillMd('a') }) | |
| 90 | + const res = await env.send('PUT', '/api/catalog/presets/web', { description: 'Web', includes: [], items: { skill: ['a'] } }) | |
| 91 | + expect(res.status).toBe(200) | |
| 92 | + const cat = (await (await env.req('/api/catalog')).json()) as CatalogResponse | |
| 93 | + expect(cat.snapshot.presets.map((p) => p.name)).toEqual(['web']) | |
| 94 | + expect((await env.send('PUT', '/api/catalog/presets/web2', { items: { widget: ['a'] } })).status).toBe(400) | |
| 95 | + expect((await env.send('DELETE', '/api/catalog/presets/web')).status).toBe(204) | |
| 96 | + }) | |
| 97 | + | |
| 98 | + it('imports inventory items and skips instructions / unknown / duplicates', async () => { | |
| 99 | + await put(`${env.home}/.claude/skills/foo/SKILL.md`, skillMd('foo')) | |
| 100 | + await put(`${env.project}/CLAUDE.md`, '# hi') | |
| 101 | + await env.send('POST', '/api/projects', { path: env.project }) | |
| 102 | + const projects = (await (await env.req('/api/projects')).json()) as { id: string }[] | |
| 103 | + const inv = (await (await env.req('/api/home')).json()) as { items: { id: string }[] } | |
| 104 | + const projInv = (await (await env.req(`/api/projects/${projects[0]!.id}/inventory`)).json()) as { items: { id: string; type: string }[] } | |
| 105 | + const instr = projInv.items.find((i) => i.type === 'instructions')! | |
| 106 | + const first = (await ( | |
| 107 | + await env.send('POST', '/api/catalog/import', { scope: 'home', itemIds: [inv.items[0]!.id, 'missing'], tags: ['t'] }) | |
| 108 | + ).json()) as ImportResult | |
| 109 | + expect(first.imported.map((i) => i.name)).toEqual(['foo']) | |
| 110 | + expect(first.imported[0]!.tags).toEqual(['t']) | |
| 111 | + expect(first.skipped.map((s) => s.id)).toEqual(['missing']) | |
| 112 | + const again = (await (await env.send('POST', '/api/catalog/import', { scope: 'home', itemIds: [inv.items[0]!.id] })).json()) as ImportResult | |
| 113 | + expect(again.imported).toEqual([]) | |
| 114 | + expect(again.skipped[0]!.reason).toMatch(/exist/i) | |
| 115 | + const instrRes = (await ( | |
| 116 | + await env.send('POST', '/api/catalog/import', { scope: projects[0]!.id, itemIds: [instr.id] }) | |
| 117 | + ).json()) as ImportResult | |
| 118 | + expect(instrRes.skipped[0]!.id).toBe(instr.id) | |
| 119 | + expect((await env.send('POST', '/api/catalog/import', { scope: 'nope', itemIds: [] })).status).toBe(404) | |
| 120 | + }) | |
| 121 | + | |
| 122 | + it('serves files from the catalog through /api/files', async () => { | |
| 123 | + await env.send('POST', '/api/catalog/init') | |
| 124 | + await env.send('PUT', '/api/catalog/items/skill/alpha', { content: skillMd('alpha') }) | |
| 125 | + const file = path.join(env.catalogDir, 'skills', 'alpha', 'SKILL.md') | |
| 126 | + const res = await env.req(`/api/files?path=${encodeURIComponent(file)}`) | |
| 127 | + expect(res.status).toBe(200) | |
| 128 | + expect(((await res.json()) as FileContent).content).toContain('alpha') | |
| 129 | + const outside = path.join(env.tmp, 'outside.txt') | |
| 130 | + await put(outside, 'x') | |
| 131 | + expect((await env.req(`/api/files?path=${encodeURIComponent(outside)}`)).status).toBe(403) | |
| 132 | + }) | |
| 133 | +}) | |
| 134 | + | |
| 135 | +describe('catalog git', () => { | |
| 136 | + let remote: string | |
| 137 | + | |
| 138 | + beforeEach(async () => { | |
| 139 | + remote = path.join(env.tmp, 'remote.git') | |
| 140 | + await mkdir(remote, { recursive: true }) | |
| 141 | + execFileSync('git', ['init', '--bare', '-q', '-b', 'main', remote], { stdio: 'ignore' }) | |
| 142 | + await env.send('POST', '/api/catalog/init') | |
| 143 | + git(env.catalogDir, 'checkout', '-q', '-B', 'main') | |
| 144 | + }) | |
| 145 | + | |
| 146 | + it('fails with 400 before the catalog exists', async () => { | |
| 147 | + const e2 = await makeEnv({ syncEngine: fakeEngine() }) | |
| 148 | + try { | |
| 149 | + const res = await e2.send('POST', '/api/catalog/git/sync') | |
| 150 | + expect(res.status).toBe(400) | |
| 151 | + expect(((await res.json()) as { error: string }).error).toBeTruthy() | |
| 152 | + } finally { | |
| 153 | + await e2.cleanup() | |
| 154 | + } | |
| 155 | + }) | |
| 156 | + | |
| 157 | + it('sets the remote, commits, pushes and syncs', async () => { | |
| 158 | + expect((await env.send('POST', '/api/catalog/git/remote', { url: '--upload-pack=x' })).status).toBe(400) | |
| 159 | + const withRemote = (await (await env.send('POST', '/api/catalog/git/remote', { url: remote })).json()) as GitStatus | |
| 160 | + expect(withRemote.remote).toBeTruthy() | |
| 161 | + await env.send('PUT', '/api/catalog/items/skill/alpha', { content: skillMd('alpha') }) | |
| 162 | + expect(((await (await env.req('/api/catalog/git')).json()) as GitStatus).dirty.length).toBeGreaterThan(0) | |
| 163 | + expect((await env.send('POST', '/api/catalog/git/commit', { message: ' ' })).status).toBe(400) | |
| 164 | + const committed = (await (await env.send('POST', '/api/catalog/git/commit', { message: 'add alpha' })).json()) as GitStatus | |
| 165 | + expect(committed.dirty).toEqual([]) | |
| 166 | + expect(committed.lastCommit?.message).toBe('add alpha') | |
| 167 | + const pushed = (await (await env.send('POST', '/api/catalog/git/push')).json()) as GitStatus | |
| 168 | + expect(pushed.ahead).toBe(0) | |
| 169 | + expect(git(remote, 'log', '--oneline', 'main')).toContain('add alpha') | |
| 170 | + expect((await env.send('POST', '/api/catalog/git/sync')).status).toBe(200) | |
| 171 | + expect((await env.send('POST', '/api/catalog/git/pull')).status).toBe(200) | |
| 172 | + }) | |
| 173 | + | |
| 174 | + it('validates resolve input', async () => { | |
| 175 | + expect((await env.send('POST', '/api/catalog/git/resolve', { path: 'a.md', side: 'mine' })).status).toBe(400) | |
| 176 | + expect((await env.send('POST', '/api/catalog/git/resolve', { side: 'ours' })).status).toBe(400) | |
| 177 | + }) | |
| 178 | + | |
| 179 | + it('auto-syncs after writes when enabled (push with a remote, commit without)', async () => { | |
| 180 | + await env.send('PUT', '/api/settings', { autoSync: true }) | |
| 181 | + await env.send('PUT', '/api/catalog/items/skill/auto1', { content: skillMd('auto1') }) | |
| 182 | + await waitFor(() => git(env.catalogDir, 'status', '--porcelain').trim() === '') | |
| 183 | + expect(git(env.catalogDir, 'log', '--oneline')).toContain('skilly') | |
| 184 | + | |
| 185 | + await env.send('POST', '/api/catalog/git/remote', { url: remote }) | |
| 186 | + await env.send('PUT', '/api/catalog/items/skill/auto2', { content: skillMd('auto2') }) | |
| 187 | + await waitFor(() => safe(() => git(remote, 'log', '--oneline', 'main')).includes('skilly')) | |
| 188 | + expect(git(remote, 'ls-tree', '-r', '--name-only', 'main')).toContain('skills/auto2/SKILL.md') | |
| 189 | + }) | |
| 190 | + | |
| 191 | + it('does not commit when autoSync is off', async () => { | |
| 192 | + await env.send('PUT', '/api/catalog/items/skill/manual', { content: skillMd('manual') }) | |
| 193 | + await new Promise((r) => setTimeout(r, 300)) | |
| 194 | + expect(git(env.catalogDir, 'status', '--porcelain').trim()).not.toBe('') | |
| 195 | + }) | |
| 196 | +}) | |
| 197 | + | |
| 198 | +function safe(fn: () => string): string { | |
| 199 | + try { | |
| 200 | + return fn() | |
| 201 | + } catch { | |
| 202 | + return '' | |
| 203 | + } | |
| 204 | +} | |
| 205 | + | |
| 206 | +async function waitFor(cond: () => boolean, timeoutMs = 8000): Promise<void> { | |
| 207 | + const start = Date.now() | |
| 208 | + while (Date.now() - start < timeoutMs) { | |
| 209 | + if (cond()) return | |
| 210 | + await new Promise((r) => setTimeout(r, 50)) | |
| 211 | + } | |
| 212 | + throw new Error('Timed out waiting for condition') | |
| 213 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,213 @@ | |||
| 1 | +import { execFileSync } from 'node:child_process' | ||
| 2 | +import { mkdir, readFile } from 'node:fs/promises' | ||
| 3 | +import path from 'node:path' | ||
| 4 | +import type { CatalogItem, CatalogItemDetail, CatalogResponse, FileContent, GitStatus, ImportResult, Settings } from '@skilly/core' | ||
| 5 | +import { afterEach, beforeEach, describe, expect, it } from 'vitest' | ||
| 6 | +import { fakeEngine, git, makeEnv, put, skillMd, type Env } from './helpers.ts' | ||
| 7 | + | ||
| 8 | +let env: Env | ||
| 9 | + | ||
| 10 | +beforeEach(async () => { | ||
| 11 | + env = await makeEnv({ syncEngine: fakeEngine() }) | ||
| 12 | +}) | ||
| 13 | + | ||
| 14 | +afterEach(() => env.cleanup()) | ||
| 15 | + | ||
| 16 | +describe('settings', () => { | ||
| 17 | + it('defaults, validates and persists', async () => { | ||
| 18 | + const s = (await (await env.req('/api/settings')).json()) as Settings | ||
| 19 | + expect(s).toEqual({ catalogPath: env.catalogDir, autoSync: false }) | ||
| 20 | + expect((await env.send('PUT', '/api/settings', { catalogPath: 'relative' })).status).toBe(400) | ||
| 21 | + expect((await env.send('PUT', '/api/settings', { autoSync: 'yes' })).status).toBe(400) | ||
| 22 | + const other = path.join(env.tmp, 'other-catalog') | ||
| 23 | + const res = await env.send('PUT', '/api/settings', { catalogPath: other, autoSync: true }) | ||
| 24 | + expect(await res.json()).toEqual({ catalogPath: other, autoSync: true }) | ||
| 25 | + expect(JSON.parse(await readFile(path.join(env.configDir, 'config.json'), 'utf8')).settings.catalogPath).toBe(other) | ||
| 26 | + }) | ||
| 27 | + | ||
| 28 | + it('keeps projects when settings are written (and vice versa)', async () => { | ||
| 29 | + await env.send('POST', '/api/projects', { path: env.project }) | ||
| 30 | + await env.send('PUT', '/api/settings', { autoSync: true }) | ||
| 31 | + expect(((await (await env.req('/api/projects')).json()) as unknown[]).length).toBe(1) | ||
| 32 | + }) | ||
| 33 | + | ||
| 34 | + it('re-opens the catalog at the new path', async () => { | ||
| 35 | + const other = path.join(env.tmp, 'other-catalog') | ||
| 36 | + await env.send('PUT', '/api/settings', { catalogPath: other }) | ||
| 37 | + await env.send('POST', '/api/catalog/init') | ||
| 38 | + const cat = (await (await env.req('/api/catalog')).json()) as CatalogResponse | ||
| 39 | + expect(cat.snapshot.root).toBe(other) | ||
| 40 | + expect(cat.snapshot.exists).toBe(true) | ||
| 41 | + }) | ||
| 42 | +}) | ||
| 43 | + | ||
| 44 | +describe('catalog', () => { | ||
| 45 | + it('reports a missing catalog without failing', async () => { | ||
| 46 | + const res = await env.req('/api/catalog') | ||
| 47 | + expect(res.status).toBe(200) | ||
| 48 | + const cat = (await res.json()) as CatalogResponse | ||
| 49 | + expect(cat.snapshot.exists).toBe(false) | ||
| 50 | + expect(cat.git.isRepo).toBe(false) | ||
| 51 | + }) | ||
| 52 | + | ||
| 53 | + it('init creates the layout and a git repo', async () => { | ||
| 54 | + const cat = (await (await env.send('POST', '/api/catalog/init')).json()) as CatalogResponse | ||
| 55 | + expect(cat.snapshot.exists).toBe(true) | ||
| 56 | + expect(cat.git.isRepo).toBe(true) | ||
| 57 | + // idempotent | ||
| 58 | + expect((await env.send('POST', '/api/catalog/init')).status).toBe(200) | ||
| 59 | + }) | ||
| 60 | + | ||
| 61 | + it('saves, reads, tags, renames and deletes items', async () => { | ||
| 62 | + await env.send('POST', '/api/catalog/init') | ||
| 63 | + const saved = await env.send('PUT', '/api/catalog/items/skill/alpha', { content: skillMd('alpha'), tags: ['a'] }) | ||
| 64 | + expect(saved.status).toBe(200) | ||
| 65 | + expect(((await saved.json()) as CatalogItem).tags).toEqual(['a']) | ||
| 66 | + const detail = (await (await env.req('/api/catalog/items/skill/alpha')).json()) as CatalogItemDetail | ||
| 67 | + expect(detail.content).toContain('Body of alpha') | ||
| 68 | + expect((await env.send('PUT', '/api/catalog/items/skill/alpha/tags', { tags: ['x', 'y'] })).status).toBe(204) | ||
| 69 | + const renamed = await env.send('PUT', '/api/catalog/items/skill/beta', { content: skillMd('beta'), previousName: 'alpha' }) | ||
| 70 | + expect(renamed.status).toBe(200) | ||
| 71 | + expect((await env.req('/api/catalog/items/skill/alpha')).status).toBe(404) | ||
| 72 | + expect((await env.send('DELETE', '/api/catalog/items/skill/beta')).status).toBe(204) | ||
| 73 | + }) | ||
| 74 | + | ||
| 75 | + it('answers 400 with the core message on invalid input, never a stack', async () => { | ||
| 76 | + await env.send('POST', '/api/catalog/init') | ||
| 77 | + const bad = await env.send('PUT', '/api/catalog/items/skill/Bad_Name', { content: 'x' }) | ||
| 78 | + expect(bad.status).toBe(400) | ||
| 79 | + const body = (await bad.json()) as { error: string } | ||
| 80 | + expect(body.error).toBeTruthy() | ||
| 81 | + expect(body.error).not.toContain(' at ') | ||
| 82 | + expect((await env.send('PUT', '/api/catalog/items/widget/x', { content: 'x' })).status).toBe(400) | ||
| 83 | + expect((await env.send('PUT', '/api/catalog/items/skill/x', { content: 5 })).status).toBe(400) | ||
| 84 | + expect((await env.send('PUT', '/api/catalog/items/mcp/x', { content: 'transport: [' })).status).toBe(400) | ||
| 85 | + }) | ||
| 86 | + | ||
| 87 | + it('saves and deletes presets', async () => { | ||
| 88 | + await env.send('POST', '/api/catalog/init') | ||
| 89 | + await env.send('PUT', '/api/catalog/items/skill/a', { content: skillMd('a') }) | ||
| 90 | + const res = await env.send('PUT', '/api/catalog/presets/web', { description: 'Web', includes: [], items: { skill: ['a'] } }) | ||
| 91 | + expect(res.status).toBe(200) | ||
| 92 | + const cat = (await (await env.req('/api/catalog')).json()) as CatalogResponse | ||
| 93 | + expect(cat.snapshot.presets.map((p) => p.name)).toEqual(['web']) | ||
| 94 | + expect((await env.send('PUT', '/api/catalog/presets/web2', { items: { widget: ['a'] } })).status).toBe(400) | ||
| 95 | + expect((await env.send('DELETE', '/api/catalog/presets/web')).status).toBe(204) | ||
| 96 | + }) | ||
| 97 | + | ||
| 98 | + it('imports inventory items and skips instructions / unknown / duplicates', async () => { | ||
| 99 | + await put(`${env.home}/.claude/skills/foo/SKILL.md`, skillMd('foo')) | ||
| 100 | + await put(`${env.project}/CLAUDE.md`, '# hi') | ||
| 101 | + await env.send('POST', '/api/projects', { path: env.project }) | ||
| 102 | + const projects = (await (await env.req('/api/projects')).json()) as { id: string }[] | ||
| 103 | + const inv = (await (await env.req('/api/home')).json()) as { items: { id: string }[] } | ||
| 104 | + const projInv = (await (await env.req(`/api/projects/${projects[0]!.id}/inventory`)).json()) as { items: { id: string; type: string }[] } | ||
| 105 | + const instr = projInv.items.find((i) => i.type === 'instructions')! | ||
| 106 | + const first = (await ( | ||
| 107 | + await env.send('POST', '/api/catalog/import', { scope: 'home', itemIds: [inv.items[0]!.id, 'missing'], tags: ['t'] }) | ||
| 108 | + ).json()) as ImportResult | ||
| 109 | + expect(first.imported.map((i) => i.name)).toEqual(['foo']) | ||
| 110 | + expect(first.imported[0]!.tags).toEqual(['t']) | ||
| 111 | + expect(first.skipped.map((s) => s.id)).toEqual(['missing']) | ||
| 112 | + const again = (await (await env.send('POST', '/api/catalog/import', { scope: 'home', itemIds: [inv.items[0]!.id] })).json()) as ImportResult | ||
| 113 | + expect(again.imported).toEqual([]) | ||
| 114 | + expect(again.skipped[0]!.reason).toMatch(/exist/i) | ||
| 115 | + const instrRes = (await ( | ||
| 116 | + await env.send('POST', '/api/catalog/import', { scope: projects[0]!.id, itemIds: [instr.id] }) | ||
| 117 | + ).json()) as ImportResult | ||
| 118 | + expect(instrRes.skipped[0]!.id).toBe(instr.id) | ||
| 119 | + expect((await env.send('POST', '/api/catalog/import', { scope: 'nope', itemIds: [] })).status).toBe(404) | ||
| 120 | + }) | ||
| 121 | + | ||
| 122 | + it('serves files from the catalog through /api/files', async () => { | ||
| 123 | + await env.send('POST', '/api/catalog/init') | ||
| 124 | + await env.send('PUT', '/api/catalog/items/skill/alpha', { content: skillMd('alpha') }) | ||
| 125 | + const file = path.join(env.catalogDir, 'skills', 'alpha', 'SKILL.md') | ||
| 126 | + const res = await env.req(`/api/files?path=${encodeURIComponent(file)}`) | ||
| 127 | + expect(res.status).toBe(200) | ||
| 128 | + expect(((await res.json()) as FileContent).content).toContain('alpha') | ||
| 129 | + const outside = path.join(env.tmp, 'outside.txt') | ||
| 130 | + await put(outside, 'x') | ||
| 131 | + expect((await env.req(`/api/files?path=${encodeURIComponent(outside)}`)).status).toBe(403) | ||
| 132 | + }) | ||
| 133 | +}) | ||
| 134 | + | ||
| 135 | +describe('catalog git', () => { | ||
| 136 | + let remote: string | ||
| 137 | + | ||
| 138 | + beforeEach(async () => { | ||
| 139 | + remote = path.join(env.tmp, 'remote.git') | ||
| 140 | + await mkdir(remote, { recursive: true }) | ||
| 141 | + execFileSync('git', ['init', '--bare', '-q', '-b', 'main', remote], { stdio: 'ignore' }) | ||
| 142 | + await env.send('POST', '/api/catalog/init') | ||
| 143 | + git(env.catalogDir, 'checkout', '-q', '-B', 'main') | ||
| 144 | + }) | ||
| 145 | + | ||
| 146 | + it('fails with 400 before the catalog exists', async () => { | ||
| 147 | + const e2 = await makeEnv({ syncEngine: fakeEngine() }) | ||
| 148 | + try { | ||
| 149 | + const res = await e2.send('POST', '/api/catalog/git/sync') | ||
| 150 | + expect(res.status).toBe(400) | ||
| 151 | + expect(((await res.json()) as { error: string }).error).toBeTruthy() | ||
| 152 | + } finally { | ||
| 153 | + await e2.cleanup() | ||
| 154 | + } | ||
| 155 | + }) | ||
| 156 | + | ||
| 157 | + it('sets the remote, commits, pushes and syncs', async () => { | ||
| 158 | + expect((await env.send('POST', '/api/catalog/git/remote', { url: '--upload-pack=x' })).status).toBe(400) | ||
| 159 | + const withRemote = (await (await env.send('POST', '/api/catalog/git/remote', { url: remote })).json()) as GitStatus | ||
| 160 | + expect(withRemote.remote).toBeTruthy() | ||
| 161 | + await env.send('PUT', '/api/catalog/items/skill/alpha', { content: skillMd('alpha') }) | ||
| 162 | + expect(((await (await env.req('/api/catalog/git')).json()) as GitStatus).dirty.length).toBeGreaterThan(0) | ||
| 163 | + expect((await env.send('POST', '/api/catalog/git/commit', { message: ' ' })).status).toBe(400) | ||
| 164 | + const committed = (await (await env.send('POST', '/api/catalog/git/commit', { message: 'add alpha' })).json()) as GitStatus | ||
| 165 | + expect(committed.dirty).toEqual([]) | ||
| 166 | + expect(committed.lastCommit?.message).toBe('add alpha') | ||
| 167 | + const pushed = (await (await env.send('POST', '/api/catalog/git/push')).json()) as GitStatus | ||
| 168 | + expect(pushed.ahead).toBe(0) | ||
| 169 | + expect(git(remote, 'log', '--oneline', 'main')).toContain('add alpha') | ||
| 170 | + expect((await env.send('POST', '/api/catalog/git/sync')).status).toBe(200) | ||
| 171 | + expect((await env.send('POST', '/api/catalog/git/pull')).status).toBe(200) | ||
| 172 | + }) | ||
| 173 | + | ||
| 174 | + it('validates resolve input', async () => { | ||
| 175 | + expect((await env.send('POST', '/api/catalog/git/resolve', { path: 'a.md', side: 'mine' })).status).toBe(400) | ||
| 176 | + expect((await env.send('POST', '/api/catalog/git/resolve', { side: 'ours' })).status).toBe(400) | ||
| 177 | + }) | ||
| 178 | + | ||
| 179 | + it('auto-syncs after writes when enabled (push with a remote, commit without)', async () => { | ||
| 180 | + await env.send('PUT', '/api/settings', { autoSync: true }) | ||
| 181 | + await env.send('PUT', '/api/catalog/items/skill/auto1', { content: skillMd('auto1') }) | ||
| 182 | + await waitFor(() => git(env.catalogDir, 'status', '--porcelain').trim() === '') | ||
| 183 | + expect(git(env.catalogDir, 'log', '--oneline')).toContain('skilly') | ||
| 184 | + | ||
| 185 | + await env.send('POST', '/api/catalog/git/remote', { url: remote }) | ||
| 186 | + await env.send('PUT', '/api/catalog/items/skill/auto2', { content: skillMd('auto2') }) | ||
| 187 | + await waitFor(() => safe(() => git(remote, 'log', '--oneline', 'main')).includes('skilly')) | ||
| 188 | + expect(git(remote, 'ls-tree', '-r', '--name-only', 'main')).toContain('skills/auto2/SKILL.md') | ||
| 189 | + }) | ||
| 190 | + | ||
| 191 | + it('does not commit when autoSync is off', async () => { | ||
| 192 | + await env.send('PUT', '/api/catalog/items/skill/manual', { content: skillMd('manual') }) | ||
| 193 | + await new Promise((r) => setTimeout(r, 300)) | ||
| 194 | + expect(git(env.catalogDir, 'status', '--porcelain').trim()).not.toBe('') | ||
| 195 | + }) | ||
| 196 | +}) | ||
| 197 | + | ||
| 198 | +function safe(fn: () => string): string { | ||
| 199 | + try { | ||
| 200 | + return fn() | ||
| 201 | + } catch { | ||
| 202 | + return '' | ||
| 203 | + } | ||
| 204 | +} | ||
| 205 | + | ||
| 206 | +async function waitFor(cond: () => boolean, timeoutMs = 8000): Promise<void> { | ||
| 207 | + const start = Date.now() | ||
| 208 | + while (Date.now() - start < timeoutMs) { | ||
| 209 | + if (cond()) return | ||
| 210 | + await new Promise((r) => setTimeout(r, 50)) | ||
| 211 | + } | ||
| 212 | + throw new Error('Timed out waiting for condition') | ||
| 213 | +} | ||
added
packages/server/test/helpers.ts +147 -0 | new file mode 100644 | ||
| @@ -0,0 +1,147 @@ | ||
| 1 | +import { execFileSync } from 'node:child_process' | |
| 2 | +import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' | |
| 3 | +import os from 'node:os' | |
| 4 | +import path from 'node:path' | |
| 5 | +import type { ApplyResult, ConflictResolution, InventoryItem, ScopeConfig, SyncEngine, SyncPlan, SyncTarget } from '@skilly/core' | |
| 6 | +import type { Hono } from 'hono' | |
| 7 | +import { createApp, type AppOptions } from '../src/index.ts' | |
| 8 | + | |
| 9 | +export const TOKEN = 'test-token' | |
| 10 | + | |
| 11 | +export interface Env { | |
| 12 | + tmp: string | |
| 13 | + home: string | |
| 14 | + configDir: string | |
| 15 | + catalogDir: string | |
| 16 | + project: string | |
| 17 | + app: Hono | |
| 18 | + req(url: string, init?: RequestInit): Promise<Response> | |
| 19 | + send(method: string, url: string, body?: unknown): Promise<Response> | |
| 20 | + cleanup(): Promise<void> | |
| 21 | +} | |
| 22 | + | |
| 23 | +export async function put(file: string, content: string): Promise<void> { | |
| 24 | + await mkdir(path.dirname(file), { recursive: true }) | |
| 25 | + await writeFile(file, content) | |
| 26 | +} | |
| 27 | + | |
| 28 | +export const skillMd = (name: string, description = 'A test skill'): string => | |
| 29 | + `---\nname: ${name}\ndescription: ${description}\n---\nBody of ${name}\n` | |
| 30 | + | |
| 31 | +export function git(cwd: string, ...args: string[]): string { | |
| 32 | + return execFileSync('git', ['-c', 'user.name=Test', '-c', 'user.email=test@example.com', '-c', 'commit.gpgsign=false', ...args], { | |
| 33 | + cwd, | |
| 34 | + encoding: 'utf8', | |
| 35 | + stdio: ['ignore', 'pipe', 'ignore'], | |
| 36 | + }) | |
| 37 | +} | |
| 38 | + | |
| 39 | +// Commits made by the catalog git wrapper need an identity; never touch the user's global config. | |
| 40 | +process.env.GIT_AUTHOR_NAME = 'Test' | |
| 41 | +process.env.GIT_AUTHOR_EMAIL = 'test@example.com' | |
| 42 | +process.env.GIT_COMMITTER_NAME = 'Test' | |
| 43 | +process.env.GIT_COMMITTER_EMAIL = 'test@example.com' | |
| 44 | + | |
| 45 | +export async function makeEnv(extra: Partial<AppOptions> = {}): Promise<Env> { | |
| 46 | + const tmp = await realpath(await mkdtemp(path.join(os.tmpdir(), 'skilly-server-'))) | |
| 47 | + const home = path.join(tmp, 'home') | |
| 48 | + const configDir = path.join(tmp, 'config') | |
| 49 | + const project = path.join(tmp, 'work', 'my-project') | |
| 50 | + await mkdir(path.join(home, '.claude'), { recursive: true }) | |
| 51 | + await mkdir(path.join(project, '.claude'), { recursive: true }) | |
| 52 | + const app = createApp({ token: TOKEN, configDir, scanContext: { homeDir: home }, autoSyncDelayMs: 50, ...extra }) | |
| 53 | + const req = (url: string, init: RequestInit = {}) => { | |
| 54 | + const headers = new Headers(init.headers) | |
| 55 | + headers.set('x-skilly-token', TOKEN) | |
| 56 | + return Promise.resolve(app.request(url, { ...init, headers })) | |
| 57 | + } | |
| 58 | + const send = (method: string, url: string, body?: unknown) => | |
| 59 | + req(url, { | |
| 60 | + method, | |
| 61 | + body: body === undefined ? undefined : JSON.stringify(body), | |
| 62 | + headers: { 'content-type': 'application/json' }, | |
| 63 | + }) | |
| 64 | + return { | |
| 65 | + tmp, | |
| 66 | + home, | |
| 67 | + configDir, | |
| 68 | + catalogDir: path.join(configDir, 'catalog'), | |
| 69 | + project, | |
| 70 | + app, | |
| 71 | + req, | |
| 72 | + send, | |
| 73 | + cleanup: () => rm(tmp, { recursive: true, force: true, maxRetries: 3 }), | |
| 74 | + } | |
| 75 | +} | |
| 76 | + | |
| 77 | +export interface FakeEngine extends SyncEngine { | |
| 78 | + calls: { method: string; args: unknown[] }[] | |
| 79 | + configs: Map<string, ScopeConfig> | |
| 80 | + /** Make `apply` report errors for these roots. */ | |
| 81 | + failApplyFor: Set<string> | |
| 82 | + removed: string[] | |
| 83 | +} | |
| 84 | + | |
| 85 | +export function fakeEngine(): FakeEngine { | |
| 86 | + const calls: FakeEngine['calls'] = [] | |
| 87 | + const configs = new Map<string, ScopeConfig>() | |
| 88 | + const failApplyFor = new Set<string>() | |
| 89 | + const removed: string[] = [] | |
| 90 | + const key = (t: SyncTarget): string => `${t.kind}:${t.root}` | |
| 91 | + const record = (method: string, ...args: unknown[]): void => void calls.push({ method, args }) | |
| 92 | + return { | |
| 93 | + calls, | |
| 94 | + configs, | |
| 95 | + failApplyFor, | |
| 96 | + removed, | |
| 97 | + async readConfig(t) { | |
| 98 | + record('readConfig', t) | |
| 99 | + return configs.get(key(t)) | |
| 100 | + }, | |
| 101 | + async writeConfig(t, config) { | |
| 102 | + record('writeConfig', t, config) | |
| 103 | + configs.set(key(t), config) | |
| 104 | + }, | |
| 105 | + async readLock(t) { | |
| 106 | + record('readLock', t) | |
| 107 | + return undefined | |
| 108 | + }, | |
| 109 | + async plan(t, config): Promise<SyncPlan> { | |
| 110 | + record('plan', t, config) | |
| 111 | + return { | |
| 112 | + scope: t.kind, | |
| 113 | + root: t.root, | |
| 114 | + warnings: [], | |
| 115 | + missing: [], | |
| 116 | + actions: Object.entries(config.add).flatMap(([type, names]) => | |
| 117 | + (names ?? []).map((name) => ({ | |
| 118 | + id: `add:claude:${type}/${name}`, | |
| 119 | + kind: 'add' as const, | |
| 120 | + type: type as 'skill', | |
| 121 | + name, | |
| 122 | + agent: 'claude' as const, | |
| 123 | + files: [], | |
| 124 | + configKeys: [], | |
| 125 | + reason: 'test', | |
| 126 | + })), | |
| 127 | + ), | |
| 128 | + } | |
| 129 | + }, | |
| 130 | + async apply(t, config, _catalog, plan, resolutions: Record<string, ConflictResolution>): Promise<ApplyResult> { | |
| 131 | + record('apply', t, config, plan, resolutions) | |
| 132 | + const lock = { version: 1 as const, syncedAt: new Date().toISOString(), entries: [] } | |
| 133 | + if (failApplyFor.has(t.root)) { | |
| 134 | + return { applied: [], skipped: [], errors: plan.actions.map((a) => ({ actionId: a.id, message: 'boom' })), lock } | |
| 135 | + } | |
| 136 | + return { applied: plan.actions.map((a) => a.id), skipped: [], errors: [], lock } | |
| 137 | + }, | |
| 138 | + async annotate(inventory) { | |
| 139 | + record('annotate') | |
| 140 | + return Object.fromEntries(inventory.items.map((i) => [i.id, { managed: false, state: 'not-in-catalog' as const }])) | |
| 141 | + }, | |
| 142 | + async removeHomeItem(t, item: InventoryItem) { | |
| 143 | + record('removeHomeItem', t, item) | |
| 144 | + removed.push(item.id) | |
| 145 | + }, | |
| 146 | + } | |
| 147 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,147 @@ | |||
| 1 | +import { execFileSync } from 'node:child_process' | ||
| 2 | +import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' | ||
| 3 | +import os from 'node:os' | ||
| 4 | +import path from 'node:path' | ||
| 5 | +import type { ApplyResult, ConflictResolution, InventoryItem, ScopeConfig, SyncEngine, SyncPlan, SyncTarget } from '@skilly/core' | ||
| 6 | +import type { Hono } from 'hono' | ||
| 7 | +import { createApp, type AppOptions } from '../src/index.ts' | ||
| 8 | + | ||
| 9 | +export const TOKEN = 'test-token' | ||
| 10 | + | ||
| 11 | +export interface Env { | ||
| 12 | + tmp: string | ||
| 13 | + home: string | ||
| 14 | + configDir: string | ||
| 15 | + catalogDir: string | ||
| 16 | + project: string | ||
| 17 | + app: Hono | ||
| 18 | + req(url: string, init?: RequestInit): Promise<Response> | ||
| 19 | + send(method: string, url: string, body?: unknown): Promise<Response> | ||
| 20 | + cleanup(): Promise<void> | ||
| 21 | +} | ||
| 22 | + | ||
| 23 | +export async function put(file: string, content: string): Promise<void> { | ||
| 24 | + await mkdir(path.dirname(file), { recursive: true }) | ||
| 25 | + await writeFile(file, content) | ||
| 26 | +} | ||
| 27 | + | ||
| 28 | +export const skillMd = (name: string, description = 'A test skill'): string => | ||
| 29 | + `---\nname: ${name}\ndescription: ${description}\n---\nBody of ${name}\n` | ||
| 30 | + | ||
| 31 | +export function git(cwd: string, ...args: string[]): string { | ||
| 32 | + return execFileSync('git', ['-c', 'user.name=Test', '-c', 'user.email=test@example.com', '-c', 'commit.gpgsign=false', ...args], { | ||
| 33 | + cwd, | ||
| 34 | + encoding: 'utf8', | ||
| 35 | + stdio: ['ignore', 'pipe', 'ignore'], | ||
| 36 | + }) | ||
| 37 | +} | ||
| 38 | + | ||
| 39 | +// Commits made by the catalog git wrapper need an identity; never touch the user's global config. | ||
| 40 | +process.env.GIT_AUTHOR_NAME = 'Test' | ||
| 41 | +process.env.GIT_AUTHOR_EMAIL = 'test@example.com' | ||
| 42 | +process.env.GIT_COMMITTER_NAME = 'Test' | ||
| 43 | +process.env.GIT_COMMITTER_EMAIL = 'test@example.com' | ||
| 44 | + | ||
| 45 | +export async function makeEnv(extra: Partial<AppOptions> = {}): Promise<Env> { | ||
| 46 | + const tmp = await realpath(await mkdtemp(path.join(os.tmpdir(), 'skilly-server-'))) | ||
| 47 | + const home = path.join(tmp, 'home') | ||
| 48 | + const configDir = path.join(tmp, 'config') | ||
| 49 | + const project = path.join(tmp, 'work', 'my-project') | ||
| 50 | + await mkdir(path.join(home, '.claude'), { recursive: true }) | ||
| 51 | + await mkdir(path.join(project, '.claude'), { recursive: true }) | ||
| 52 | + const app = createApp({ token: TOKEN, configDir, scanContext: { homeDir: home }, autoSyncDelayMs: 50, ...extra }) | ||
| 53 | + const req = (url: string, init: RequestInit = {}) => { | ||
| 54 | + const headers = new Headers(init.headers) | ||
| 55 | + headers.set('x-skilly-token', TOKEN) | ||
| 56 | + return Promise.resolve(app.request(url, { ...init, headers })) | ||
| 57 | + } | ||
| 58 | + const send = (method: string, url: string, body?: unknown) => | ||
| 59 | + req(url, { | ||
| 60 | + method, | ||
| 61 | + body: body === undefined ? undefined : JSON.stringify(body), | ||
| 62 | + headers: { 'content-type': 'application/json' }, | ||
| 63 | + }) | ||
| 64 | + return { | ||
| 65 | + tmp, | ||
| 66 | + home, | ||
| 67 | + configDir, | ||
| 68 | + catalogDir: path.join(configDir, 'catalog'), | ||
| 69 | + project, | ||
| 70 | + app, | ||
| 71 | + req, | ||
| 72 | + send, | ||
| 73 | + cleanup: () => rm(tmp, { recursive: true, force: true, maxRetries: 3 }), | ||
| 74 | + } | ||
| 75 | +} | ||
| 76 | + | ||
| 77 | +export interface FakeEngine extends SyncEngine { | ||
| 78 | + calls: { method: string; args: unknown[] }[] | ||
| 79 | + configs: Map<string, ScopeConfig> | ||
| 80 | + /** Make `apply` report errors for these roots. */ | ||
| 81 | + failApplyFor: Set<string> | ||
| 82 | + removed: string[] | ||
| 83 | +} | ||
| 84 | + | ||
| 85 | +export function fakeEngine(): FakeEngine { | ||
| 86 | + const calls: FakeEngine['calls'] = [] | ||
| 87 | + const configs = new Map<string, ScopeConfig>() | ||
| 88 | + const failApplyFor = new Set<string>() | ||
| 89 | + const removed: string[] = [] | ||
| 90 | + const key = (t: SyncTarget): string => `${t.kind}:${t.root}` | ||
| 91 | + const record = (method: string, ...args: unknown[]): void => void calls.push({ method, args }) | ||
| 92 | + return { | ||
| 93 | + calls, | ||
| 94 | + configs, | ||
| 95 | + failApplyFor, | ||
| 96 | + removed, | ||
| 97 | + async readConfig(t) { | ||
| 98 | + record('readConfig', t) | ||
| 99 | + return configs.get(key(t)) | ||
| 100 | + }, | ||
| 101 | + async writeConfig(t, config) { | ||
| 102 | + record('writeConfig', t, config) | ||
| 103 | + configs.set(key(t), config) | ||
| 104 | + }, | ||
| 105 | + async readLock(t) { | ||
| 106 | + record('readLock', t) | ||
| 107 | + return undefined | ||
| 108 | + }, | ||
| 109 | + async plan(t, config): Promise<SyncPlan> { | ||
| 110 | + record('plan', t, config) | ||
| 111 | + return { | ||
| 112 | + scope: t.kind, | ||
| 113 | + root: t.root, | ||
| 114 | + warnings: [], | ||
| 115 | + missing: [], | ||
| 116 | + actions: Object.entries(config.add).flatMap(([type, names]) => | ||
| 117 | + (names ?? []).map((name) => ({ | ||
| 118 | + id: `add:claude:${type}/${name}`, | ||
| 119 | + kind: 'add' as const, | ||
| 120 | + type: type as 'skill', | ||
| 121 | + name, | ||
| 122 | + agent: 'claude' as const, | ||
| 123 | + files: [], | ||
| 124 | + configKeys: [], | ||
| 125 | + reason: 'test', | ||
| 126 | + })), | ||
| 127 | + ), | ||
| 128 | + } | ||
| 129 | + }, | ||
| 130 | + async apply(t, config, _catalog, plan, resolutions: Record<string, ConflictResolution>): Promise<ApplyResult> { | ||
| 131 | + record('apply', t, config, plan, resolutions) | ||
| 132 | + const lock = { version: 1 as const, syncedAt: new Date().toISOString(), entries: [] } | ||
| 133 | + if (failApplyFor.has(t.root)) { | ||
| 134 | + return { applied: [], skipped: [], errors: plan.actions.map((a) => ({ actionId: a.id, message: 'boom' })), lock } | ||
| 135 | + } | ||
| 136 | + return { applied: plan.actions.map((a) => a.id), skipped: [], errors: [], lock } | ||
| 137 | + }, | ||
| 138 | + async annotate(inventory) { | ||
| 139 | + record('annotate') | ||
| 140 | + return Object.fromEntries(inventory.items.map((i) => [i.id, { managed: false, state: 'not-in-catalog' as const }])) | ||
| 141 | + }, | ||
| 142 | + async removeHomeItem(t, item: InventoryItem) { | ||
| 143 | + record('removeHomeItem', t, item) | ||
| 144 | + removed.push(item.id) | ||
| 145 | + }, | ||
| 146 | + } | ||
| 147 | +} | ||
added
packages/server/test/integration.test.ts +45 -0 | new file mode 100644 | ||
| @@ -0,0 +1,45 @@ | ||
| 1 | +import { syncEngine, type ApplyResponse, type ProjectSummary, type ScopeConfig, type ScopeState } from '@skilly/core' | |
| 2 | +import { afterEach, beforeEach, describe, expect, it } from 'vitest' | |
| 3 | +import { makeEnv, skillMd, type Env } from './helpers.ts' | |
| 4 | + | |
| 5 | +// The sync engine may still be the placeholder that throws "not implemented". | |
| 6 | +const placeholder = | |
| 7 | + syncEngine.readConfig.name === 'notImplemented' || /not implemented/i.test(Function.prototype.toString.call(syncEngine.readConfig)) | |
| 8 | + | |
| 9 | +let env: Env | |
| 10 | + | |
| 11 | +beforeEach(async () => { | |
| 12 | + env = await makeEnv() // real sync engine | |
| 13 | +}) | |
| 14 | + | |
| 15 | +afterEach(() => env.cleanup()) | |
| 16 | + | |
| 17 | +describe('real sync engine', () => { | |
| 18 | + it.skipIf(placeholder)('PUT config -> state -> apply -> state shows managed items', async () => { | |
| 19 | + await env.send('POST', '/api/catalog/init') | |
| 20 | + await env.send('PUT', '/api/catalog/items/skill/hello', { content: skillMd('hello') }) | |
| 21 | + const projectId = ((await (await env.send('POST', '/api/projects', { path: env.project })).json()) as ProjectSummary).id | |
| 22 | + | |
| 23 | + const before = (await (await env.req(`/api/scopes/${projectId}/state`)).json()) as ScopeState | |
| 24 | + expect(before.config).toBeNull() | |
| 25 | + | |
| 26 | + const config: ScopeConfig = { | |
| 27 | + version: 1, | |
| 28 | + targets: ['claude'], | |
| 29 | + presets: [], | |
| 30 | + add: { skill: ['hello'] }, | |
| 31 | + exclude: {}, | |
| 32 | + onDemand: { skills: [] }, | |
| 33 | + hideGlobal: { skills: [], mcp: [] }, | |
| 34 | + } | |
| 35 | + const put = (await (await env.send('PUT', `/api/scopes/${projectId}/config`, config)).json()) as ScopeState | |
| 36 | + expect(put.plan?.actions.some((a) => a.kind === 'add' && a.name === 'hello')).toBe(true) | |
| 37 | + | |
| 38 | + const applied = (await (await env.send('POST', `/api/scopes/${projectId}/apply`, { resolutions: {} })).json()) as ApplyResponse | |
| 39 | + expect(applied.result.errors).toEqual([]) | |
| 40 | + const skill = applied.state.inventory.items.find((i) => i.type === 'skill' && i.name === 'hello') | |
| 41 | + expect(skill).toBeDefined() | |
| 42 | + expect(applied.state.statuses[skill!.id]?.managed).toBe(true) | |
| 43 | + expect(applied.state.lock?.entries.length).toBeGreaterThan(0) | |
| 44 | + }) | |
| 45 | +}) | |
| new file mode 100644 | |||
| @@ -0,0 +1,45 @@ | |||
| 1 | +import { syncEngine, type ApplyResponse, type ProjectSummary, type ScopeConfig, type ScopeState } from '@skilly/core' | ||
| 2 | +import { afterEach, beforeEach, describe, expect, it } from 'vitest' | ||
| 3 | +import { makeEnv, skillMd, type Env } from './helpers.ts' | ||
| 4 | + | ||
| 5 | +// The sync engine may still be the placeholder that throws "not implemented". | ||
| 6 | +const placeholder = | ||
| 7 | + syncEngine.readConfig.name === 'notImplemented' || /not implemented/i.test(Function.prototype.toString.call(syncEngine.readConfig)) | ||
| 8 | + | ||
| 9 | +let env: Env | ||
| 10 | + | ||
| 11 | +beforeEach(async () => { | ||
| 12 | + env = await makeEnv() // real sync engine | ||
| 13 | +}) | ||
| 14 | + | ||
| 15 | +afterEach(() => env.cleanup()) | ||
| 16 | + | ||
| 17 | +describe('real sync engine', () => { | ||
| 18 | + it.skipIf(placeholder)('PUT config -> state -> apply -> state shows managed items', async () => { | ||
| 19 | + await env.send('POST', '/api/catalog/init') | ||
| 20 | + await env.send('PUT', '/api/catalog/items/skill/hello', { content: skillMd('hello') }) | ||
| 21 | + const projectId = ((await (await env.send('POST', '/api/projects', { path: env.project })).json()) as ProjectSummary).id | ||
| 22 | + | ||
| 23 | + const before = (await (await env.req(`/api/scopes/${projectId}/state`)).json()) as ScopeState | ||
| 24 | + expect(before.config).toBeNull() | ||
| 25 | + | ||
| 26 | + const config: ScopeConfig = { | ||
| 27 | + version: 1, | ||
| 28 | + targets: ['claude'], | ||
| 29 | + presets: [], | ||
| 30 | + add: { skill: ['hello'] }, | ||
| 31 | + exclude: {}, | ||
| 32 | + onDemand: { skills: [] }, | ||
| 33 | + hideGlobal: { skills: [], mcp: [] }, | ||
| 34 | + } | ||
| 35 | + const put = (await (await env.send('PUT', `/api/scopes/${projectId}/config`, config)).json()) as ScopeState | ||
| 36 | + expect(put.plan?.actions.some((a) => a.kind === 'add' && a.name === 'hello')).toBe(true) | ||
| 37 | + | ||
| 38 | + const applied = (await (await env.send('POST', `/api/scopes/${projectId}/apply`, { resolutions: {} })).json()) as ApplyResponse | ||
| 39 | + expect(applied.result.errors).toEqual([]) | ||
| 40 | + const skill = applied.state.inventory.items.find((i) => i.type === 'skill' && i.name === 'hello') | ||
| 41 | + expect(skill).toBeDefined() | ||
| 42 | + expect(applied.state.statuses[skill!.id]?.managed).toBe(true) | ||
| 43 | + expect(applied.state.lock?.entries.length).toBeGreaterThan(0) | ||
| 44 | + }) | ||
| 45 | +}) | ||
added
packages/server/test/marketplace.test.ts +93 -0 | new file mode 100644 | ||
| @@ -0,0 +1,93 @@ | ||
| 1 | +import { mkdir } from 'node:fs/promises' | |
| 2 | +import path from 'node:path' | |
| 3 | +import type { ImportResult, MarketplaceSkill, MarketplaceSkillDetail, MarketplaceSource } from '@skilly/core' | |
| 4 | +import { afterEach, beforeEach, describe, expect, it } from 'vitest' | |
| 5 | +import { fakeEngine, git, makeEnv, put, skillMd, type Env } from './helpers.ts' | |
| 6 | + | |
| 7 | +let env: Env | |
| 8 | +let repo: string | |
| 9 | +let repoUrl: string | |
| 10 | + | |
| 11 | +beforeEach(async () => { | |
| 12 | + env = await makeEnv({ syncEngine: fakeEngine() }) | |
| 13 | + repo = path.join(env.tmp, 'mkt-repo') | |
| 14 | + await mkdir(repo, { recursive: true }) | |
| 15 | + git(repo, 'init', '-q') | |
| 16 | + await put(path.join(repo, 'skills', 'one', 'SKILL.md'), skillMd('one', 'First skill')) | |
| 17 | + await put(path.join(repo, 'skills', 'one', 'extra.txt'), 'extra') | |
| 18 | + await put(path.join(repo, 'skills', 'two', 'SKILL.md'), skillMd('two')) | |
| 19 | + git(repo, 'add', '-A') | |
| 20 | + git(repo, 'commit', '-q', '-m', 'init') | |
| 21 | + repoUrl = new URL(`file:///${repo.replace(/\\/g, '/')}`).href | |
| 22 | +}) | |
| 23 | + | |
| 24 | +afterEach(() => env.cleanup()) | |
| 25 | + | |
| 26 | +describe('marketplaces', () => { | |
| 27 | + it('lists built-in sources and manages user sources', async () => { | |
| 28 | + const before = (await (await env.req('/api/marketplaces')).json()) as MarketplaceSource[] | |
| 29 | + expect(before.length).toBeGreaterThan(0) | |
| 30 | + expect(before.every((s) => s.builtIn)).toBe(true) | |
| 31 | + | |
| 32 | + expect((await env.send('POST', '/api/marketplaces', { name: '', kind: 'git', url: repoUrl })).status).toBe(400) | |
| 33 | + expect((await env.send('POST', '/api/marketplaces', { name: 'x', kind: 'svn', url: repoUrl })).status).toBe(400) | |
| 34 | + expect((await env.send('POST', '/api/marketplaces', { name: 'x', kind: 'git', url: '--evil' })).status).toBe(400) | |
| 35 | + | |
| 36 | + const res = await env.send('POST', '/api/marketplaces', { name: 'My Test Repo', kind: 'git', url: repoUrl }) | |
| 37 | + expect(res.status).toBe(201) | |
| 38 | + const created = (await res.json()) as MarketplaceSource | |
| 39 | + expect(created.id).toBe('my-test-repo') | |
| 40 | + expect((await env.send('POST', '/api/marketplaces', { name: 'my test repo', kind: 'git', url: repoUrl })).status).toBe(409) | |
| 41 | + | |
| 42 | + expect((await env.send('DELETE', `/api/marketplaces/${before[0]!.id}`)).status).toBe(400) | |
| 43 | + expect((await env.send('DELETE', '/api/marketplaces/nope')).status).toBe(404) | |
| 44 | + expect((await env.send('DELETE', '/api/marketplaces/my-test-repo')).status).toBe(204) | |
| 45 | + expect(((await (await env.req('/api/marketplaces')).json()) as MarketplaceSource[]).some((s) => s.id === 'my-test-repo')).toBe(false) | |
| 46 | + }) | |
| 47 | + | |
| 48 | + it('refreshes, lists skills, previews and imports into the catalog', async () => { | |
| 49 | + await env.send('POST', '/api/marketplaces', { name: 'local', kind: 'git', url: repoUrl }) | |
| 50 | + const refreshed = (await (await env.send('POST', '/api/marketplaces/local/refresh')).json()) as MarketplaceSource | |
| 51 | + expect(refreshed.lastFetchedAt).toBeTruthy() | |
| 52 | + | |
| 53 | + const skills = (await (await env.req('/api/marketplaces/local/skills')).json()) as MarketplaceSkill[] | |
| 54 | + expect(skills.map((s) => s.name).sort()).toEqual(['one', 'two']) | |
| 55 | + const one = skills.find((s) => s.name === 'one')! | |
| 56 | + | |
| 57 | + const detail = (await (await env.req(`/api/marketplaces/local/skill?path=${encodeURIComponent(one.path)}`)).json()) as MarketplaceSkillDetail | |
| 58 | + expect(detail.name).toBe('one') | |
| 59 | + expect(detail.description).toBe('First skill') | |
| 60 | + expect(detail.content).toContain('Body of one') | |
| 61 | + expect(detail.files.sort()).toEqual(['SKILL.md', 'extra.txt']) | |
| 62 | + expect((await env.req('/api/marketplaces/local/skill?path=..%2F..')).status).toBe(400) | |
| 63 | + expect((await env.req('/api/marketplaces/local/skill')).status).toBe(400) | |
| 64 | + | |
| 65 | + const imp = (await (await env.send('POST', '/api/marketplaces/local/import', { paths: [one.path, 'skills/missing'] })).json()) as ImportResult | |
| 66 | + expect(imp.imported.map((i) => i.name)).toEqual(['one']) | |
| 67 | + expect(imp.imported[0]!.origin).toMatchObject({ kind: 'marketplace', source: 'local', sourcePath: one.path }) | |
| 68 | + expect(imp.skipped.map((s) => s.id)).toEqual(['skills/missing']) | |
| 69 | + | |
| 70 | + const again = (await (await env.send('POST', '/api/marketplaces/local/import', { paths: [one.path] })).json()) as ImportResult | |
| 71 | + expect(again.imported).toEqual([]) | |
| 72 | + expect(again.skipped).toHaveLength(1) | |
| 73 | + const overwritten = (await ( | |
| 74 | + await env.send('POST', '/api/marketplaces/local/import', { paths: [one.path], overwrite: true }) | |
| 75 | + ).json()) as ImportResult | |
| 76 | + expect(overwritten.imported).toHaveLength(1) | |
| 77 | + | |
| 78 | + const after = (await (await env.req('/api/marketplaces/local/skills')).json()) as MarketplaceSkill[] | |
| 79 | + expect(after.find((s) => s.name === 'one')!.inCatalog).toBe('same') | |
| 80 | + }) | |
| 81 | + | |
| 82 | + it('serves cached marketplace files through /api/files', async () => { | |
| 83 | + await env.send('POST', '/api/marketplaces', { name: 'local', kind: 'git', url: repoUrl }) | |
| 84 | + await env.send('POST', '/api/marketplaces/local/refresh') | |
| 85 | + const file = path.join(env.configDir, 'cache', 'marketplaces', 'local', 'skills', 'one', 'SKILL.md') | |
| 86 | + expect((await env.req(`/api/files?path=${encodeURIComponent(file)}`)).status).toBe(200) | |
| 87 | + }) | |
| 88 | + | |
| 89 | + it('answers 404 for unknown sources', async () => { | |
| 90 | + expect((await env.send('POST', '/api/marketplaces/nope/refresh')).status).toBe(404) | |
| 91 | + expect((await env.req('/api/marketplaces/nope/skills')).status).toBe(404) | |
| 92 | + }) | |
| 93 | +}) | |
| new file mode 100644 | |||
| @@ -0,0 +1,93 @@ | |||
| 1 | +import { mkdir } from 'node:fs/promises' | ||
| 2 | +import path from 'node:path' | ||
| 3 | +import type { ImportResult, MarketplaceSkill, MarketplaceSkillDetail, MarketplaceSource } from '@skilly/core' | ||
| 4 | +import { afterEach, beforeEach, describe, expect, it } from 'vitest' | ||
| 5 | +import { fakeEngine, git, makeEnv, put, skillMd, type Env } from './helpers.ts' | ||
| 6 | + | ||
| 7 | +let env: Env | ||
| 8 | +let repo: string | ||
| 9 | +let repoUrl: string | ||
| 10 | + | ||
| 11 | +beforeEach(async () => { | ||
| 12 | + env = await makeEnv({ syncEngine: fakeEngine() }) | ||
| 13 | + repo = path.join(env.tmp, 'mkt-repo') | ||
| 14 | + await mkdir(repo, { recursive: true }) | ||
| 15 | + git(repo, 'init', '-q') | ||
| 16 | + await put(path.join(repo, 'skills', 'one', 'SKILL.md'), skillMd('one', 'First skill')) | ||
| 17 | + await put(path.join(repo, 'skills', 'one', 'extra.txt'), 'extra') | ||
| 18 | + await put(path.join(repo, 'skills', 'two', 'SKILL.md'), skillMd('two')) | ||
| 19 | + git(repo, 'add', '-A') | ||
| 20 | + git(repo, 'commit', '-q', '-m', 'init') | ||
| 21 | + repoUrl = new URL(`file:///${repo.replace(/\\/g, '/')}`).href | ||
| 22 | +}) | ||
| 23 | + | ||
| 24 | +afterEach(() => env.cleanup()) | ||
| 25 | + | ||
| 26 | +describe('marketplaces', () => { | ||
| 27 | + it('lists built-in sources and manages user sources', async () => { | ||
| 28 | + const before = (await (await env.req('/api/marketplaces')).json()) as MarketplaceSource[] | ||
| 29 | + expect(before.length).toBeGreaterThan(0) | ||
| 30 | + expect(before.every((s) => s.builtIn)).toBe(true) | ||
| 31 | + | ||
| 32 | + expect((await env.send('POST', '/api/marketplaces', { name: '', kind: 'git', url: repoUrl })).status).toBe(400) | ||
| 33 | + expect((await env.send('POST', '/api/marketplaces', { name: 'x', kind: 'svn', url: repoUrl })).status).toBe(400) | ||
| 34 | + expect((await env.send('POST', '/api/marketplaces', { name: 'x', kind: 'git', url: '--evil' })).status).toBe(400) | ||
| 35 | + | ||
| 36 | + const res = await env.send('POST', '/api/marketplaces', { name: 'My Test Repo', kind: 'git', url: repoUrl }) | ||
| 37 | + expect(res.status).toBe(201) | ||
| 38 | + const created = (await res.json()) as MarketplaceSource | ||
| 39 | + expect(created.id).toBe('my-test-repo') | ||
| 40 | + expect((await env.send('POST', '/api/marketplaces', { name: 'my test repo', kind: 'git', url: repoUrl })).status).toBe(409) | ||
| 41 | + | ||
| 42 | + expect((await env.send('DELETE', `/api/marketplaces/${before[0]!.id}`)).status).toBe(400) | ||
| 43 | + expect((await env.send('DELETE', '/api/marketplaces/nope')).status).toBe(404) | ||
| 44 | + expect((await env.send('DELETE', '/api/marketplaces/my-test-repo')).status).toBe(204) | ||
| 45 | + expect(((await (await env.req('/api/marketplaces')).json()) as MarketplaceSource[]).some((s) => s.id === 'my-test-repo')).toBe(false) | ||
| 46 | + }) | ||
| 47 | + | ||
| 48 | + it('refreshes, lists skills, previews and imports into the catalog', async () => { | ||
| 49 | + await env.send('POST', '/api/marketplaces', { name: 'local', kind: 'git', url: repoUrl }) | ||
| 50 | + const refreshed = (await (await env.send('POST', '/api/marketplaces/local/refresh')).json()) as MarketplaceSource | ||
| 51 | + expect(refreshed.lastFetchedAt).toBeTruthy() | ||
| 52 | + | ||
| 53 | + const skills = (await (await env.req('/api/marketplaces/local/skills')).json()) as MarketplaceSkill[] | ||
| 54 | + expect(skills.map((s) => s.name).sort()).toEqual(['one', 'two']) | ||
| 55 | + const one = skills.find((s) => s.name === 'one')! | ||
| 56 | + | ||
| 57 | + const detail = (await (await env.req(`/api/marketplaces/local/skill?path=${encodeURIComponent(one.path)}`)).json()) as MarketplaceSkillDetail | ||
| 58 | + expect(detail.name).toBe('one') | ||
| 59 | + expect(detail.description).toBe('First skill') | ||
| 60 | + expect(detail.content).toContain('Body of one') | ||
| 61 | + expect(detail.files.sort()).toEqual(['SKILL.md', 'extra.txt']) | ||
| 62 | + expect((await env.req('/api/marketplaces/local/skill?path=..%2F..')).status).toBe(400) | ||
| 63 | + expect((await env.req('/api/marketplaces/local/skill')).status).toBe(400) | ||
| 64 | + | ||
| 65 | + const imp = (await (await env.send('POST', '/api/marketplaces/local/import', { paths: [one.path, 'skills/missing'] })).json()) as ImportResult | ||
| 66 | + expect(imp.imported.map((i) => i.name)).toEqual(['one']) | ||
| 67 | + expect(imp.imported[0]!.origin).toMatchObject({ kind: 'marketplace', source: 'local', sourcePath: one.path }) | ||
| 68 | + expect(imp.skipped.map((s) => s.id)).toEqual(['skills/missing']) | ||
| 69 | + | ||
| 70 | + const again = (await (await env.send('POST', '/api/marketplaces/local/import', { paths: [one.path] })).json()) as ImportResult | ||
| 71 | + expect(again.imported).toEqual([]) | ||
| 72 | + expect(again.skipped).toHaveLength(1) | ||
| 73 | + const overwritten = (await ( | ||
| 74 | + await env.send('POST', '/api/marketplaces/local/import', { paths: [one.path], overwrite: true }) | ||
| 75 | + ).json()) as ImportResult | ||
| 76 | + expect(overwritten.imported).toHaveLength(1) | ||
| 77 | + | ||
| 78 | + const after = (await (await env.req('/api/marketplaces/local/skills')).json()) as MarketplaceSkill[] | ||
| 79 | + expect(after.find((s) => s.name === 'one')!.inCatalog).toBe('same') | ||
| 80 | + }) | ||
| 81 | + | ||
| 82 | + it('serves cached marketplace files through /api/files', async () => { | ||
| 83 | + await env.send('POST', '/api/marketplaces', { name: 'local', kind: 'git', url: repoUrl }) | ||
| 84 | + await env.send('POST', '/api/marketplaces/local/refresh') | ||
| 85 | + const file = path.join(env.configDir, 'cache', 'marketplaces', 'local', 'skills', 'one', 'SKILL.md') | ||
| 86 | + expect((await env.req(`/api/files?path=${encodeURIComponent(file)}`)).status).toBe(200) | ||
| 87 | + }) | ||
| 88 | + | ||
| 89 | + it('answers 404 for unknown sources', async () => { | ||
| 90 | + expect((await env.send('POST', '/api/marketplaces/nope/refresh')).status).toBe(404) | ||
| 91 | + expect((await env.req('/api/marketplaces/nope/skills')).status).toBe(404) | ||
| 92 | + }) | ||
| 93 | +}) | ||
added
packages/server/test/scopes.test.ts +177 -0 | new file mode 100644 | ||
| @@ -0,0 +1,177 @@ | ||
| 1 | +import type { ApplyResponse, CatalogItem, ProjectSummary, RemoveFromGlobalResult, ScopeConfig, ScopeState } from '@skilly/core' | |
| 2 | +import { afterEach, beforeEach, describe, expect, it } from 'vitest' | |
| 3 | +import { fakeEngine, makeEnv, put, skillMd, type Env, type FakeEngine } from './helpers.ts' | |
| 4 | + | |
| 5 | +let env: Env | |
| 6 | +let engine: FakeEngine | |
| 7 | +let projectId: string | |
| 8 | + | |
| 9 | +const config = (over: Partial<ScopeConfig> = {}): ScopeConfig => ({ | |
| 10 | + version: 1, | |
| 11 | + targets: ['claude'], | |
| 12 | + presets: [], | |
| 13 | + add: { skill: ['foo'] }, | |
| 14 | + exclude: {}, | |
| 15 | + onDemand: { skills: [] }, | |
| 16 | + hideGlobal: { skills: [], mcp: [] }, | |
| 17 | + ...over, | |
| 18 | +}) | |
| 19 | + | |
| 20 | +beforeEach(async () => { | |
| 21 | + engine = fakeEngine() | |
| 22 | + env = await makeEnv({ syncEngine: engine }) | |
| 23 | + await put(`${env.home}/.claude/skills/foo/SKILL.md`, skillMd('foo')) | |
| 24 | + await put(`${env.home}/.claude/skills/bar/SKILL.md`, skillMd('bar')) | |
| 25 | + const res = await env.send('POST', '/api/projects', { path: env.project }) | |
| 26 | + projectId = ((await res.json()) as ProjectSummary).id | |
| 27 | +}) | |
| 28 | + | |
| 29 | +afterEach(() => env.cleanup()) | |
| 30 | + | |
| 31 | +describe('scope state and config', () => { | |
| 32 | + it('returns a state without config and plan, even without a catalog folder', async () => { | |
| 33 | + const res = await env.req(`/api/scopes/${projectId}/state`) | |
| 34 | + expect(res.status).toBe(200) | |
| 35 | + const state = (await res.json()) as ScopeState | |
| 36 | + expect(state.config).toBeNull() | |
| 37 | + expect(state.plan).toBeNull() | |
| 38 | + expect(state.lock).toBeNull() | |
| 39 | + expect(state.scope).toBe(projectId) | |
| 40 | + }) | |
| 41 | + | |
| 42 | + it('404s on unknown scopes', async () => { | |
| 43 | + expect((await env.req('/api/scopes/nope/state')).status).toBe(404) | |
| 44 | + }) | |
| 45 | + | |
| 46 | + it('validates and writes the config, then returns a fresh state with a plan', async () => { | |
| 47 | + const res = await env.send('PUT', `/api/scopes/${projectId}/config`, config()) | |
| 48 | + expect(res.status).toBe(200) | |
| 49 | + const state = (await res.json()) as ScopeState | |
| 50 | + expect(state.config?.add.skill).toEqual(['foo']) | |
| 51 | + expect(state.plan?.actions).toHaveLength(1) | |
| 52 | + const write = engine.calls.find((c) => c.method === 'writeConfig')! | |
| 53 | + expect(write.args[0]).toMatchObject({ kind: 'project', root: env.project, configDir: env.project, homeDir: env.home }) | |
| 54 | + }) | |
| 55 | + | |
| 56 | + it('uses the skilly config dir as target for Home', async () => { | |
| 57 | + await env.send('PUT', '/api/scopes/home/config', config()) | |
| 58 | + const write = engine.calls.find((c) => c.method === 'writeConfig')! | |
| 59 | + expect(write.args[0]).toMatchObject({ kind: 'home', root: env.home, configDir: env.configDir }) | |
| 60 | + }) | |
| 61 | + | |
| 62 | + it('rejects invalid configs', async () => { | |
| 63 | + const put_ = (body: unknown) => env.send('PUT', `/api/scopes/${projectId}/config`, body) | |
| 64 | + expect((await put_({ ...config(), targets: ['gemini'] })).status).toBe(400) | |
| 65 | + expect((await put_({ ...config(), targets: 'claude' })).status).toBe(400) | |
| 66 | + expect((await put_({ ...config(), presets: [1] })).status).toBe(400) | |
| 67 | + expect((await put_({ ...config(), add: { widget: ['a'] } })).status).toBe(400) | |
| 68 | + expect((await put_({ ...config(), add: { skill: 'a' } })).status).toBe(400) | |
| 69 | + expect((await put_({ ...config(), hideGlobal: { skills: [3] } })).status).toBe(400) | |
| 70 | + expect((await put_([])).status).toBe(400) | |
| 71 | + expect(engine.calls.some((c) => c.method === 'writeConfig')).toBe(false) | |
| 72 | + }) | |
| 73 | +}) | |
| 74 | + | |
| 75 | +describe('apply', () => { | |
| 76 | + it('requires a config', async () => { | |
| 77 | + const res = await env.send('POST', `/api/scopes/${projectId}/apply`, { resolutions: {} }) | |
| 78 | + expect(res.status).toBe(400) | |
| 79 | + }) | |
| 80 | + | |
| 81 | + it('re-plans, applies with the resolutions, and returns result + state', async () => { | |
| 82 | + await env.send('PUT', `/api/scopes/${projectId}/config`, config()) | |
| 83 | + const res = await env.send('POST', `/api/scopes/${projectId}/apply`, { | |
| 84 | + resolutions: { 'conflict:claude:skill/foo': 'take-catalog' }, | |
| 85 | + }) | |
| 86 | + expect(res.status).toBe(200) | |
| 87 | + const body = (await res.json()) as ApplyResponse | |
| 88 | + expect(body.result.applied).toEqual(['add:claude:skill/foo']) | |
| 89 | + expect(body.state.config).not.toBeNull() | |
| 90 | + const apply = engine.calls.find((c) => c.method === 'apply')! | |
| 91 | + expect(apply.args[3]).toEqual({ 'conflict:claude:skill/foo': 'take-catalog' }) | |
| 92 | + expect(engine.calls.filter((c) => c.method === 'plan').length).toBeGreaterThanOrEqual(2) | |
| 93 | + }) | |
| 94 | + | |
| 95 | + it('rejects unknown resolutions', async () => { | |
| 96 | + await env.send('PUT', `/api/scopes/${projectId}/config`, config()) | |
| 97 | + const res = await env.send('POST', `/api/scopes/${projectId}/apply`, { resolutions: { x: 'whatever' } }) | |
| 98 | + expect(res.status).toBe(400) | |
| 99 | + }) | |
| 100 | +}) | |
| 101 | + | |
| 102 | +describe('remove-from-global', () => { | |
| 103 | + const homeIds = async (): Promise<Record<string, string>> => { | |
| 104 | + const inv = (await (await env.req('/api/home')).json()) as { items: { id: string; name: string }[] } | |
| 105 | + return Object.fromEntries(inv.items.map((i) => [i.name, i.id])) | |
| 106 | + } | |
| 107 | + const run = async (body: unknown): Promise<RemoveFromGlobalResult> => | |
| 108 | + (await (await env.send('POST', '/api/home/remove-from-global', body)).json()) as RemoveFromGlobalResult | |
| 109 | + | |
| 110 | + it('imports, enables in the project, applies, then removes from Home', async () => { | |
| 111 | + const ids = await homeIds() | |
| 112 | + const body = await run({ itemIds: [ids.foo], enableIn: [projectId] }) | |
| 113 | + expect(body.errors).toEqual([]) | |
| 114 | + expect(body.removed).toEqual([ids.foo]) | |
| 115 | + expect(body.imported).toEqual([{ type: 'skill', name: 'foo' }]) | |
| 116 | + expect(engine.removed).toEqual([ids.foo]) | |
| 117 | + const cfg = engine.configs.get(`project:${env.project}`)! | |
| 118 | + expect(cfg.add.skill).toEqual(['foo']) | |
| 119 | + expect(cfg.targets.length).toBeGreaterThan(0) | |
| 120 | + expect(engine.calls.some((c) => c.method === 'apply')).toBe(true) | |
| 121 | + const catalog = (await (await env.req('/api/catalog')).json()) as { snapshot: { items: CatalogItem[] } } | |
| 122 | + expect(catalog.snapshot.items.map((i) => i.name)).toEqual(['foo']) | |
| 123 | + }) | |
| 124 | + | |
| 125 | + it('skips the import when an identical item exists, and errors when a different one does', async () => { | |
| 126 | + const ids = await homeIds() | |
| 127 | + await run({ itemIds: [ids.foo], enableIn: [] }) | |
| 128 | + engine.removed.length = 0 | |
| 129 | + let body = await run({ itemIds: [ids.foo], enableIn: [] }) | |
| 130 | + expect(body.removed).toEqual([ids.foo]) | |
| 131 | + await put(`${env.home}/.claude/skills/foo/SKILL.md`, skillMd('foo', 'changed')) | |
| 132 | + engine.removed.length = 0 | |
| 133 | + body = await run({ itemIds: [ids.foo], enableIn: [] }) | |
| 134 | + expect(body.removed).toEqual([]) | |
| 135 | + expect(body.errors[0]?.id).toBe(ids.foo) | |
| 136 | + expect(body.errors[0]?.message).toMatch(/already exists/) | |
| 137 | + expect(engine.removed).toEqual([]) | |
| 138 | + }) | |
| 139 | + | |
| 140 | + it('never removes from Home when a project apply fails', async () => { | |
| 141 | + engine.failApplyFor.add(env.project) | |
| 142 | + const ids = await homeIds() | |
| 143 | + const body = await run({ itemIds: [ids.foo, ids.bar], enableIn: [projectId] }) | |
| 144 | + expect(body.removed).toEqual([]) | |
| 145 | + expect(body.errors.map((e) => e.id).sort()).toEqual([ids.bar, ids.foo].sort()) | |
| 146 | + expect(engine.removed).toEqual([]) | |
| 147 | + expect(body.imported).toHaveLength(2) | |
| 148 | + }) | |
| 149 | + | |
| 150 | + it('reports unknown items and validates the body', async () => { | |
| 151 | + const body = await run({ itemIds: ['nope'], enableIn: [] }) | |
| 152 | + expect(body.errors).toEqual([{ id: 'nope', message: 'Item not found in Home' }]) | |
| 153 | + expect((await env.send('POST', '/api/home/remove-from-global', { itemIds: [], enableIn: [] })).status).toBe(400) | |
| 154 | + expect((await env.send('POST', '/api/home/remove-from-global', { itemIds: ['a'], enableIn: ['unknown'] })).status).toBe(404) | |
| 155 | + }) | |
| 156 | +}) | |
| 157 | + | |
| 158 | +describe('catalog usage and deletion guards', () => { | |
| 159 | + it('maps items and presets to scopes and blocks deletion with 409 unless forced', async () => { | |
| 160 | + await env.send('POST', '/api/catalog/init') | |
| 161 | + await env.send('PUT', '/api/catalog/items/skill/foo', { content: skillMd('foo') }) | |
| 162 | + await env.send('PUT', '/api/catalog/items/skill/viapreset', { content: skillMd('viapreset') }) | |
| 163 | + await env.send('PUT', '/api/catalog/presets/base', { includes: [], items: { skill: ['viapreset'] } }) | |
| 164 | + await env.send('PUT', `/api/scopes/${projectId}/config`, config({ presets: ['base'] })) | |
| 165 | + await env.send('PUT', '/api/scopes/home/config', config()) | |
| 166 | + | |
| 167 | + const cat = (await (await env.req('/api/catalog')).json()) as { usage: Record<string, string[]> } | |
| 168 | + expect(cat.usage['skill/foo']?.sort()).toEqual(['home', projectId].sort()) | |
| 169 | + expect(cat.usage['preset/base']).toEqual([projectId]) | |
| 170 | + expect(cat.usage['skill/viapreset']).toEqual([projectId]) | |
| 171 | + | |
| 172 | + expect((await env.send('DELETE', '/api/catalog/items/skill/foo')).status).toBe(409) | |
| 173 | + expect((await env.send('DELETE', '/api/catalog/presets/base')).status).toBe(409) | |
| 174 | + expect((await env.send('DELETE', '/api/catalog/items/skill/foo?force=1')).status).toBe(204) | |
| 175 | + expect((await env.send('DELETE', '/api/catalog/presets/base?force=1')).status).toBe(204) | |
| 176 | + }) | |
| 177 | +}) | |
| new file mode 100644 | |||
| @@ -0,0 +1,177 @@ | |||
| 1 | +import type { ApplyResponse, CatalogItem, ProjectSummary, RemoveFromGlobalResult, ScopeConfig, ScopeState } from '@skilly/core' | ||
| 2 | +import { afterEach, beforeEach, describe, expect, it } from 'vitest' | ||
| 3 | +import { fakeEngine, makeEnv, put, skillMd, type Env, type FakeEngine } from './helpers.ts' | ||
| 4 | + | ||
| 5 | +let env: Env | ||
| 6 | +let engine: FakeEngine | ||
| 7 | +let projectId: string | ||
| 8 | + | ||
| 9 | +const config = (over: Partial<ScopeConfig> = {}): ScopeConfig => ({ | ||
| 10 | + version: 1, | ||
| 11 | + targets: ['claude'], | ||
| 12 | + presets: [], | ||
| 13 | + add: { skill: ['foo'] }, | ||
| 14 | + exclude: {}, | ||
| 15 | + onDemand: { skills: [] }, | ||
| 16 | + hideGlobal: { skills: [], mcp: [] }, | ||
| 17 | + ...over, | ||
| 18 | +}) | ||
| 19 | + | ||
| 20 | +beforeEach(async () => { | ||
| 21 | + engine = fakeEngine() | ||
| 22 | + env = await makeEnv({ syncEngine: engine }) | ||
| 23 | + await put(`${env.home}/.claude/skills/foo/SKILL.md`, skillMd('foo')) | ||
| 24 | + await put(`${env.home}/.claude/skills/bar/SKILL.md`, skillMd('bar')) | ||
| 25 | + const res = await env.send('POST', '/api/projects', { path: env.project }) | ||
| 26 | + projectId = ((await res.json()) as ProjectSummary).id | ||
| 27 | +}) | ||
| 28 | + | ||
| 29 | +afterEach(() => env.cleanup()) | ||
| 30 | + | ||
| 31 | +describe('scope state and config', () => { | ||
| 32 | + it('returns a state without config and plan, even without a catalog folder', async () => { | ||
| 33 | + const res = await env.req(`/api/scopes/${projectId}/state`) | ||
| 34 | + expect(res.status).toBe(200) | ||
| 35 | + const state = (await res.json()) as ScopeState | ||
| 36 | + expect(state.config).toBeNull() | ||
| 37 | + expect(state.plan).toBeNull() | ||
| 38 | + expect(state.lock).toBeNull() | ||
| 39 | + expect(state.scope).toBe(projectId) | ||
| 40 | + }) | ||
| 41 | + | ||
| 42 | + it('404s on unknown scopes', async () => { | ||
| 43 | + expect((await env.req('/api/scopes/nope/state')).status).toBe(404) | ||
| 44 | + }) | ||
| 45 | + | ||
| 46 | + it('validates and writes the config, then returns a fresh state with a plan', async () => { | ||
| 47 | + const res = await env.send('PUT', `/api/scopes/${projectId}/config`, config()) | ||
| 48 | + expect(res.status).toBe(200) | ||
| 49 | + const state = (await res.json()) as ScopeState | ||
| 50 | + expect(state.config?.add.skill).toEqual(['foo']) | ||
| 51 | + expect(state.plan?.actions).toHaveLength(1) | ||
| 52 | + const write = engine.calls.find((c) => c.method === 'writeConfig')! | ||
| 53 | + expect(write.args[0]).toMatchObject({ kind: 'project', root: env.project, configDir: env.project, homeDir: env.home }) | ||
| 54 | + }) | ||
| 55 | + | ||
| 56 | + it('uses the skilly config dir as target for Home', async () => { | ||
| 57 | + await env.send('PUT', '/api/scopes/home/config', config()) | ||
| 58 | + const write = engine.calls.find((c) => c.method === 'writeConfig')! | ||
| 59 | + expect(write.args[0]).toMatchObject({ kind: 'home', root: env.home, configDir: env.configDir }) | ||
| 60 | + }) | ||
| 61 | + | ||
| 62 | + it('rejects invalid configs', async () => { | ||
| 63 | + const put_ = (body: unknown) => env.send('PUT', `/api/scopes/${projectId}/config`, body) | ||
| 64 | + expect((await put_({ ...config(), targets: ['gemini'] })).status).toBe(400) | ||
| 65 | + expect((await put_({ ...config(), targets: 'claude' })).status).toBe(400) | ||
| 66 | + expect((await put_({ ...config(), presets: [1] })).status).toBe(400) | ||
| 67 | + expect((await put_({ ...config(), add: { widget: ['a'] } })).status).toBe(400) | ||
| 68 | + expect((await put_({ ...config(), add: { skill: 'a' } })).status).toBe(400) | ||
| 69 | + expect((await put_({ ...config(), hideGlobal: { skills: [3] } })).status).toBe(400) | ||
| 70 | + expect((await put_([])).status).toBe(400) | ||
| 71 | + expect(engine.calls.some((c) => c.method === 'writeConfig')).toBe(false) | ||
| 72 | + }) | ||
| 73 | +}) | ||
| 74 | + | ||
| 75 | +describe('apply', () => { | ||
| 76 | + it('requires a config', async () => { | ||
| 77 | + const res = await env.send('POST', `/api/scopes/${projectId}/apply`, { resolutions: {} }) | ||
| 78 | + expect(res.status).toBe(400) | ||
| 79 | + }) | ||
| 80 | + | ||
| 81 | + it('re-plans, applies with the resolutions, and returns result + state', async () => { | ||
| 82 | + await env.send('PUT', `/api/scopes/${projectId}/config`, config()) | ||
| 83 | + const res = await env.send('POST', `/api/scopes/${projectId}/apply`, { | ||
| 84 | + resolutions: { 'conflict:claude:skill/foo': 'take-catalog' }, | ||
| 85 | + }) | ||
| 86 | + expect(res.status).toBe(200) | ||
| 87 | + const body = (await res.json()) as ApplyResponse | ||
| 88 | + expect(body.result.applied).toEqual(['add:claude:skill/foo']) | ||
| 89 | + expect(body.state.config).not.toBeNull() | ||
| 90 | + const apply = engine.calls.find((c) => c.method === 'apply')! | ||
| 91 | + expect(apply.args[3]).toEqual({ 'conflict:claude:skill/foo': 'take-catalog' }) | ||
| 92 | + expect(engine.calls.filter((c) => c.method === 'plan').length).toBeGreaterThanOrEqual(2) | ||
| 93 | + }) | ||
| 94 | + | ||
| 95 | + it('rejects unknown resolutions', async () => { | ||
| 96 | + await env.send('PUT', `/api/scopes/${projectId}/config`, config()) | ||
| 97 | + const res = await env.send('POST', `/api/scopes/${projectId}/apply`, { resolutions: { x: 'whatever' } }) | ||
| 98 | + expect(res.status).toBe(400) | ||
| 99 | + }) | ||
| 100 | +}) | ||
| 101 | + | ||
| 102 | +describe('remove-from-global', () => { | ||
| 103 | + const homeIds = async (): Promise<Record<string, string>> => { | ||
| 104 | + const inv = (await (await env.req('/api/home')).json()) as { items: { id: string; name: string }[] } | ||
| 105 | + return Object.fromEntries(inv.items.map((i) => [i.name, i.id])) | ||
| 106 | + } | ||
| 107 | + const run = async (body: unknown): Promise<RemoveFromGlobalResult> => | ||
| 108 | + (await (await env.send('POST', '/api/home/remove-from-global', body)).json()) as RemoveFromGlobalResult | ||
| 109 | + | ||
| 110 | + it('imports, enables in the project, applies, then removes from Home', async () => { | ||
| 111 | + const ids = await homeIds() | ||
| 112 | + const body = await run({ itemIds: [ids.foo], enableIn: [projectId] }) | ||
| 113 | + expect(body.errors).toEqual([]) | ||
| 114 | + expect(body.removed).toEqual([ids.foo]) | ||
| 115 | + expect(body.imported).toEqual([{ type: 'skill', name: 'foo' }]) | ||
| 116 | + expect(engine.removed).toEqual([ids.foo]) | ||
| 117 | + const cfg = engine.configs.get(`project:${env.project}`)! | ||
| 118 | + expect(cfg.add.skill).toEqual(['foo']) | ||
| 119 | + expect(cfg.targets.length).toBeGreaterThan(0) | ||
| 120 | + expect(engine.calls.some((c) => c.method === 'apply')).toBe(true) | ||
| 121 | + const catalog = (await (await env.req('/api/catalog')).json()) as { snapshot: { items: CatalogItem[] } } | ||
| 122 | + expect(catalog.snapshot.items.map((i) => i.name)).toEqual(['foo']) | ||
| 123 | + }) | ||
| 124 | + | ||
| 125 | + it('skips the import when an identical item exists, and errors when a different one does', async () => { | ||
| 126 | + const ids = await homeIds() | ||
| 127 | + await run({ itemIds: [ids.foo], enableIn: [] }) | ||
| 128 | + engine.removed.length = 0 | ||
| 129 | + let body = await run({ itemIds: [ids.foo], enableIn: [] }) | ||
| 130 | + expect(body.removed).toEqual([ids.foo]) | ||
| 131 | + await put(`${env.home}/.claude/skills/foo/SKILL.md`, skillMd('foo', 'changed')) | ||
| 132 | + engine.removed.length = 0 | ||
| 133 | + body = await run({ itemIds: [ids.foo], enableIn: [] }) | ||
| 134 | + expect(body.removed).toEqual([]) | ||
| 135 | + expect(body.errors[0]?.id).toBe(ids.foo) | ||
| 136 | + expect(body.errors[0]?.message).toMatch(/already exists/) | ||
| 137 | + expect(engine.removed).toEqual([]) | ||
| 138 | + }) | ||
| 139 | + | ||
| 140 | + it('never removes from Home when a project apply fails', async () => { | ||
| 141 | + engine.failApplyFor.add(env.project) | ||
| 142 | + const ids = await homeIds() | ||
| 143 | + const body = await run({ itemIds: [ids.foo, ids.bar], enableIn: [projectId] }) | ||
| 144 | + expect(body.removed).toEqual([]) | ||
| 145 | + expect(body.errors.map((e) => e.id).sort()).toEqual([ids.bar, ids.foo].sort()) | ||
| 146 | + expect(engine.removed).toEqual([]) | ||
| 147 | + expect(body.imported).toHaveLength(2) | ||
| 148 | + }) | ||
| 149 | + | ||
| 150 | + it('reports unknown items and validates the body', async () => { | ||
| 151 | + const body = await run({ itemIds: ['nope'], enableIn: [] }) | ||
| 152 | + expect(body.errors).toEqual([{ id: 'nope', message: 'Item not found in Home' }]) | ||
| 153 | + expect((await env.send('POST', '/api/home/remove-from-global', { itemIds: [], enableIn: [] })).status).toBe(400) | ||
| 154 | + expect((await env.send('POST', '/api/home/remove-from-global', { itemIds: ['a'], enableIn: ['unknown'] })).status).toBe(404) | ||
| 155 | + }) | ||
| 156 | +}) | ||
| 157 | + | ||
| 158 | +describe('catalog usage and deletion guards', () => { | ||
| 159 | + it('maps items and presets to scopes and blocks deletion with 409 unless forced', async () => { | ||
| 160 | + await env.send('POST', '/api/catalog/init') | ||
| 161 | + await env.send('PUT', '/api/catalog/items/skill/foo', { content: skillMd('foo') }) | ||
| 162 | + await env.send('PUT', '/api/catalog/items/skill/viapreset', { content: skillMd('viapreset') }) | ||
| 163 | + await env.send('PUT', '/api/catalog/presets/base', { includes: [], items: { skill: ['viapreset'] } }) | ||
| 164 | + await env.send('PUT', `/api/scopes/${projectId}/config`, config({ presets: ['base'] })) | ||
| 165 | + await env.send('PUT', '/api/scopes/home/config', config()) | ||
| 166 | + | ||
| 167 | + const cat = (await (await env.req('/api/catalog')).json()) as { usage: Record<string, string[]> } | ||
| 168 | + expect(cat.usage['skill/foo']?.sort()).toEqual(['home', projectId].sort()) | ||
| 169 | + expect(cat.usage['preset/base']).toEqual([projectId]) | ||
| 170 | + expect(cat.usage['skill/viapreset']).toEqual([projectId]) | ||
| 171 | + | ||
| 172 | + expect((await env.send('DELETE', '/api/catalog/items/skill/foo')).status).toBe(409) | ||
| 173 | + expect((await env.send('DELETE', '/api/catalog/presets/base')).status).toBe(409) | ||
| 174 | + expect((await env.send('DELETE', '/api/catalog/items/skill/foo?force=1')).status).toBe(204) | ||
| 175 | + expect((await env.send('DELETE', '/api/catalog/presets/base?force=1')).status).toBe(204) | ||
| 176 | + }) | ||
| 177 | +}) | ||