fix: unify extension discovery logic (#995)

* fix: unify extension discovery between loader.ts and resource-loader.ts

Extract shared extension discovery logic (resolveExtensionEntries,
discoverExtensionEntryPaths) into extension-discovery.ts. Both loader.ts
and resource-loader.ts now use the same algorithm, which supports
package.json pi.extensions declarations in addition to index.ts/index.js
fallback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: update test to match refactored extension discovery imports

Test checked for readdirSync which was replaced by discoverExtensionEntryPaths.
Updated import path from resource-loader.ts to extension-discovery.ts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
TÂCHES 2026-03-17 17:14:04 -06:00 committed by GitHub
parent 196be59d71
commit 89ee5e439a
4 changed files with 89 additions and 93 deletions

View file

@ -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
}

View file

@ -2,8 +2,8 @@
// GSD Startup Loader
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
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)

View file

@ -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)

View file

@ -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);