diff --git a/scripts/watch-node.mjs b/scripts/watch-node.mjs index e4598ae79fe..01ef9bba261 100644 --- a/scripts/watch-node.mjs +++ b/scripts/watch-node.mjs @@ -82,7 +82,7 @@ export async function runWatchMain(params = {}) { env: childEnv, stdio: "inherit", }); - watchProcess.on("exit", () => { + watchProcess.on("exit", (exitCode, exitSignal) => { watchProcess = null; if (shuttingDown) { return; @@ -90,7 +90,9 @@ export async function runWatchMain(params = {}) { if (restartRequested) { restartRequested = false; startRunner(); + return; } + settle(exitSignal ? 1 : (exitCode ?? 1)); }); }; diff --git a/src/infra/watch-node.test.ts b/src/infra/watch-node.test.ts index 81ff76f8de3..0f176d859ad 100644 --- a/src/infra/watch-node.test.ts +++ b/src/infra/watch-node.test.ts @@ -127,6 +127,25 @@ describe("watch-node script", () => { expect(fakeProcess.listenerCount("SIGTERM")).toBe(0); }); + it("returns the child exit code when the runner exits on its own", async () => { + const { child, spawn, watcher, createWatcher, fakeProcess } = createWatchHarness(); + + const runPromise = runWatchMain({ + args: ["gateway", "--force", "--help"], + createWatcher, + process: fakeProcess, + spawn, + }); + + child.emit("exit", 0, null); + const exitCode = await runPromise; + + expect(exitCode).toBe(0); + expect(watcher.close).toHaveBeenCalledTimes(1); + expect(fakeProcess.listenerCount("SIGINT")).toBe(0); + expect(fakeProcess.listenerCount("SIGTERM")).toBe(0); + }); + it("ignores test-only changes and restarts on non-test source changes", async () => { const childA = Object.assign(new EventEmitter(), { kill: vi.fn(function () { diff --git a/src/plugins/bundled-dir.test.ts b/src/plugins/bundled-dir.test.ts index 8f3464fa8af..eff0f34a1c7 100644 --- a/src/plugins/bundled-dir.test.ts +++ b/src/plugins/bundled-dir.test.ts @@ -168,6 +168,20 @@ describe("resolveBundledPluginsDir", () => { expectedRelativeDir: path.join("dist", "extensions"), }, ], + [ + "prefers built dist/extensions in a git checkout outside vitest", + { + prefix: "openclaw-bundled-dir-git-built-", + hasExtensions: true, + hasSrc: true, + hasDistRuntimeExtensions: true, + hasDistExtensions: true, + hasGitCheckout: true, + }, + { + expectedRelativeDir: path.join("dist-runtime", "extensions"), + }, + ], [ "prefers source extensions under vitest to avoid stale staged plugins", { @@ -182,13 +196,11 @@ describe("resolveBundledPluginsDir", () => { }, ], [ - "prefers source extensions in a git checkout even without vitest env", + "falls back to source extensions in a git checkout when built trees are missing", { prefix: "openclaw-bundled-dir-git-", hasExtensions: true, hasSrc: true, - hasDistRuntimeExtensions: true, - hasDistExtensions: true, hasGitCheckout: true, }, { diff --git a/src/plugins/bundled-dir.ts b/src/plugins/bundled-dir.ts index f66959acd7b..a58451fb8d9 100644 --- a/src/plugins/bundled-dir.ts +++ b/src/plugins/bundled-dir.ts @@ -31,10 +31,7 @@ function resolveBundledDirFromPackageRoot( ): string | undefined { const sourceExtensionsDir = path.join(packageRoot, "extensions"); const builtExtensionsDir = path.join(packageRoot, "dist", "extensions"); - if ( - (preferSourceCheckout || isSourceCheckoutRoot(packageRoot)) && - fs.existsSync(sourceExtensionsDir) - ) { + if (preferSourceCheckout && fs.existsSync(sourceExtensionsDir)) { return sourceExtensionsDir; } // Local source checkouts stage a runtime-complete bundled plugin tree under @@ -47,6 +44,9 @@ function resolveBundledDirFromPackageRoot( if (fs.existsSync(builtExtensionsDir)) { return builtExtensionsDir; } + if (isSourceCheckoutRoot(packageRoot) && fs.existsSync(sourceExtensionsDir)) { + return sourceExtensionsDir; + } return undefined; } diff --git a/src/plugins/discovery.test.ts b/src/plugins/discovery.test.ts index 14fb724e205..3c6ddee5aac 100644 --- a/src/plugins/discovery.test.ts +++ b/src/plugins/discovery.test.ts @@ -385,6 +385,43 @@ describe("discoverOpenClawPlugins", () => { expectCandidateOrder(candidates, ["opik-openclaw"]); }); + it("skips dependency and build directories while scanning workspace roots", () => { + const stateDir = makeTempDir(); + const workspaceDir = path.join(stateDir, "workspace"); + const workspacePluginDir = path.join(workspaceDir, "packages", "workspace-plugin"); + const nestedNodeModulesDir = path.join(workspaceDir, "node_modules", "openclaw"); + const nestedDistDir = path.join(workspaceDir, "dist", "extensions", "diffs"); + mkdirSafe(path.join(workspacePluginDir, "src")); + mkdirSafe(path.join(nestedNodeModulesDir, "src")); + mkdirSafe(nestedDistDir); + + createPackagePluginWithEntry({ + packageDir: workspacePluginDir, + packageName: "@openclaw/workspace-plugin", + pluginId: "workspace-plugin", + }); + + createPackagePluginWithEntry({ + packageDir: nestedNodeModulesDir, + packageName: "openclaw", + pluginId: "node-modules-copy", + }); + + writePluginManifest({ pluginDir: nestedDistDir, id: "dist-copy" }); + fs.writeFileSync( + path.join(nestedDistDir, "index.js"), + "module.exports = { id: 'dist-copy', register() {} };", + "utf-8", + ); + + const { candidates } = discoverOpenClawPlugins({ + workspaceDir, + env: buildDiscoveryEnv(stateDir), + }); + + expectCandidateOrder(candidates, ["workspace-plugin"]); + }); + it.each([ { name: "derives unscoped ids for scoped packages", diff --git a/src/plugins/discovery.ts b/src/plugins/discovery.ts index 307ae359306..531b0051ecd 100644 --- a/src/plugins/discovery.ts +++ b/src/plugins/discovery.ts @@ -17,6 +17,18 @@ import { resolvePluginCacheInputs, resolvePluginSourceRoots } from "./roots.js"; import type { PluginBundleFormat, PluginDiagnostic, PluginFormat, PluginOrigin } from "./types.js"; const EXTENSION_EXTS = new Set([".ts", ".js", ".mts", ".cts", ".mjs", ".cjs"]); +const SCANNED_DIRECTORY_IGNORE_NAMES = new Set([ + ".git", + ".hg", + ".svn", + ".turbo", + ".yarn", + ".yarn-cache", + "build", + "coverage", + "dist", + "node_modules", +]); export type PluginCandidate = { idHint: string; @@ -292,6 +304,9 @@ function shouldIgnoreScannedDirectory(dirName: string): boolean { if (!normalized) { return true; } + if (SCANNED_DIRECTORY_IGNORE_NAMES.has(normalized)) { + return true; + } if (normalized.endsWith(".bak")) { return true; }