Skip to content

Commit 19e5ad9

Browse files
committed
feat(skills): add versioned git-export and atomic install
Replace ref_env with optional version field defaulting to "latest". Resolve latest release tags via GitHub API. Perform atomic skill installs by staging to a temp directory before renaming into place.
1 parent caba351 commit 19e5ad9

1 file changed

Lines changed: 64 additions & 19 deletions

File tree

scripts/install-skills.ts

Lines changed: 64 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,25 @@
11
#!/usr/bin/env bun
2-
import { mkdir, mkdtemp, readdir, cp, rm } from "node:fs/promises"
2+
import { mkdir, mkdtemp, readdir, cp, rm, rename } from "node:fs/promises"
33
import path from "node:path"
44
import os from "node:os"
55
import YAML from "yaml"
66

77
type Step =
88
| { type: "download"; url: string; dest: string }
99
| { type: "git-export"; url: string }
10-
| { type: "git-export"; repo: string; path: string; ref_env?: string }
10+
| { type: "git-export"; repo: string; path: string; version?: string }
1111
| { type: "uv-pip"; packages: string[] }
1212

1313
type GitExportSpec = Extract<Step, { type: "git-export" }>
1414
type ResolvedGitExport = {
1515
repo: string
16-
ref: string
16+
ref: string | null
1717
path: string
1818
}
1919
type Workspace = {
2020
dir: string
2121
cloneDir: string
22-
ref: string
22+
ref: string | null
2323
}
2424

2525
type Skill = {
@@ -42,6 +42,10 @@ function fail(msg: string): never {
4242
throw new Error(`[install-skills] ${msg}`)
4343
}
4444

45+
function info(msg: string) {
46+
console.log(`[install-skills] ${msg}`)
47+
}
48+
4549
function safe(base: string, dest: string) {
4650
const file = path.resolve(base, dest)
4751
const rel = path.relative(base, file)
@@ -53,6 +57,11 @@ async function ensure(dir: string) {
5357
await mkdir(dir, { recursive: true })
5458
}
5559

60+
async function resetDir(dir: string) {
61+
await rm(dir, { recursive: true, force: true })
62+
await mkdir(dir, { recursive: true })
63+
}
64+
5665
async function shell(args: string[]) {
5766
const proc = Bun.spawn(args, {
5867
stdout: "inherit",
@@ -67,22 +76,38 @@ function isGitHubRepo(repo: string) {
6776
return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)
6877
}
6978

70-
function resolveRef(step: GitExportSpec): ResolvedGitExport {
79+
async function resolveLatestReleaseTag(repo: string) {
80+
const res = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {
81+
headers: {
82+
Accept: "application/vnd.github+json",
83+
"User-Agent": "install-skills",
84+
},
85+
})
86+
87+
if (res.status === 404) return null
88+
if (!res.ok) fail(`git-export: failed latest release lookup for ${repo} (${res.status})`)
89+
90+
const data = (await res.json()) as { tag_name?: unknown }
91+
const tag = typeof data.tag_name === "string" && data.tag_name ? data.tag_name : null
92+
if (tag) info(`git-export: resolved latest release for ${repo} -> ${tag}`)
93+
return tag
94+
}
95+
96+
async function resolveRef(step: GitExportSpec): Promise<ResolvedGitExport> {
7197
if (!step.repo) fail(`git-export: missing repo`)
7298
if (!step.path) fail(`git-export: missing path for ${step.repo}`)
73-
if (!step.ref_env) fail(`git-export: missing ref_env for ${step.repo}/${step.path}`)
74-
75-
const ref = process.env[step.ref_env]
76-
if (!ref) fail(`git-export: missing environment variable ${step.ref_env}`)
7799
if (!isGitHubRepo(step.repo)) fail(`git-export: unsupported repo format: ${step.repo}`)
100+
101+
const version = step.version ?? "latest"
102+
const ref = version === "latest" ? await resolveLatestReleaseTag(step.repo) : version
78103
return { repo: step.repo, path: step.path, ref }
79104
}
80105

81-
function workspaceKey(repo: string, ref: string) {
82-
return `${repo}@${ref}`
106+
function workspaceKey(repo: string, ref: string | null) {
107+
return `${repo}@${ref ?? "HEAD"}`
83108
}
84109

85-
async function ensureWorkspace(repo: string, ref: string) {
110+
async function ensureWorkspace(repo: string, ref: string | null) {
86111
const key = workspaceKey(repo, ref)
87112
const existing = workspaces.get(key)
88113
if (existing) return existing
@@ -91,8 +116,12 @@ async function ensureWorkspace(repo: string, ref: string) {
91116
const cloneDir = path.join(dir, "repo")
92117
const repoUrl = `https://github.com/${repo}.git`
93118
await shell(["git", "clone", "--depth", "1", "--filter=tree:0", "--no-checkout", repoUrl, cloneDir])
94-
await shell(["git", "-C", cloneDir, "fetch", "--depth", "1", "origin", ref])
95-
await shell(["git", "-C", cloneDir, "checkout", "--detach", "FETCH_HEAD"])
119+
if (ref) {
120+
await shell(["git", "-C", cloneDir, "fetch", "--depth", "1", "origin", ref])
121+
await shell(["git", "-C", cloneDir, "checkout", "--detach", "FETCH_HEAD"])
122+
} else {
123+
await shell(["git", "-C", cloneDir, "checkout"])
124+
}
96125

97126
const workspace: Workspace = { dir, cloneDir, ref }
98127
workspaces.set(key, workspace)
@@ -116,6 +145,25 @@ async function cleanupWorkspaces() {
116145
workspaces.clear()
117146
}
118147

148+
async function runSkill(name: string, steps: Step[]) {
149+
const destDir = path.join(root, name)
150+
const stageParent = await mkdtemp(path.join(os.tmpdir(), "skills-install-"))
151+
const stageDir = path.join(stageParent, name)
152+
153+
try {
154+
await resetDir(stageDir)
155+
for (const step of steps) {
156+
await run(name, step, stageDir)
157+
}
158+
159+
await rm(destDir, { recursive: true, force: true })
160+
await ensure(path.dirname(destDir))
161+
await rename(stageDir, destDir)
162+
} finally {
163+
await rm(stageParent, { recursive: true, force: true })
164+
}
165+
}
166+
119167
async function run(name: string, step: Step, dir: string) {
120168
if (step.type === "download") {
121169
await ensure(dir)
@@ -132,7 +180,7 @@ async function run(name: string, step: Step, dir: string) {
132180
return
133181
}
134182

135-
const resolved = resolveRef(step)
183+
const resolved = await resolveRef(step)
136184
const workspace = await ensureWorkspace(resolved.repo, resolved.ref)
137185
await copySubtreeFromWorkspace(workspace, resolved.path, dir)
138186
return
@@ -162,10 +210,7 @@ async function main() {
162210
for (const skill of manifest.skills) {
163211
if (!skill.name) fail(`skill missing name`)
164212
if (!Array.isArray(skill.steps)) fail(`${skill.name}: missing steps`)
165-
const dir = path.join(root, skill.name)
166-
for (const step of skill.steps) {
167-
await run(skill.name, step, dir)
168-
}
213+
await runSkill(skill.name, skill.steps)
169214
}
170215
} finally {
171216
await cleanupWorkspaces()

0 commit comments

Comments
 (0)