298 lines
12 KiB
JavaScript
298 lines
12 KiB
JavaScript
import { createHash, randomUUID } from 'node:crypto'
|
|
import { spawn } from 'node:child_process'
|
|
import { cp, lstat, mkdir, readFile, readdir, readlink, rename, rm, symlink, writeFile } from 'node:fs/promises'
|
|
import { dirname, join, resolve } from 'node:path'
|
|
import { canonicalJson } from '../../../platform/lib/canonical-json.mjs'
|
|
import { parseEnvironmentManifest } from '../../../platform/lib/contracts.mjs'
|
|
import { hashTree } from '../../../platform/lib/tree-hash.mjs'
|
|
|
|
const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
|
const STATE_SCHEMA = 1
|
|
export const SYSTEM_SKILL_CATALOG_ARTIFACT_ID = 'system-skill-catalog'
|
|
export const SYSTEM_SKILL_CATALOG_MEDIA_TYPE = 'application/vnd.dsh-platform.system-skill-catalog.v1+tar+gzip'
|
|
|
|
function runTar(args, { capture = false } = {}) {
|
|
return new Promise((resolveTar, reject) => {
|
|
const child = spawn('tar', args, { stdio: ['ignore', capture ? 'pipe' : 'ignore', 'pipe'] })
|
|
const stdout = []
|
|
const stderr = []
|
|
child.stdout?.on('data', chunk => stdout.push(chunk))
|
|
child.stderr.on('data', chunk => stderr.push(chunk))
|
|
child.once('error', reject)
|
|
child.once('exit', code => code === 0
|
|
? resolveTar(Buffer.concat(stdout).toString('utf8'))
|
|
: reject(new Error(`System Skill archive failed: ${Buffer.concat(stderr).toString('utf8')}`)))
|
|
})
|
|
}
|
|
|
|
async function exists(path) {
|
|
return lstat(path).then(() => true, error => error?.code === 'ENOENT' ? false : Promise.reject(error))
|
|
}
|
|
|
|
export async function retainLegacySystemSkillCatalog({ sourceRoot, outputRoot }) {
|
|
const source = resolve(sourceRoot)
|
|
const destination = join(resolve(outputRoot), 'legacy-bootstrap')
|
|
if (await exists(join(destination, 'catalog.json'))) {
|
|
await readSystemSkillCatalog(destination)
|
|
return destination
|
|
}
|
|
await readSystemSkillCatalog(source)
|
|
const staging = `${destination}.${randomUUID()}.tmp`
|
|
try {
|
|
await cp(source, staging, { recursive: true, verbatimSymlinks: true })
|
|
try {
|
|
await rename(staging, destination)
|
|
} catch (error) {
|
|
if (error?.code !== 'EEXIST' && error?.code !== 'ENOTEMPTY') throw error
|
|
}
|
|
} finally {
|
|
await rm(staging, { recursive: true, force: true })
|
|
}
|
|
await readSystemSkillCatalog(destination)
|
|
return destination
|
|
}
|
|
|
|
export async function materializeSystemSkillCatalog({ environmentRoot, outputRoot }) {
|
|
const root = resolve(environmentRoot)
|
|
const manifest = parseEnvironmentManifest(await readFile(join(root, 'environment.manifest.json')))
|
|
const artifact = manifest.artifacts.find(entry => entry.id === SYSTEM_SKILL_CATALOG_ARTIFACT_ID)
|
|
if (artifact === undefined) return undefined
|
|
if (artifact.mediaType !== SYSTEM_SKILL_CATALOG_MEDIA_TYPE) {
|
|
throw new Error('System Skill catalog Artifact media type is invalid')
|
|
}
|
|
const archive = join(root, 'artifacts', artifact.id)
|
|
const bytes = await readFile(archive)
|
|
if (bytes.byteLength !== artifact.size || createHash('sha256').update(bytes).digest('hex') !== artifact.sha256) {
|
|
throw new Error('System Skill catalog Artifact differs from its Environment manifest')
|
|
}
|
|
const destination = join(resolve(outputRoot), artifact.sha256)
|
|
if (!await exists(destination)) {
|
|
const staging = `${destination}.${randomUUID()}.tmp`
|
|
await mkdir(staging, { recursive: true })
|
|
try {
|
|
const entries = (await runTar(['-tzf', archive], { capture: true })).split('\n').filter(Boolean)
|
|
if (entries.length === 0 || entries.some(entry => (
|
|
entry.startsWith('/') || !entry.startsWith('skills/') || entry.split('/').includes('..')
|
|
))) throw new Error('System Skill catalog archive contains an unsafe path')
|
|
await runTar(['-xzf', archive, '--no-same-owner', '--no-same-permissions', '--strip-components=1', '-C', staging])
|
|
await readSystemSkillCatalog(staging)
|
|
try {
|
|
await rename(staging, destination)
|
|
} catch (error) {
|
|
if (error?.code !== 'EEXIST' && error?.code !== 'ENOTEMPTY') throw error
|
|
}
|
|
} finally {
|
|
await rm(staging, { recursive: true, force: true })
|
|
}
|
|
}
|
|
await readSystemSkillCatalog(destination)
|
|
return destination
|
|
}
|
|
|
|
function exactKeys(value, keys, label) {
|
|
if (value === null || typeof value !== 'object' || Array.isArray(value)
|
|
|| Object.keys(value).sort().join(',') !== [...keys].sort().join(',')) {
|
|
throw new Error(`${label} is invalid`)
|
|
}
|
|
return value
|
|
}
|
|
|
|
function skillId(value) {
|
|
if (typeof value !== 'string' || !ID_PATTERN.test(value) || value.length > 64) {
|
|
throw new Error('System Skill ID is invalid')
|
|
}
|
|
return value
|
|
}
|
|
|
|
function description(value, label) {
|
|
exactKeys(value, ['en', 'zh'], label)
|
|
for (const language of ['en', 'zh']) {
|
|
if (typeof value[language] !== 'string' || value[language].length === 0 || value[language].length > 240) {
|
|
throw new Error(`${label}.${language} is invalid`)
|
|
}
|
|
}
|
|
return Object.freeze({ en: value.en, zh: value.zh })
|
|
}
|
|
|
|
export async function readSystemSkillCatalog(root) {
|
|
const sourceRoot = resolve(root)
|
|
const document = JSON.parse(await readFile(join(sourceRoot, 'catalog.json'), 'utf8'))
|
|
exactKeys(document, ['schema', 'skills'], 'System Skill catalog')
|
|
if (document.schema !== 1 || !Array.isArray(document.skills)) throw new Error('System Skill catalog is invalid')
|
|
const ids = new Set()
|
|
const skills = []
|
|
for (const entry of document.skills) {
|
|
exactKeys(entry, ['description', 'id', 'source'], 'System Skill catalog entry')
|
|
const id = skillId(entry.id)
|
|
if (ids.has(id)) throw new Error(`duplicate System Skill ID ${id}`)
|
|
ids.add(id)
|
|
if (typeof entry.source !== 'string' || entry.source !== id) throw new Error(`System Skill ${id} source is invalid`)
|
|
const source = resolve(sourceRoot, entry.source)
|
|
if (!source.startsWith(`${sourceRoot}/`)) throw new Error(`System Skill ${id} source escapes its catalog`)
|
|
const metadata = await lstat(join(source, 'SKILL.md'))
|
|
if (!metadata.isFile() || metadata.isSymbolicLink()) throw new Error(`System Skill ${id} SKILL.md is invalid`)
|
|
const bytes = await readFile(join(source, 'SKILL.md'), 'utf8')
|
|
if (!bytes.startsWith('---\n') || !bytes.includes(`\nname: ${id}\n`)) {
|
|
throw new Error(`System Skill ${id} frontmatter does not match its catalog`)
|
|
}
|
|
skills.push(Object.freeze({
|
|
id,
|
|
source,
|
|
sha256: await hashTree(source),
|
|
description: description(entry.description, `System Skill ${id} description`),
|
|
}))
|
|
}
|
|
return Object.freeze(skills)
|
|
}
|
|
|
|
function defaults(catalog) {
|
|
return Object.fromEntries(catalog.map(skill => [skill.id, { installed: true, enabled: true }]))
|
|
}
|
|
|
|
function normalizeState(value, catalog) {
|
|
const fallback = defaults(catalog)
|
|
if (value === undefined) return fallback
|
|
exactKeys(value, ['schema', 'skills'], 'System Skill selection state')
|
|
if (value.schema !== STATE_SCHEMA || value.skills === null || typeof value.skills !== 'object' || Array.isArray(value.skills)) {
|
|
throw new Error('System Skill selection state is invalid')
|
|
}
|
|
for (const skill of catalog) {
|
|
const saved = value.skills[skill.id]
|
|
if (saved === undefined) continue
|
|
exactKeys(saved, ['enabled', 'installed'], `System Skill ${skill.id} selection`)
|
|
if (typeof saved.installed !== 'boolean' || typeof saved.enabled !== 'boolean' || (saved.enabled && !saved.installed)) {
|
|
throw new Error(`System Skill ${skill.id} selection is invalid`)
|
|
}
|
|
fallback[skill.id] = { installed: saved.installed, enabled: saved.enabled }
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
export class SystemSkillSelectionStore {
|
|
constructor(path) {
|
|
this.path = resolve(path)
|
|
}
|
|
|
|
async read(catalog) {
|
|
let value
|
|
try {
|
|
value = JSON.parse(await readFile(this.path, 'utf8'))
|
|
} catch (error) {
|
|
if (error?.code !== 'ENOENT') throw error
|
|
}
|
|
return normalizeState(value, catalog)
|
|
}
|
|
|
|
async write(catalog, state) {
|
|
const normalized = normalizeState({ schema: STATE_SCHEMA, skills: state }, catalog)
|
|
const document = {
|
|
schema: STATE_SCHEMA,
|
|
skills: Object.fromEntries(catalog.map(skill => [skill.id, normalized[skill.id]])),
|
|
}
|
|
await mkdir(dirname(this.path), { recursive: true })
|
|
const temporary = `${this.path}.${randomUUID()}.tmp`
|
|
try {
|
|
await writeFile(temporary, `${canonicalJson(document)}\n`, { flag: 'wx', mode: 0o600 })
|
|
await rename(temporary, this.path)
|
|
} finally {
|
|
await rm(temporary, { force: true })
|
|
}
|
|
return normalized
|
|
}
|
|
}
|
|
|
|
async function replaceSkillLink(viewRoot, skill) {
|
|
const path = join(viewRoot, skill.id)
|
|
const existing = await lstat(path).catch(error => error?.code === 'ENOENT' ? undefined : Promise.reject(error))
|
|
if (existing?.isSymbolicLink() && await readlink(path) === skill.source) return
|
|
const temporary = join(viewRoot, `.${skill.id}.${randomUUID()}.tmp`)
|
|
try {
|
|
await symlink(skill.source, temporary, 'dir')
|
|
await rename(temporary, path)
|
|
} finally {
|
|
await rm(temporary, { recursive: true, force: true })
|
|
}
|
|
}
|
|
|
|
export async function synchronizeSystemSkillView({ catalog, selection, viewRoot }) {
|
|
const root = resolve(viewRoot)
|
|
await mkdir(root, { recursive: true })
|
|
const expected = new Set(catalog.filter(skill => selection[skill.id].enabled).map(skill => skill.id))
|
|
for (const entry of await readdir(root, { withFileTypes: true })) {
|
|
if (!expected.has(entry.name)) await rm(join(root, entry.name), { recursive: true, force: true })
|
|
}
|
|
for (const skill of catalog) {
|
|
if (selection[skill.id].enabled) await replaceSkillLink(root, skill)
|
|
}
|
|
}
|
|
|
|
export class SystemSkillManager {
|
|
constructor({ sourceRoot, viewRoot, statePath }) {
|
|
this.sourceRoot = resolve(sourceRoot)
|
|
this.viewRoot = resolve(viewRoot)
|
|
this.store = new SystemSkillSelectionStore(statePath)
|
|
}
|
|
|
|
async initialize() {
|
|
const catalog = await readSystemSkillCatalog(this.sourceRoot)
|
|
const selection = await this.store.read(catalog)
|
|
await this.store.write(catalog, selection)
|
|
await synchronizeSystemSkillView({ catalog, selection, viewRoot: this.viewRoot })
|
|
return this.listFrom(catalog, selection)
|
|
}
|
|
|
|
async useSourceRoot(sourceRoot) {
|
|
const nextRoot = resolve(sourceRoot)
|
|
const catalog = await readSystemSkillCatalog(nextRoot)
|
|
const selection = await this.store.read(catalog)
|
|
await this.store.write(catalog, selection)
|
|
await synchronizeSystemSkillView({ catalog, selection, viewRoot: this.viewRoot })
|
|
this.sourceRoot = nextRoot
|
|
return this.listFrom(catalog, selection)
|
|
}
|
|
|
|
listFrom(catalog, selection) {
|
|
return Object.freeze(catalog.map(skill => Object.freeze({
|
|
id: skill.id,
|
|
sha256: skill.sha256,
|
|
description: skill.description,
|
|
installed: selection[skill.id].installed,
|
|
enabled: selection[skill.id].enabled,
|
|
})))
|
|
}
|
|
|
|
async list() {
|
|
const catalog = await readSystemSkillCatalog(this.sourceRoot)
|
|
return this.listFrom(catalog, await this.store.read(catalog))
|
|
}
|
|
|
|
async configure(idValue, action) {
|
|
const id = skillId(idValue)
|
|
if (!['install', 'uninstall', 'enable', 'disable'].includes(action)) throw new Error('System Skill action is invalid')
|
|
const catalog = await readSystemSkillCatalog(this.sourceRoot)
|
|
if (!catalog.some(skill => skill.id === id)) throw new Error(`System Skill ${id} is not provided by the current Environment`)
|
|
const before = await this.store.read(catalog)
|
|
const next = structuredClone(before)
|
|
const current = next[id]
|
|
if (action === 'install') next[id] = { installed: true, enabled: true }
|
|
else if (action === 'uninstall') next[id] = { installed: false, enabled: false }
|
|
else if (action === 'enable') {
|
|
if (!current.installed) throw new Error(`System Skill ${id} must be installed before it can be enabled`)
|
|
next[id] = { ...current, enabled: true }
|
|
} else next[id] = { ...current, enabled: false }
|
|
try {
|
|
await this.store.write(catalog, next)
|
|
await synchronizeSystemSkillView({ catalog, selection: next, viewRoot: this.viewRoot })
|
|
return this.listFrom(catalog, next)
|
|
} catch (error) {
|
|
try {
|
|
await this.store.write(catalog, before)
|
|
await synchronizeSystemSkillView({ catalog, selection: before, viewRoot: this.viewRoot })
|
|
} catch (rollbackError) {
|
|
throw new AggregateError([error, rollbackError], 'System Skill operation and rollback failed')
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
}
|