initResources() only re-synced when the GSD version changed. This meant
same-version content fixes (e.g. the subagent bundled-extension-paths.js
import fix in a2a701b1) never reached ~/.gsd/agent/extensions/ because
the version-only check saw 2.28.0 == 2.28.0 and skipped the sync.
Add a lightweight content fingerprint (sha256 of file paths + sizes) to
the managed-resources.json manifest. On startup, if the version matches
but the fingerprint doesn't, resources are re-synced. This covers:
- npm link dev workflows where source changes without version bumps
- hotfixes within a release that change bundled extension content
- upgrades from manifests without contentHash (treated as stale)
Cost: ~1ms of stat calls on ~100 files — no file reads needed.
274 lines
10 KiB
TypeScript
274 lines
10 KiB
TypeScript
import { DefaultResourceLoader } from '@gsd/pi-coding-agent'
|
|
import { createHash } from 'node:crypto'
|
|
import { homedir } from 'node:os'
|
|
import { chmodSync, copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
import { dirname, join, relative, resolve } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { compareSemver } from './update-check.js'
|
|
import { discoverExtensionEntryPaths } from './extension-discovery.js'
|
|
|
|
// 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
|
|
const bundledExtensionsDir = join(resourcesDir, 'extensions')
|
|
const resourceVersionManifestName = 'managed-resources.json'
|
|
|
|
interface ManagedResourceManifest {
|
|
gsdVersion: string
|
|
syncedAt?: number
|
|
/** Content fingerprint of bundled resources — detects same-version content changes. */
|
|
contentHash?: string
|
|
}
|
|
|
|
export { discoverExtensionEntryPaths } from './extension-discovery.js'
|
|
|
|
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 {
|
|
// Prefer GSD_VERSION env var (set once by loader.ts) to avoid re-reading package.json
|
|
if (process.env.GSD_VERSION && process.env.GSD_VERSION !== '0.0.0') {
|
|
return process.env.GSD_VERSION
|
|
}
|
|
try {
|
|
const pkg = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf-8'))
|
|
return typeof pkg?.version === 'string' ? pkg.version : '0.0.0'
|
|
} catch {
|
|
return '0.0.0'
|
|
}
|
|
}
|
|
|
|
function writeManagedResourceManifest(agentDir: string): void {
|
|
const manifest: ManagedResourceManifest = {
|
|
gsdVersion: getBundledGsdVersion(),
|
|
syncedAt: Date.now(),
|
|
contentHash: computeResourceFingerprint(),
|
|
}
|
|
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
|
|
}
|
|
}
|
|
|
|
function readManagedResourceManifest(agentDir: string): ManagedResourceManifest | null {
|
|
try {
|
|
return JSON.parse(readFileSync(getManagedResourceManifestPath(agentDir), 'utf-8')) as ManagedResourceManifest
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Computes a lightweight content fingerprint of the bundled resources directory.
|
|
*
|
|
* Walks all files under resourcesDir and hashes their relative paths + sizes.
|
|
* This catches same-version content changes (npm link dev workflow, hotfixes
|
|
* within a release) without the cost of reading every file's contents.
|
|
*
|
|
* ~1ms for a typical resources tree (~100 files) — just stat calls, no reads.
|
|
*/
|
|
function computeResourceFingerprint(): string {
|
|
const entries: string[] = []
|
|
collectFileEntries(resourcesDir, resourcesDir, entries)
|
|
entries.sort()
|
|
return createHash('sha256').update(entries.join('\n')).digest('hex').slice(0, 16)
|
|
}
|
|
|
|
function collectFileEntries(dir: string, root: string, out: string[]): void {
|
|
if (!existsSync(dir)) return
|
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
const fullPath = join(dir, entry.name)
|
|
if (entry.isDirectory()) {
|
|
collectFileEntries(fullPath, root, out)
|
|
} else {
|
|
const rel = relative(root, fullPath)
|
|
const size = statSync(fullPath).size
|
|
out.push(`${rel}:${size}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
export function getNewerManagedResourceVersion(agentDir: string, currentVersion: string): string | null {
|
|
const managedVersion = readManagedResourceVersion(agentDir)
|
|
if (!managedVersion) {
|
|
return null
|
|
}
|
|
return compareSemver(managedVersion, currentVersion) > 0 ? managedVersion : null
|
|
}
|
|
|
|
/**
|
|
* Recursively makes all files and directories under dirPath owner-writable.
|
|
*
|
|
* Files copied from the Nix store inherit read-only modes (0444/0555).
|
|
* Calling this before cpSync prevents overwrite failures on subsequent upgrades,
|
|
* and calling it after ensures the next run can overwrite the copies too.
|
|
*
|
|
* Preserves existing permission bits (including executability) and only adds
|
|
* owner-write (and for directories, owner-exec) without widening group/other
|
|
* permissions.
|
|
*/
|
|
function makeTreeWritable(dirPath: string): void {
|
|
if (!existsSync(dirPath)) return
|
|
|
|
const stats = statSync(dirPath)
|
|
const isDir = stats.isDirectory()
|
|
const currentMode = stats.mode & 0o777
|
|
|
|
// Ensure owner-write; for directories also ensure owner-exec so they remain traversable.
|
|
let newMode = currentMode | 0o200
|
|
if (isDir) {
|
|
newMode |= 0o100
|
|
}
|
|
|
|
if (newMode !== currentMode) {
|
|
chmodSync(dirPath, newMode)
|
|
}
|
|
|
|
if (isDir) {
|
|
for (const entry of readdirSync(dirPath, { withFileTypes: true })) {
|
|
const entryPath = join(dirPath, entry.name)
|
|
makeTreeWritable(entryPath)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Syncs a single bundled resource directory into the agent directory.
|
|
*
|
|
* 1. Makes the destination writable (handles Nix store read-only copies).
|
|
* 2. Removes destination subdirs that exist in source to clear stale files,
|
|
* while preserving user-created directories.
|
|
* 3. Copies source into destination.
|
|
* 4. Makes the result writable for the next upgrade cycle.
|
|
*/
|
|
function syncResourceDir(srcDir: string, destDir: string): void {
|
|
makeTreeWritable(destDir)
|
|
if (existsSync(srcDir)) {
|
|
for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
|
|
if (entry.isDirectory()) {
|
|
const target = join(destDir, entry.name)
|
|
if (existsSync(target)) rmSync(target, { recursive: true, force: true })
|
|
}
|
|
}
|
|
try {
|
|
cpSync(srcDir, destDir, { recursive: true, force: true })
|
|
} catch {
|
|
// Fallback for Windows paths with non-ASCII characters where cpSync
|
|
// fails with the \\?\ extended-length prefix (#1178).
|
|
copyDirRecursive(srcDir, destDir)
|
|
}
|
|
makeTreeWritable(destDir)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Recursive directory copy using copyFileSync — workaround for cpSync failures
|
|
* on Windows paths containing non-ASCII characters (#1178).
|
|
*/
|
|
function copyDirRecursive(src: string, dest: string): void {
|
|
mkdirSync(dest, { recursive: true })
|
|
for (const entry of readdirSync(src, { withFileTypes: true })) {
|
|
const srcPath = join(src, entry.name)
|
|
const destPath = join(dest, entry.name)
|
|
if (entry.isDirectory()) {
|
|
copyDirRecursive(srcPath, destPath)
|
|
} else {
|
|
copyFileSync(srcPath, destPath)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
* - 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.
|
|
*
|
|
* Inspectable: `ls ~/.gsd/agent/extensions/`
|
|
*/
|
|
export function initResources(agentDir: string): void {
|
|
mkdirSync(agentDir, { recursive: true })
|
|
|
|
// Skip the full copy when both version AND content fingerprint match.
|
|
// Version-only checks miss same-version content changes (npm link dev workflow,
|
|
// hotfixes within a release). The content hash catches those at ~1ms cost.
|
|
const currentVersion = getBundledGsdVersion()
|
|
const manifest = readManagedResourceManifest(agentDir)
|
|
if (manifest && manifest.gsdVersion === currentVersion) {
|
|
// Version matches — check content fingerprint for same-version staleness.
|
|
const currentHash = computeResourceFingerprint()
|
|
if (manifest.contentHash && manifest.contentHash === currentHash) {
|
|
return
|
|
}
|
|
}
|
|
|
|
syncResourceDir(bundledExtensionsDir, join(agentDir, 'extensions'))
|
|
syncResourceDir(join(resourcesDir, 'agents'), join(agentDir, 'agents'))
|
|
syncResourceDir(join(resourcesDir, 'skills'), join(agentDir, 'skills'))
|
|
|
|
// Ensure all newly copied files are owner-writable so the next run can
|
|
// overwrite them (covers extensions, agents, and skills in one walk).
|
|
makeTreeWritable(agentDir)
|
|
|
|
writeManagedResourceManifest(agentDir)
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
// Cache bundled extension keys at module load — avoids re-scanning the extensions
|
|
// directory in buildResourceLoader() (already scanned by loader.ts for env var).
|
|
let _bundledExtensionKeys: Set<string> | null = null
|
|
function getBundledExtensionKeys(): Set<string> {
|
|
if (!_bundledExtensionKeys) {
|
|
_bundledExtensionKeys = new Set(
|
|
discoverExtensionEntryPaths(bundledExtensionsDir).map((entryPath) => getExtensionKey(entryPath, bundledExtensionsDir)),
|
|
)
|
|
}
|
|
return _bundledExtensionKeys
|
|
}
|
|
|
|
export function buildResourceLoader(agentDir: string): DefaultResourceLoader {
|
|
const piAgentDir = join(homedir(), '.pi', 'agent')
|
|
const piExtensionsDir = join(piAgentDir, 'extensions')
|
|
const bundledKeys = getBundledExtensionKeys()
|
|
const piExtensionPaths = discoverExtensionEntryPaths(piExtensionsDir).filter(
|
|
(entryPath) => !bundledKeys.has(getExtensionKey(entryPath, piExtensionsDir)),
|
|
)
|
|
|
|
return new DefaultResourceLoader({
|
|
agentDir,
|
|
additionalExtensionPaths: piExtensionPaths,
|
|
})
|
|
}
|