singularity-forge/src/resource-loader.ts

192 lines
6.9 KiB
TypeScript
Raw Normal View History

import { DefaultResourceLoader } from '@gsd/pi-coding-agent'
import { homedir } from 'node:os'
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
import { dirname, join, relative, resolve } from 'node:path'
2026-03-10 22:28:37 -06:00
import { fileURLToPath } from 'node:url'
import { compareSemver } from './update-check.js'
2026-03-10 22:28:37 -06:00
2026-03-14 18:47:03 +01:00
// Resolve resources directory — prefer dist/resources/ (stable, set at build time)
// over src/resources/ (live working tree, changes with git branch).
//
// Why this matters: with `npm link`, src/resources/ points into the gsd-2 repo's
// working tree. Switching branches there changes src/resources/ for ALL projects
// that use gsd — causing stale/broken extensions to be synced to ~/.gsd/agent/.
// dist/resources/ is populated by the build step (`npm run copy-resources`) and
// reflects the built state, not the currently checked-out branch.
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const distResources = join(packageRoot, 'dist', 'resources')
const srcResources = join(packageRoot, 'src', 'resources')
const resourcesDir = existsSync(distResources) ? distResources : srcResources
2026-03-10 22:28:37 -06:00
const bundledExtensionsDir = join(resourcesDir, 'extensions')
const resourceVersionManifestName = 'managed-resources.json'
interface ManagedResourceManifest {
gsdVersion: string
}
2026-03-10 22:28:37 -06:00
function isExtensionFile(name: string): boolean {
return name.endsWith('.ts') || name.endsWith('.js')
}
function resolveExtensionEntries(dir: string): string[] {
const packageJsonPath = join(dir, 'package.json')
if (existsSync(packageJsonPath)) {
try {
const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf-8'))
const declared = pkg?.pi?.extensions
if (Array.isArray(declared)) {
const resolved = declared
.filter((entry: unknown): entry is string => typeof entry === 'string')
.map((entry: string) => resolve(dir, entry))
.filter((entry: string) => existsSync(entry))
if (resolved.length > 0) {
return resolved
}
}
} catch {
// Ignore malformed manifests and fall back to index.ts/index.js discovery.
}
}
const indexTs = join(dir, 'index.ts')
if (existsSync(indexTs)) {
return [indexTs]
}
const indexJs = join(dir, 'index.js')
if (existsSync(indexJs)) {
return [indexJs]
}
return []
}
export function discoverExtensionEntryPaths(extensionsDir: string): string[] {
if (!existsSync(extensionsDir)) {
return []
}
const discovered: string[] = []
for (const entry of readdirSync(extensionsDir, { withFileTypes: true })) {
const entryPath = join(extensionsDir, entry.name)
if ((entry.isFile() || entry.isSymbolicLink()) && isExtensionFile(entry.name)) {
discovered.push(entryPath)
continue
}
if (entry.isDirectory() || entry.isSymbolicLink()) {
discovered.push(...resolveExtensionEntries(entryPath))
}
}
return discovered
}
function getExtensionKey(entryPath: string, extensionsDir: string): string {
const relPath = relative(extensionsDir, entryPath)
return relPath.split(/[\\/]/)[0]
}
function getManagedResourceManifestPath(agentDir: string): string {
return join(agentDir, resourceVersionManifestName)
}
function getBundledGsdVersion(): string {
try {
const pkg = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf-8'))
return typeof pkg?.version === 'string' ? pkg.version : '0.0.0'
} catch {
return process.env.GSD_VERSION || '0.0.0'
}
}
function writeManagedResourceManifest(agentDir: string): void {
const manifest: ManagedResourceManifest = { gsdVersion: getBundledGsdVersion() }
writeFileSync(getManagedResourceManifestPath(agentDir), JSON.stringify(manifest))
}
export function readManagedResourceVersion(agentDir: string): string | null {
try {
const manifest = JSON.parse(readFileSync(getManagedResourceManifestPath(agentDir), 'utf-8')) as ManagedResourceManifest
return typeof manifest?.gsdVersion === 'string' ? manifest.gsdVersion : null
} catch {
return null
}
}
export function getNewerManagedResourceVersion(agentDir: string, currentVersion: string): string | null {
const managedVersion = readManagedResourceVersion(agentDir)
if (!managedVersion) {
return null
}
return compareSemver(managedVersion, currentVersion) > 0 ? managedVersion : null
}
2026-03-10 22:28:37 -06:00
/**
* Syncs all bundled resources to agentDir (~/.gsd/agent/) on every launch.
*
* - extensions/ ~/.gsd/agent/extensions/ (overwrite when version changes)
* - agents/ ~/.gsd/agent/agents/ (overwrite when version changes)
* - skills/ ~/.gsd/agent/skills/ (overwrite when version changes)
2026-03-10 22:28:37 -06:00
* - GSD-WORKFLOW.md is read directly from bundled path via GSD_WORKFLOW_PATH env var
*
* Skips the copy when the managed-resources.json version matches the current
* GSD version, avoiding ~128ms of synchronous cpSync on every startup.
* After `npm update -g @glittercowboy/gsd`, versions will differ and the
* copy runs once to land the new resources.
2026-03-10 22:28:37 -06:00
*
* Inspectable: `ls ~/.gsd/agent/extensions/`
*/
export function initResources(agentDir: string): void {
mkdirSync(agentDir, { recursive: true })
// Skip resource sync when versions match — saves ~128ms of cpSync per launch
const currentVersion = getBundledGsdVersion()
const managedVersion = readManagedResourceVersion(agentDir)
if (managedVersion && managedVersion === currentVersion) {
return
}
// Sync extensions — overwrite so updates land on next launch
2026-03-10 22:28:37 -06:00
const destExtensions = join(agentDir, 'extensions')
cpSync(bundledExtensionsDir, destExtensions, { recursive: true, force: true })
// Sync agents
const destAgents = join(agentDir, 'agents')
const srcAgents = join(resourcesDir, 'agents')
if (existsSync(srcAgents)) {
cpSync(srcAgents, destAgents, { recursive: true, force: true })
}
// Sync skills — overwrite so updates land on next launch
const destSkills = join(agentDir, 'skills')
const srcSkills = join(resourcesDir, 'skills')
if (existsSync(srcSkills)) {
cpSync(srcSkills, destSkills, { recursive: true, force: true })
}
writeManagedResourceManifest(agentDir)
2026-03-10 22:28:37 -06:00
}
/**
* Constructs a DefaultResourceLoader that loads extensions from both
* ~/.gsd/agent/extensions/ (GSD's default) and ~/.pi/agent/extensions/ (pi's default).
* This allows users to use extensions from either location.
2026-03-10 22:28:37 -06:00
*/
export function buildResourceLoader(agentDir: string): DefaultResourceLoader {
const piAgentDir = join(homedir(), '.pi', 'agent')
const piExtensionsDir = join(piAgentDir, 'extensions')
const bundledKeys = new Set(
discoverExtensionEntryPaths(bundledExtensionsDir).map((entryPath) => getExtensionKey(entryPath, bundledExtensionsDir)),
)
const piExtensionPaths = discoverExtensionEntryPaths(piExtensionsDir).filter(
(entryPath) => !bundledKeys.has(getExtensionKey(entryPath, piExtensionsDir)),
)
return new DefaultResourceLoader({
agentDir,
additionalExtensionPaths: piExtensionPaths,
})
2026-03-10 22:28:37 -06:00
}