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:
parent
196be59d71
commit
89ee5e439a
4 changed files with 89 additions and 93 deletions
75
src/extension-discovery.ts
Normal file
75
src/extension-discovery.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue