Skip to content

Commit 793e300

Browse files
committed
fix(plugins): support linked source checkouts on Windows
1 parent 42bdc94 commit 793e300

10 files changed

Lines changed: 252 additions & 26 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ Docs: https://docs.openclaw.ai
1818

1919
- Installer: let the local-prefix CLI installer use Alpine's `apk` Node.js, npm, and Git packages on musl Linux instead of downloading glibc Node tarballs that fail `node:sqlite`.
2020
- Scripts: use `git grep` to prefilter tracked conflict-marker scans so changed checks avoid reading every repository file on clean runs.
21+
- Plugins: allow linked local plugin paths to probe TypeScript source entries without requiring compiled package output, restoring source-checkout plugin development on native Windows.
22+
- CLI: route source-checkout build output to stderr before launching OpenClaw commands so stale local builds do not corrupt `--json` stdout.
2123
- Installer: install Node.js through `apk` on Alpine Linux instead of falling through to the NodeSource package-manager path.
2224
- Agents/perf: cache manifest-backed CLI provider descriptors and fallback provider resolution so model fallback retries avoid repeated bundled provider runtime scans while still invalidating across plugin reloads.
2325
- Installer: detect musl Linux shells such as Alpine as Linux instead of rejecting them before npm install.

scripts/run-node.mjs

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -647,6 +647,28 @@ const createRunNodeOutputTee = (deps) => {
647647
if (!outputLogPath) {
648648
return null;
649649
}
650+
try {
651+
const existing = deps.fs.statSync(outputLogPath);
652+
if (existing.isDirectory()) {
653+
return {
654+
outputLogPath,
655+
write() {},
656+
async close() {
657+
throw new Error(`output log path is a directory: ${outputLogPath}`);
658+
},
659+
};
660+
}
661+
} catch (error) {
662+
if (error?.code && error.code !== "ENOENT") {
663+
return {
664+
outputLogPath,
665+
write() {},
666+
async close() {
667+
throw error;
668+
},
669+
};
670+
}
671+
}
650672
deps.fs.mkdirSync(path.dirname(outputLogPath), { recursive: true });
651673
const stream = deps.fs.createWriteStream(outputLogPath, {
652674
flags: "a",
@@ -936,16 +958,18 @@ const runOpenClaw = async (deps) => {
936958
return res.exitCode ?? 1;
937959
};
938960

939-
const pipeSpawnedOutput = (childProcess, deps) => {
940-
if (!shouldPipeSpawnedOutput(deps)) {
961+
const pipeSpawnedOutput = (childProcess, deps, options = {}) => {
962+
const stdoutTarget = options.stdoutTarget ?? "stdout";
963+
if (!shouldPipeSpawnedOutput(deps) && stdoutTarget !== "stderr") {
941964
return;
942965
}
943966
const stderrFilter =
944967
deps.env[RUN_NODE_FILTER_SYNC_IO_STDERR_ENV] === "1"
945968
? createSyncIoTraceStderrFilter(deps)
946969
: null;
947970
childProcess.stdout?.on("data", (chunk) => {
948-
writeRunnerStream(deps, deps.stdout, chunk);
971+
const target = stdoutTarget === "stderr" ? deps.stderr : deps.stdout;
972+
writeRunnerStream(deps, target, chunk);
949973
deps.outputTee?.write(chunk);
950974
});
951975
childProcess.stderr?.on("data", (chunk) => {
@@ -1389,9 +1413,9 @@ export async function runNodeMain(params = {}) {
13891413
const assetBuild = deps.spawn(buildCmd, bundledPluginAssetBuildArgs, {
13901414
cwd: deps.cwd,
13911415
env: deps.env,
1392-
stdio: shouldPipeSpawnedOutput(deps) ? ["inherit", "pipe", "pipe"] : "inherit",
1416+
stdio: ["inherit", "pipe", "pipe"],
13931417
});
1394-
pipeSpawnedOutput(assetBuild, deps);
1418+
pipeSpawnedOutput(assetBuild, deps, { stdoutTarget: "stderr" });
13951419
const assetBuildRes = await waitForSpawnedProcess(assetBuild, deps);
13961420
const assetBuildInterruptedExitCode = getInterruptedSpawnExitCode(assetBuildRes);
13971421
if (assetBuildInterruptedExitCode !== null) {
@@ -1407,9 +1431,9 @@ export async function runNodeMain(params = {}) {
14071431
...deps.env,
14081432
[RUN_NODE_SKIP_DTS_BUILD_ENV]: deps.env[RUN_NODE_SKIP_DTS_BUILD_ENV] ?? "1",
14091433
},
1410-
stdio: shouldPipeSpawnedOutput(deps) ? ["inherit", "pipe", "pipe"] : "inherit",
1434+
stdio: ["inherit", "pipe", "pipe"],
14111435
});
1412-
pipeSpawnedOutput(build, deps);
1436+
pipeSpawnedOutput(build, deps, { stdoutTarget: "stderr" });
14131437

14141438
const buildRes = await waitForSpawnedProcess(build, deps);
14151439
const interruptedExitCode = getInterruptedSpawnExitCode(buildRes);

src/cli/plugins-cli.install.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,7 @@ type MockWithCalls = {
302302
};
303303

304304
type PluginInstallCall = {
305+
allowSourceTypeScriptEntries?: boolean;
305306
archivePath?: string;
306307
dangerouslyForceUnsafeInstall?: boolean;
307308
dryRun?: boolean;
@@ -1321,6 +1322,7 @@ describe("plugins cli install", () => {
13211322

13221323
expect(pathInstallCall().path).toBe(tmpRoot);
13231324
expect(pathInstallCall().dryRun).toBe(true);
1325+
expect(pathInstallCall().allowSourceTypeScriptEntries).toBe(true);
13241326
expect(pathInstallCall().dangerouslyForceUnsafeInstall).toBe(true);
13251327
});
13261328

@@ -1536,6 +1538,7 @@ describe("plugins cli install", () => {
15361538

15371539
expect(pathInstallCall().path).toBe(localPluginDir);
15381540
expect(pathInstallCall().dryRun).toBe(true);
1541+
expect(pathInstallCall().allowSourceTypeScriptEntries).toBe(true);
15391542
expect(pathInstallCall().dangerouslyForceUnsafeInstall).toBe(true);
15401543
expect(typeof pathInstallCall().logger?.info).toBe("function");
15411544
expect(typeof pathInstallCall().logger?.warn).toBe("function");

src/cli/plugins-install-command.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -693,6 +693,7 @@ export async function runPluginInstallCommand(params: {
693693
mode: installMode,
694694
path: resolved,
695695
dryRun: true,
696+
allowSourceTypeScriptEntries: true,
696697
extensionsDir,
697698
logger: createPluginInstallLogger(runtime),
698699
});

src/infra/run-node.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -559,6 +559,68 @@ describe("run-node script", () => {
559559
});
560560
});
561561

562+
it("routes local build stdout to stderr before JSON command output", async () => {
563+
await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => {
564+
await writeRuntimePostBuildScaffold(tmp);
565+
const outputPath = path.join(tmp, ".artifacts", "run-node", "output.log");
566+
const spawn = (_cmd: string, args: string[]) => {
567+
if (args[0] === "scripts/bundled-plugin-assets.mjs") {
568+
return createPipedExitedProcess({
569+
stdout: "asset stdout\n",
570+
stderr: "asset stderr\n",
571+
});
572+
}
573+
if (args[0] === "scripts/tsdown-build.mjs") {
574+
return createPipedExitedProcess({
575+
stdout: "build stdout\n",
576+
stderr: "build stderr\n",
577+
});
578+
}
579+
return createPipedExitedProcess({ stdout: '{"plugins":[]}\n' });
580+
};
581+
const stdoutChunks: string[] = [];
582+
const stderrChunks: string[] = [];
583+
const stdout = {
584+
write: (chunk: string | Buffer) => {
585+
stdoutChunks.push(String(chunk));
586+
return true;
587+
},
588+
} as unknown as NodeJS.WriteStream;
589+
const stderr = {
590+
write: (chunk: string | Buffer) => {
591+
stderrChunks.push(String(chunk));
592+
return true;
593+
},
594+
} as unknown as NodeJS.WriteStream;
595+
596+
const exitCode = await runNodeMain({
597+
cwd: tmp,
598+
args: ["plugins", "list", "--json"],
599+
env: {
600+
...process.env,
601+
OPENCLAW_FORCE_BUILD: "1",
602+
OPENCLAW_RUNNER_LOG: "0",
603+
OPENCLAW_RUN_NODE_OUTPUT_LOG: outputPath,
604+
},
605+
spawn,
606+
stdout,
607+
stderr,
608+
execPath: process.execPath,
609+
platform: process.platform,
610+
} as Parameters<typeof runNodeMain>[0] & {
611+
stdout: NodeJS.WriteStream;
612+
stderr: NodeJS.WriteStream;
613+
});
614+
615+
expect(exitCode).toBe(0);
616+
expect(stdoutChunks.join("")).toBe('{"plugins":[]}\n');
617+
expect(stderrChunks.join("")).toContain("asset stdout\n");
618+
expect(stderrChunks.join("")).toContain("asset stderr\n");
619+
expect(stderrChunks.join("")).toContain("build stdout\n");
620+
expect(stderrChunks.join("")).toContain("build stderr\n");
621+
});
622+
});
623+
562624
it("routes sync I/O trace stderr blocks to the output log without flooding stderr", async () => {
563625
await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => {
564626
await setupTrackedProject(tmp);

src/plugins/discovery.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -879,6 +879,45 @@ describe("discoverOpenClawPlugins", () => {
879879
});
880880
});
881881

882+
it("allows linked local install records to point at TypeScript source entries", async () => {
883+
const stateDir = makeTempDir();
884+
const pluginDir = path.join(stateDir, "extensions", "linked-source-pack");
885+
mkdirSafe(path.join(pluginDir, "src"));
886+
887+
writePluginPackageManifest({
888+
packageDir: pluginDir,
889+
packageName: "@openclaw/linked-source-pack",
890+
extensions: ["./src/index.ts"],
891+
setupEntry: "./src/setup-entry.ts",
892+
});
893+
writePluginManifest({ pluginDir, id: "linked-source-pack" });
894+
writePluginEntry(path.join(pluginDir, "src", "index.ts"));
895+
writePluginEntry(path.join(pluginDir, "src", "setup-entry.ts"));
896+
897+
const installRecords = {
898+
"linked-source-pack": {
899+
source: "path",
900+
installPath: pluginDir,
901+
sourcePath: pluginDir,
902+
},
903+
} satisfies Record<string, PluginInstallRecord>;
904+
const result = await discoverWithStateDir(stateDir, { installRecords });
905+
906+
expectCandidateSource(
907+
result.candidates,
908+
"linked-source-pack",
909+
fs.realpathSync(path.join(pluginDir, "src", "index.ts")),
910+
);
911+
expectCandidateFields(requireCandidateById(result.candidates, "linked-source-pack"), {
912+
setupSource: fs.realpathSync(path.join(pluginDir, "src", "setup-entry.ts")),
913+
});
914+
expectNoDiagnostic({
915+
diagnostics: result.diagnostics,
916+
pluginId: "linked-source-pack",
917+
messageIncludes: "requires compiled runtime output",
918+
});
919+
});
920+
882921
it("still requires compiled runtime output for tracked installed package plugins", async () => {
883922
const stateDir = makeTempDir();
884923
const pluginDir = path.join(stateDir, "extensions", "source-only-pack");

src/plugins/discovery.ts

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -389,11 +389,40 @@ function mergeDiscoveryResult(
389389
}
390390
}
391391

392+
type InstalledPluginRecordPath = {
393+
path: string;
394+
requireBuiltRuntimeEntry: boolean;
395+
};
396+
397+
function isLinkedLocalPluginRecord(params: {
398+
record: PluginInstallRecord;
399+
env: NodeJS.ProcessEnv;
400+
realpathCache: Map<string, string>;
401+
}): boolean {
402+
if (params.record.source !== "path") {
403+
return false;
404+
}
405+
if (
406+
typeof params.record.sourcePath !== "string" ||
407+
!params.record.sourcePath.trim() ||
408+
typeof params.record.installPath !== "string" ||
409+
!params.record.installPath.trim()
410+
) {
411+
return false;
412+
}
413+
return resolvesToSameDirectory(
414+
resolveUserPath(params.record.sourcePath, params.env),
415+
resolveUserPath(params.record.installPath, params.env),
416+
params.realpathCache,
417+
);
418+
}
419+
392420
function collectInstalledPluginRecordPaths(
393421
installRecords: Record<string, PluginInstallRecord> | undefined,
394422
env: NodeJS.ProcessEnv,
395-
): string[] {
396-
const paths: string[] = [];
423+
realpathCache: Map<string, string>,
424+
): InstalledPluginRecordPath[] {
425+
const paths: InstalledPluginRecordPath[] = [];
397426
const seen = new Set<string>();
398427
for (const record of Object.values(installRecords ?? {})) {
399428
const rawPath =
@@ -410,7 +439,10 @@ function collectInstalledPluginRecordPaths(
410439
continue;
411440
}
412441
seen.add(resolved);
413-
paths.push(resolved);
442+
paths.push({
443+
path: resolved,
444+
requireBuiltRuntimeEntry: !isLinkedLocalPluginRecord({ record, env, realpathCache }),
445+
});
414446
}
415447
return paths;
416448
}
@@ -1380,19 +1412,26 @@ export function discoverOpenClawPlugins(params: {
13801412
skipDirectories: readChildDirectoryNames(roots.stock),
13811413
});
13821414
}
1383-
const installedPaths = collectInstalledPluginRecordPaths(params.installRecords, env);
1384-
const installedPluginDirKeys = collectManagedPluginDirKeys(installedPaths, realpathCache);
1415+
const installedPaths = collectInstalledPluginRecordPaths(
1416+
params.installRecords,
1417+
env,
1418+
realpathCache,
1419+
);
1420+
const installedPluginDirKeys = collectManagedPluginDirKeys(
1421+
installedPaths.map((installedPath) => installedPath.path),
1422+
realpathCache,
1423+
);
13851424
const managedPluginDirs = collectManagedPluginDirKeys(
13861425
collectManagedPluginRecordPaths(params.installRecords, env),
13871426
realpathCache,
13881427
);
13891428
for (const installedPath of installedPaths) {
13901429
discoverFromPath({
1391-
rawPath: installedPath,
1430+
rawPath: installedPath.path,
13921431
origin: "global",
13931432
ownershipUid: params.ownershipUid,
13941433
workspaceDir,
1395-
requireBuiltRuntimeEntry: true,
1434+
requireBuiltRuntimeEntry: installedPath.requireBuiltRuntimeEntry,
13961435
managedPluginDirs,
13971436
env,
13981437
candidates: result.candidates,

0 commit comments

Comments
 (0)