From aca53c5853ee81d6cde79ecc51c09801906fd582 Mon Sep 17 00:00:00 2001 From: deseltrus Date: Sat, 14 Mar 2026 20:06:08 +0100 Subject: [PATCH] refactor: replace hardcoded extension list with dynamic discovery in loader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit loader.ts previously maintained a hardcoded list of bundled extension paths for GSD_BUNDLED_EXTENSION_PATHS. This required manual updates whenever extensions were added or removed, and created a consistency gap with buildResourceLoader() which already discovers extensions dynamically. Replace with runtime directory scanning that mirrors the discovery rules in resource-loader.ts: - Top-level .ts/.js files → extension entry point - Directories with index.ts or index.js → extension entry point - Directories without either (shared/, remote-questions/) → skipped Benefits: - Adding a new extension no longer requires editing loader.ts - GSD_BUNDLED_EXTENSION_PATHS stays in sync with what buildResourceLoader() loads in the main process — subagents now receive the same extensions - Fixes: 5 extensions (google-search, mcporter, ttsr, universal-config, voice) were loaded in the main process but missing from GSD_BUNDLED_EXTENSION_PATHS, meaning subagents did not receive them - Eliminates a common source of merge conflicts for contributors and forks that add custom extensions --- src/loader.ts | 57 +++++++++++++++++++++++-------------- src/tests/app-smoke.test.ts | 38 ++++++++++++++----------- 2 files changed, 57 insertions(+), 38 deletions(-) diff --git a/src/loader.ts b/src/loader.ts index a54050b9b..5301d25d5 100644 --- a/src/loader.ts +++ b/src/loader.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node import { fileURLToPath } from 'url' import { dirname, resolve, join, delimiter } from 'path' -import { existsSync, readFileSync, mkdirSync, symlinkSync } from 'fs' +import { existsSync, readFileSync, readdirSync, mkdirSync, symlinkSync } from 'fs' import { agentDir, appRoot } from './app-paths.js' import { serializeBundledExtensionPaths } from './bundled-extension-paths.js' import { renderLogo } from './logo.js' @@ -78,27 +78,40 @@ const srcRes = join(loaderPackageRoot, 'src', 'resources') const resourcesDir = existsSync(distRes) ? distRes : srcRes process.env.GSD_WORKFLOW_PATH = join(resourcesDir, 'GSD-WORKFLOW.md') -// GSD_BUNDLED_EXTENSION_PATHS — platform-delimited list of bundled extension entry point absolute -// paths, used by patched subagent to pass --extension to spawned gsd processes. -// IMPORTANT: paths point to agentDir (~/.gsd/agent/extensions/) NOT src/resources/extensions/. -// initResources() syncs bundled extensions to agentDir before any extension loading occurs, -// so these paths are always valid at runtime. Using agentDir paths matches what buildResourceLoader -// discovers (it scans agentDir), so pi's deduplication works correctly and extensions are not -// double-loaded in subagent child processes. -// Note: shared/ is NOT included — it's a library imported by gsd and ask-user-questions, not an entry point. -process.env.GSD_BUNDLED_EXTENSION_PATHS = serializeBundledExtensionPaths([ - join(agentDir, 'extensions', 'gsd', 'index.ts'), - join(agentDir, 'extensions', 'bg-shell', 'index.ts'), - join(agentDir, 'extensions', 'browser-tools', 'index.ts'), - join(agentDir, 'extensions', 'context7', 'index.ts'), - join(agentDir, 'extensions', 'search-the-web', 'index.ts'), - join(agentDir, 'extensions', 'slash-commands', 'index.ts'), - join(agentDir, 'extensions', 'subagent', 'index.ts'), - join(agentDir, 'extensions', 'mac-tools', 'index.ts'), - join(agentDir, 'extensions', 'async-jobs', 'index.ts'), - join(agentDir, 'extensions', 'ask-user-questions.ts'), - join(agentDir, 'extensions', 'get-secrets-from-user.ts'), -]) +// 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. +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)) + } + } + } +} + +process.env.GSD_BUNDLED_EXTENSION_PATHS = serializeBundledExtensionPaths(discoveredExtensionPaths) // Respect HTTP_PROXY / HTTPS_PROXY / NO_PROXY env vars for all outbound requests. // pi-coding-agent's cli.ts sets this, but GSD bypasses that entry point — so we diff --git a/src/tests/app-smoke.test.ts b/src/tests/app-smoke.test.ts index 61e28cebb..1c94190c0 100644 --- a/src/tests/app-smoke.test.ts +++ b/src/tests/app-smoke.test.ts @@ -94,22 +94,28 @@ test("loader sets all 4 GSD_ env vars and PI_PACKAGE_DIR", async () => { assert.ok(loaderSrc.includes("serializeBundledExtensionPaths"), "loader uses shared bundled path serializer"); assert.ok(loaderSrc.includes("join(delimiter)"), "loader uses platform delimiter for NODE_PATH"); - // Verify all 11 extension entry points are referenced in loader - // Loader uses join() calls like join(agentDir, 'extensions', 'gsd', 'index.ts') - // so we check for the distinguishing directory name of each extension - const extNames = [ - "'gsd'", - "'bg-shell'", - "'browser-tools'", - "'context7'", - "'search-the-web'", - "'slash-commands'", - "'subagent'", - "'ask-user-questions.ts'", - "'get-secrets-from-user.ts'", - ]; - for (const name of extNames) { - assert.ok(loaderSrc.includes(name), `loader references extension ${name}`); + // 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"); + 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 bundledExtensionsDir = join(projectRoot, existsSync(join(projectRoot, "dist", "resources")) + ? "dist" : "src", "resources", "extensions"); + const discovered = discoverExtensionEntryPaths(bundledExtensionsDir); + assert.ok(discovered.length >= 10, `expected >=10 extensions, found ${discovered.length}`); + + // Spot-check that core extensions are discoverable + const discoveredNames = discovered.map(p => { + const rel = p.slice(bundledExtensionsDir.length + 1); + return rel.split(/[\\/]/)[0].replace(/\.ts$/, ""); + }); + for (const core of ["gsd", "bg-shell", "browser-tools", "subagent", "search-the-web"]) { + assert.ok(discoveredNames.includes(core), `core extension '${core}' is discoverable`); } rmSync(tmp, { recursive: true, force: true });