189 lines
7.0 KiB
TypeScript
189 lines
7.0 KiB
TypeScript
// Updates the pristine upstream/ snapshot from mattpocock/skills and records the revision.
|
|
//
|
|
// node scripts/sync-upstream.ts [path-to-existing-clone]
|
|
//
|
|
// Without a path it shallow-clones https://github.com/mattpocock/skills into a temp dir.
|
|
// Exits 0 with "no changes" when upstream HEAD equals the recorded commit and the snapshot exists,
|
|
// so the nightly workflow can stop early. On change it replaces upstream/skills wholesale, writes
|
|
// upstream/UPSTREAM.json, updates .opencode/state/upstream.json and adds needs-review entries for
|
|
// new skills in .opencode/state/classification.json, then prints the added/removed/changed skills.
|
|
import { execFileSync } from "node:child_process"
|
|
import { createHash } from "node:crypto"
|
|
import {
|
|
cpSync,
|
|
existsSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
readdirSync,
|
|
rmSync,
|
|
statSync,
|
|
writeFileSync,
|
|
} from "node:fs"
|
|
import { tmpdir } from "node:os"
|
|
import path from "node:path"
|
|
import { fileURLToPath } from "node:url"
|
|
|
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
|
export const UPSTREAM_URL = "https://github.com/mattpocock/skills"
|
|
const STATE_PATH = path.join(root, ".opencode/state/upstream.json")
|
|
const CLASSIFICATION_PATH = path.join(root, ".opencode/state/classification.json")
|
|
const SNAPSHOT_DIR = path.join(root, "upstream")
|
|
|
|
export type UpstreamState = {
|
|
repo: string
|
|
commit: string
|
|
commit_date: string
|
|
upstream_version: string
|
|
synced_at: string | null
|
|
note?: string
|
|
}
|
|
|
|
/** Names of skill dirs (those containing SKILL.md) under a skills/ root, as "<bucket>/<name>". */
|
|
export function listSkillDirs(skillsRoot: string): string[] {
|
|
const out: string[] = []
|
|
if (!existsSync(skillsRoot)) return out
|
|
for (const bucket of readdirSync(skillsRoot).sort()) {
|
|
const bucketPath = path.join(skillsRoot, bucket)
|
|
if (!statSync(bucketPath).isDirectory()) continue
|
|
for (const name of readdirSync(bucketPath).sort()) {
|
|
if (existsSync(path.join(bucketPath, name, "SKILL.md"))) out.push(`${bucket}/${name}`)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
/** Deterministic content hash of a directory (sorted relative paths + file bytes). */
|
|
export function hashDir(dir: string): string {
|
|
const hash = createHash("sha256")
|
|
const walk = (d: string, prefix: string) => {
|
|
for (const entry of readdirSync(d).sort()) {
|
|
const full = path.join(d, entry)
|
|
const rel = prefix ? `${prefix}/${entry}` : entry
|
|
if (statSync(full).isDirectory()) walk(full, rel)
|
|
else hash.update(rel).update(readFileSync(full))
|
|
}
|
|
}
|
|
walk(dir, "")
|
|
return hash.digest("hex")
|
|
}
|
|
|
|
export type SnapshotDiff = { added: string[]; removed: string[]; changed: string[] }
|
|
|
|
export function diffSnapshots(before: string, after: string): SnapshotDiff {
|
|
const oldDirs = new Map(
|
|
listSkillDirs(before).map((s) => [s, hashDir(path.join(before, ...s.split("/")))]),
|
|
)
|
|
const newDirs = new Map(
|
|
listSkillDirs(after).map((s) => [s, hashDir(path.join(after, ...s.split("/")))]),
|
|
)
|
|
const diff: SnapshotDiff = { added: [], removed: [], changed: [] }
|
|
for (const [name, hash] of newDirs) {
|
|
if (!oldDirs.has(name)) diff.added.push(name)
|
|
else if (oldDirs.get(name) !== hash) diff.changed.push(name)
|
|
}
|
|
for (const name of oldDirs.keys()) if (!newDirs.has(name)) diff.removed.push(name)
|
|
return diff
|
|
}
|
|
|
|
/** Appends needs-review classification entries for skill dirs missing from classification.json. */
|
|
export function recordNewSkills(classificationPath: string, added: string[]): string[] {
|
|
if (added.length === 0) return []
|
|
const doc = JSON.parse(readFileSync(classificationPath, "utf8"))
|
|
const known = new Set(doc.skills.map((s: { name: string }) => s.name))
|
|
const fresh: string[] = []
|
|
for (const dir of added) {
|
|
const [bucket, name] = dir.split("/")
|
|
if (known.has(name)) continue
|
|
doc.skills.push({
|
|
name,
|
|
bucket,
|
|
classification: "skill",
|
|
user_invoked: false,
|
|
artifacts: ["skill:verbatim"],
|
|
depends_on: [],
|
|
status: "needs-review",
|
|
rationale: "New upstream skill; default classification, awaiting human review.",
|
|
})
|
|
fresh.push(name)
|
|
}
|
|
if (fresh.length) writeFileSync(classificationPath, JSON.stringify(doc, null, 2) + "\n")
|
|
return fresh
|
|
}
|
|
|
|
function git(cloneDir: string, ...args: string[]): string {
|
|
return execFileSync("git", ["-C", cloneDir, ...args], { encoding: "utf8" }).trim()
|
|
}
|
|
|
|
async function main() {
|
|
const existing = process.argv[2]
|
|
let cloneDir = existing
|
|
let temp: string | undefined
|
|
if (!cloneDir) {
|
|
temp = mkdtempSync(path.join(tmpdir(), "mp-skills-"))
|
|
cloneDir = path.join(temp, "skills")
|
|
console.log(`cloning ${UPSTREAM_URL} ...`)
|
|
execFileSync("git", ["clone", "--quiet", "--depth", "1", UPSTREAM_URL, cloneDir])
|
|
}
|
|
try {
|
|
const commit = git(cloneDir, "rev-parse", "HEAD")
|
|
const commitDate = git(cloneDir, "log", "-1", "--format=%cI")
|
|
const upstreamPkg = JSON.parse(readFileSync(path.join(cloneDir, "package.json"), "utf8"))
|
|
|
|
const recorded: UpstreamState | undefined = existsSync(STATE_PATH)
|
|
? JSON.parse(readFileSync(STATE_PATH, "utf8"))
|
|
: undefined
|
|
if (
|
|
recorded?.commit === commit &&
|
|
existsSync(path.join(SNAPSHOT_DIR, "skills")) &&
|
|
recorded.synced_at
|
|
) {
|
|
console.log(`no changes: upstream already at ${commit.slice(0, 9)}`)
|
|
return
|
|
}
|
|
|
|
const oldSkills = path.join(SNAPSHOT_DIR, "skills")
|
|
const diff = existsSync(oldSkills)
|
|
? diffSnapshots(oldSkills, path.join(cloneDir, "skills"))
|
|
: { added: listSkillDirs(path.join(cloneDir, "skills")), removed: [], changed: [] }
|
|
|
|
rmSync(oldSkills, { recursive: true, force: true })
|
|
cpSync(path.join(cloneDir, "skills"), oldSkills, { recursive: true })
|
|
const meta = {
|
|
repo: UPSTREAM_URL,
|
|
commit,
|
|
commit_date: commitDate,
|
|
upstream_version: upstreamPkg.version,
|
|
}
|
|
writeFileSync(path.join(SNAPSHOT_DIR, "UPSTREAM.json"), JSON.stringify(meta, null, 2) + "\n")
|
|
const state: UpstreamState & { previous_commit?: string | null; last_diff?: SnapshotDiff } = {
|
|
...meta,
|
|
synced_at: new Date().toISOString(),
|
|
previous_commit: recorded?.commit ?? null,
|
|
last_diff: diff,
|
|
}
|
|
writeFileSync(STATE_PATH, JSON.stringify(state, null, 2) + "\n")
|
|
|
|
const fresh = recordNewSkills(CLASSIFICATION_PATH, diff.added)
|
|
console.log(`synced upstream to ${commit.slice(0, 9)} (${commitDate}), v${upstreamPkg.version}`)
|
|
console.log(
|
|
`skills: +${diff.added.length} -${diff.removed.length} ~${diff.changed.length}` +
|
|
(diff.added.length ? `\n added: ${diff.added.join(", ")}` : "") +
|
|
(diff.removed.length ? `\n removed: ${diff.removed.join(", ")}` : "") +
|
|
(diff.changed.length ? `\n changed: ${diff.changed.join(", ")}` : ""),
|
|
)
|
|
if (fresh.length) {
|
|
console.log(`classification: added needs-review entries for ${fresh.join(", ")}`)
|
|
console.log("NOTE: needs-review entries fail the drift test until reviewed.")
|
|
}
|
|
} finally {
|
|
if (temp) rmSync(temp, { recursive: true, force: true })
|
|
}
|
|
}
|
|
|
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
main().catch((err) => {
|
|
console.error(err.message)
|
|
process.exit(1)
|
|
})
|
|
}
|