diff --git a/src/extension-discovery.ts b/src/extension-discovery.ts new file mode 100644 index 000000000..8e586a052 --- /dev/null +++ b/src/extension-discovery.ts @@ -0,0 +1,75 @@ +import { existsSync, readFileSync, readdirSync } from 'node:fs' +import { join, resolve } from 'node:path' + +function isExtensionFile(name: string): boolean { + return name.endsWith('.ts') || name.endsWith('.js') +} + +/** + * Resolves the entry-point file(s) for a single extension directory. + * + * 1. If the directory contains a package.json with a `pi.extensions` array, + * each entry is resolved relative to the directory and returned (if it exists). + * 2. Otherwise falls back to `index.ts` → `index.js`. + */ +export 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 [] +} + +/** + * Discovers all extension entry-point paths under an extensions directory. + * + * - Top-level .ts/.js files are treated as standalone extension entry points. + * - Subdirectories are resolved via `resolveExtensionEntries()` (package.json → + * pi.extensions, then index.ts/index.js fallback). + */ +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 +} diff --git a/src/loader.ts b/src/loader.ts index 42149656c..e43b07718 100644 --- a/src/loader.ts +++ b/src/loader.ts @@ -2,8 +2,8 @@ // GSD Startup Loader // Copyright (c) 2026 Jeremy McSpadden import { fileURLToPath } from 'url' -import { dirname, resolve, join, delimiter } from 'path' -import { existsSync, readFileSync, readdirSync, mkdirSync, symlinkSync, cpSync } from 'fs' +import { dirname, resolve, join, relative, delimiter } from 'path' +import { existsSync, readFileSync, mkdirSync, symlinkSync, cpSync } from 'fs' // Fast-path: handle --version/-v and --help/-h before importing any heavy // dependencies. This avoids loading the entire pi-coding-agent barrel import @@ -35,6 +35,7 @@ if (firstArg === '--help' || firstArg === '-h') { import { agentDir, appRoot } from './app-paths.js' import { serializeBundledExtensionPaths } from './bundled-extension-paths.js' +import { discoverExtensionEntryPaths } from './extension-discovery.js' import { renderLogo } from './logo.js' // pkg/ is a shim directory: contains gsd's piConfig (package.json) and pi's @@ -108,37 +109,14 @@ const resourcesDir = existsSync(distRes) ? distRes : srcRes process.env.GSD_WORKFLOW_PATH = join(resourcesDir, 'GSD-WORKFLOW.md') // GSD_BUNDLED_EXTENSION_PATHS — dynamically discovered bundled extension entry points. -// Scans the bundled resources directory to find all extensions, then maps paths to -// agentDir (~/.gsd/agent/extensions/) where initResources() will sync them. -// -// Discovery rules (mirroring resource-loader.ts discoverExtensionEntryPaths): -// - Top-level .ts/.js files → extension entry point -// - Directories with index.ts or index.js → extension entry point -// - Directories without either (e.g. shared/, remote-questions/) → skipped -// -// Previously this was a hardcoded list that required manual updates whenever -// extensions were added or removed — causing merge conflicts in forks and -// falling out of sync with what buildResourceLoader() discovers at runtime. +// Uses the shared discoverExtensionEntryPaths() to scan the bundled resources +// directory, then remaps discovered paths to agentDir (~/.gsd/agent/extensions/) +// where initResources() will sync them. const bundledExtDir = join(resourcesDir, 'extensions') const agentExtDir = join(agentDir, 'extensions') -const discoveredExtensionPaths: string[] = [] - -if (existsSync(bundledExtDir)) { - for (const entry of readdirSync(bundledExtDir, { withFileTypes: true })) { - if (entry.isFile() && (entry.name.endsWith('.ts') || entry.name.endsWith('.js'))) { - discoveredExtensionPaths.push(join(agentExtDir, entry.name)) - } else if (entry.isDirectory()) { - const srcIndex = existsSync(join(bundledExtDir, entry.name, 'index.ts')) - ? 'index.ts' - : existsSync(join(bundledExtDir, entry.name, 'index.js')) - ? 'index.js' - : null - if (srcIndex) { - discoveredExtensionPaths.push(join(agentExtDir, entry.name, srcIndex)) - } - } - } -} +const discoveredExtensionPaths = discoverExtensionEntryPaths(bundledExtDir).map( + (entryPath) => join(agentExtDir, relative(bundledExtDir, entryPath)), +) process.env.GSD_BUNDLED_EXTENSION_PATHS = serializeBundledExtensionPaths(discoveredExtensionPaths) diff --git a/src/resource-loader.ts b/src/resource-loader.ts index d5fdc9a7f..b9b95ce65 100644 --- a/src/resource-loader.ts +++ b/src/resource-loader.ts @@ -4,6 +4,7 @@ import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rm 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). @@ -25,64 +26,7 @@ interface ManagedResourceManifest { syncedAt?: number } -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 -} +export { discoverExtensionEntryPaths } from './extension-discovery.js' function getExtensionKey(entryPath: string, extensionsDir: string): string { const relPath = relative(extensionsDir, entryPath) diff --git a/src/tests/app-smoke.test.ts b/src/tests/app-smoke.test.ts index dceb25fc2..f027b3be7 100644 --- a/src/tests/app-smoke.test.ts +++ b/src/tests/app-smoke.test.ts @@ -95,15 +95,14 @@ test("loader sets all 4 GSD_ env vars and PI_PACKAGE_DIR", async () => { assert.ok(loaderSrc.includes("join(delimiter)"), "loader uses platform delimiter for NODE_PATH"); // Verify extension discovery mechanism is in place - // loader.ts now dynamically discovers extensions via readdirSync instead of - // hardcoding paths — verify the discovery infrastructure exists - assert.ok(loaderSrc.includes("readdirSync"), "loader uses readdirSync for extension discovery"); + // loader.ts uses shared discoverExtensionEntryPaths() from extension-discovery.ts + assert.ok(loaderSrc.includes("discoverExtensionEntryPaths"), "loader uses discoverExtensionEntryPaths for extension discovery"); assert.ok(loaderSrc.includes("bundledExtDir"), "loader defines bundledExtDir for scanning"); assert.ok(loaderSrc.includes("discoveredExtensionPaths"), "loader collects discovered paths"); // Verify that the env var is populated at runtime by checking the actual // extensions directory has discoverable entry points - const { discoverExtensionEntryPaths } = await import("../resource-loader.ts"); + const { discoverExtensionEntryPaths } = await import("../extension-discovery.ts"); const bundledExtensionsDir = join(projectRoot, existsSync(join(projectRoot, "dist", "resources")) ? "dist" : "src", "resources", "extensions"); const discovered = discoverExtensionEntryPaths(bundledExtensionsDir);