@openclaw/plugin-inspector 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/init.js ADDED
@@ -0,0 +1,150 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { inferPluginSeams, packageId } from "./config.js";
5
+
6
+ export const defaultInitConfigPath = "plugin-inspector.config.json";
7
+ export const defaultInitWorkflowPath = ".github/workflows/plugin-inspector.yml";
8
+
9
+ export async function writePluginInspectorInit(options = {}) {
10
+ const pluginRoot = path.resolve(options.pluginRoot ?? options.cwd ?? process.cwd());
11
+ const configPath = path.resolve(pluginRoot, options.configPath ?? defaultInitConfigPath);
12
+ const written = [];
13
+
14
+ if (existsSync(configPath) && options.force !== true) {
15
+ throw new Error(`${path.relative(pluginRoot, configPath)} already exists; pass --force to overwrite it`);
16
+ }
17
+
18
+ const config = await buildPluginInspectorConfig({ pluginRoot });
19
+ await mkdir(path.dirname(configPath), { recursive: true });
20
+ await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
21
+ written.push(configPath);
22
+
23
+ if (options.ci === true) {
24
+ const workflowPath = path.resolve(pluginRoot, options.workflowPath ?? defaultInitWorkflowPath);
25
+ if (existsSync(workflowPath) && options.force !== true) {
26
+ throw new Error(`${path.relative(pluginRoot, workflowPath)} already exists; pass --force to overwrite it`);
27
+ }
28
+ await mkdir(path.dirname(workflowPath), { recursive: true });
29
+ await writeFile(workflowPath, renderGithubActionsWorkflow({ packageManager: options.packageManager }), "utf8");
30
+ written.push(workflowPath);
31
+ }
32
+
33
+ return { pluginRoot, configPath, written };
34
+ }
35
+
36
+ export async function buildPluginInspectorConfig(options = {}) {
37
+ const pluginRoot = path.resolve(options.pluginRoot ?? options.cwd ?? process.cwd());
38
+ const packageJson = await readJsonIfExists(path.join(pluginRoot, "package.json"));
39
+ const pluginManifest = await readJsonIfExists(path.join(pluginRoot, "openclaw.plugin.json"));
40
+ const sourceRoot = inferSourceRoot(packageJson);
41
+
42
+ const plugin = {
43
+ id: pluginManifest?.id ?? packageId(packageJson?.name) ?? "plugin",
44
+ priority: "high",
45
+ seams: inferPluginSeams(pluginManifest, packageJson),
46
+ };
47
+
48
+ if (sourceRoot !== ".") {
49
+ plugin.sourceRoot = sourceRoot;
50
+ }
51
+
52
+ return {
53
+ version: 1,
54
+ plugin,
55
+ capture: {
56
+ mockSdk: true,
57
+ },
58
+ };
59
+ }
60
+
61
+ export function renderGithubActionsWorkflow(options = {}) {
62
+ const packageManager = normalizePackageManager(options.packageManager);
63
+ const setup = packageManagerSetup(packageManager);
64
+
65
+ return `name: plugin-inspector
66
+
67
+ on:
68
+ pull_request:
69
+ push:
70
+ branches: [main]
71
+
72
+ jobs:
73
+ check:
74
+ runs-on: ubuntu-latest
75
+ steps:
76
+ - uses: actions/checkout@v5
77
+ - uses: actions/setup-node@v5
78
+ with:
79
+ node-version: 24
80
+ cache: ${setup.cache}
81
+ ${setup.corepack ? " - run: corepack enable\n" : ""} - run: ${setup.install}
82
+ - run: ${setup.exec} @openclaw/plugin-inspector check --no-openclaw
83
+ - run: PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 ${setup.exec} @openclaw/plugin-inspector check --no-openclaw --runtime --mock-sdk
84
+ - uses: actions/upload-artifact@v5
85
+ if: always()
86
+ with:
87
+ name: plugin-inspector-reports
88
+ path: reports/plugin-inspector-*
89
+ `;
90
+ }
91
+
92
+ function inferSourceRoot(packageJson) {
93
+ const entrypoints = [
94
+ packageJson?.openclaw?.entrypoint,
95
+ ...(packageJson?.openclaw?.extensions ?? []),
96
+ ...(packageJson?.openclaw?.runtimeExtensions ?? []),
97
+ ].filter((value) => typeof value === "string");
98
+ const entrypoint = entrypoints[0] ?? packageJson?.exports?.["."] ?? packageJson?.main ?? "src/index.js";
99
+ if (typeof entrypoint === "string" && entrypoint.startsWith("src/")) {
100
+ return "src";
101
+ }
102
+ return ".";
103
+ }
104
+
105
+ async function readJsonIfExists(filePath) {
106
+ if (!existsSync(filePath)) {
107
+ return null;
108
+ }
109
+ return JSON.parse(await readFile(filePath, "utf8"));
110
+ }
111
+
112
+ function normalizePackageManager(packageManager = "npm") {
113
+ if (["npm", "pnpm", "yarn", "bun"].includes(packageManager)) {
114
+ return packageManager;
115
+ }
116
+ throw new Error("--package-manager must be npm, pnpm, yarn, or bun");
117
+ }
118
+
119
+ function packageManagerSetup(packageManager) {
120
+ if (packageManager === "pnpm") {
121
+ return {
122
+ cache: "pnpm",
123
+ corepack: true,
124
+ install: "pnpm install --frozen-lockfile",
125
+ exec: "pnpm dlx",
126
+ };
127
+ }
128
+ if (packageManager === "yarn") {
129
+ return {
130
+ cache: "yarn",
131
+ corepack: true,
132
+ install: "yarn install --immutable",
133
+ exec: "yarn dlx",
134
+ };
135
+ }
136
+ if (packageManager === "bun") {
137
+ return {
138
+ cache: "npm",
139
+ corepack: false,
140
+ install: "bun install --frozen-lockfile",
141
+ exec: "bunx",
142
+ };
143
+ }
144
+ return {
145
+ cache: "npm",
146
+ corepack: false,
147
+ install: "npm ci",
148
+ exec: "npx",
149
+ };
150
+ }
package/src/inspector.js CHANGED
@@ -156,7 +156,12 @@ export async function captureEntrypoint(entrypoint, options = {}) {
156
156
  }
157
157
 
158
158
  const resolvedEntrypoint = path.resolve(options.cwd ?? process.cwd(), entrypoint);
159
- const module = await import(pathToFileURL(resolvedEntrypoint).href);
159
+ let module;
160
+ try {
161
+ module = await import(pathToFileURL(resolvedEntrypoint).href);
162
+ } catch (error) {
163
+ throw classifyCapturePhaseError(error, "entrypoint-import-error");
164
+ }
160
165
  const register = findRegisterExport(module);
161
166
 
162
167
  if (!register) {
@@ -168,7 +173,11 @@ export async function captureEntrypoint(entrypoint, options = {}) {
168
173
  }
169
174
 
170
175
  const api = createCaptureApi(options.apiOptions);
171
- await register(api);
176
+ try {
177
+ await register(api);
178
+ } catch (error) {
179
+ throw classifyCapturePhaseError(error, "registration-execution-error");
180
+ }
172
181
  const result = {
173
182
  status: "captured",
174
183
  entrypoint: resolvedEntrypoint,
@@ -188,19 +197,85 @@ export async function captureEntrypointWithMockSdk(entrypoint, options = {}) {
188
197
  pluginRoot: options.pluginRoot,
189
198
  apiOptions: options.apiOptions,
190
199
  };
191
- const { stdout } = await execFileAsync(
192
- process.execPath,
193
- ["--preserve-symlinks", runnerPath, JSON.stringify(payload)],
194
- {
195
- cwd: options.cwd ?? process.cwd(),
196
- env: {
197
- ...process.env,
198
- ...(options.env ?? {}),
200
+ try {
201
+ const { stdout } = await execFileAsync(
202
+ process.execPath,
203
+ ["--no-warnings", "--preserve-symlinks", runnerPath, JSON.stringify(payload)],
204
+ {
205
+ cwd: options.cwd ?? process.cwd(),
206
+ env: {
207
+ ...process.env,
208
+ ...(options.env ?? {}),
209
+ },
210
+ maxBuffer: 1024 * 1024 * 10,
199
211
  },
200
- maxBuffer: 1024 * 1024 * 10,
201
- },
202
- );
203
- return JSON.parse(stdout);
212
+ );
213
+ return JSON.parse(stdout);
214
+ } catch (error) {
215
+ throw classifyMockSdkCaptureError(error);
216
+ }
217
+ }
218
+
219
+ export function classifyMockSdkCaptureError(error) {
220
+ const rawMessage = [error?.stderr, error?.stdout, error?.message].filter(Boolean).join("\n");
221
+ const missingExport = rawMessage.match(/does not provide an export named ['"]([^'"]+)['"]/)?.[1];
222
+ if (missingExport) {
223
+ return enrichCaptureError(error, {
224
+ message: `Mock SDK import failed: openclaw/plugin-sdk is missing export ${missingExport}`,
225
+ failureClass: "missing-sdk-export",
226
+ missingExport,
227
+ });
228
+ }
229
+
230
+ const missingModule =
231
+ rawMessage.match(/Cannot find (?:package|module) ['"]([^'"]*openclaw\/plugin-sdk[^'"]*)['"]/)?.[1] ??
232
+ rawMessage.match(/Package subpath ['"](\.\/plugin-sdk\/[^'"]+)['"]/)?.[1];
233
+ if (missingModule || rawMessage.includes("openclaw/plugin-sdk")) {
234
+ return enrichCaptureError(error, {
235
+ message: `Mock SDK import failed: ${missingModule ?? "openclaw/plugin-sdk module could not be resolved"}`,
236
+ failureClass: "missing-sdk-module",
237
+ missingModule,
238
+ });
239
+ }
240
+
241
+ const failureClass = rawMessage.match(/\[plugin-inspector:([^\]]+)\]/)?.[1];
242
+ if (failureClass) {
243
+ return enrichCaptureError(error, {
244
+ message: firstMeaningfulErrorLine(rawMessage.replace(/\[plugin-inspector:[^\]]+\]/, "")) ?? "Mock SDK capture failed",
245
+ failureClass,
246
+ });
247
+ }
248
+
249
+ return enrichCaptureError(error, {
250
+ message: firstMeaningfulErrorLine(rawMessage) ?? "Mock SDK capture failed",
251
+ failureClass: "mock-sdk-capture-error",
252
+ });
253
+ }
254
+
255
+ export function classifyCapturePhaseError(error, failureClass) {
256
+ return enrichCaptureError(error, {
257
+ message: error instanceof Error ? error.message : String(error),
258
+ failureClass,
259
+ });
260
+ }
261
+
262
+ function enrichCaptureError(error, details) {
263
+ const wrapped = new Error(details.message, { cause: error });
264
+ wrapped.failureClass = details.failureClass;
265
+ if (details.missingExport) {
266
+ wrapped.missingExport = details.missingExport;
267
+ }
268
+ if (details.missingModule) {
269
+ wrapped.missingModule = details.missingModule;
270
+ }
271
+ return wrapped;
272
+ }
273
+
274
+ function firstMeaningfulErrorLine(message) {
275
+ return String(message)
276
+ .split("\n")
277
+ .map((line) => line.trim())
278
+ .find((line) => line && !line.startsWith("Command failed:"));
204
279
  }
205
280
 
206
281
  function findRegisterExport(module) {
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { mkdtemp, rm, symlink } from "node:fs/promises";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { register } from "node:module";
3
4
  import os from "node:os";
4
5
  import path from "node:path";
5
6
  import { pathToFileURL } from "node:url";
@@ -7,12 +8,16 @@ import { createCaptureApi } from "./capture-api.js";
7
8
  import { createMockSdkPackage } from "./sdk-mock.js";
8
9
 
9
10
  const options = JSON.parse(process.argv[2] ?? "{}");
11
+ let activeOutputCapture = null;
10
12
 
11
13
  try {
12
14
  const result = await run(options);
13
- process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
15
+ writeRunnerStdout(`${JSON.stringify(result, null, 2)}\n`);
14
16
  } catch (error) {
15
- process.stderr.write(`${error.stack ?? error.message}\n`);
17
+ if (error.failureClass) {
18
+ writeRunnerStderr(`[plugin-inspector:${error.failureClass}]\n`);
19
+ }
20
+ writeRunnerStderr(`${error.stack ?? error.message}\n`);
16
21
  process.exitCode = 1;
17
22
  }
18
23
 
@@ -22,37 +27,79 @@ async function run(options) {
22
27
  const workspace = await mkdtemp(path.join(os.tmpdir(), "plugin-inspector-mock-sdk-"));
23
28
 
24
29
  try {
25
- await createMockSdkPackage(workspace);
26
- const linkedPluginRoot = path.join(workspace, "plugin");
27
- await symlink(pluginRoot, linkedPluginRoot, "junction");
28
- const linkedEntrypoint = path.join(linkedPluginRoot, path.relative(pluginRoot, entrypoint));
29
- return await captureLinkedEntrypoint(linkedEntrypoint, options);
30
+ const { loaderPath } = await createMockSdkPackage(workspace, { pluginRoot });
31
+ register(pathToFileURL(loaderPath));
32
+ return await captureLinkedEntrypoint(entrypoint, options);
30
33
  } finally {
31
34
  await rm(workspace, { force: true, recursive: true });
32
35
  }
33
36
  }
34
37
 
35
38
  async function captureLinkedEntrypoint(entrypoint, options) {
36
- const module = await import(pathToFileURL(entrypoint).href);
39
+ const outputCapture = installProcessOutputCapture();
40
+ activeOutputCapture = outputCapture;
41
+
42
+ let module;
43
+ try {
44
+ module = await import(pathToFileURL(entrypoint).href);
45
+ } catch (error) {
46
+ await drainAsyncOutput();
47
+ throw capturePhaseError(error, "entrypoint-import-error");
48
+ }
37
49
  const register = findRegisterExport(module);
38
50
 
39
51
  if (!register) {
40
- return {
41
- status: "no-register-export",
42
- entrypoint: options.entrypoint,
43
- mockSdk: true,
44
- captured: [],
45
- };
52
+ await drainAsyncOutput();
53
+ return withProcessOutput(
54
+ {
55
+ status: "no-register-export",
56
+ entrypoint: options.entrypoint,
57
+ mockSdk: true,
58
+ captured: [],
59
+ },
60
+ outputCapture,
61
+ );
46
62
  }
47
63
 
48
64
  const api = createCaptureApi(options.apiOptions);
49
- await register(api);
50
- return {
65
+ try {
66
+ await register(api);
67
+ } catch (error) {
68
+ await drainAsyncOutput();
69
+ throw capturePhaseError(error, "registration-execution-error");
70
+ }
71
+ await drainAsyncOutput();
72
+
73
+ const result = {
51
74
  status: "captured",
52
75
  entrypoint: options.entrypoint,
53
76
  mockSdk: true,
54
77
  captured: api.getCapturedContracts(),
55
78
  };
79
+ if (options.apiOptions?.retainHandlers === true) {
80
+ result.retained = api.getRetainedContracts();
81
+ }
82
+ return withProcessOutput(result, outputCapture);
83
+ }
84
+
85
+ function withProcessOutput(result, outputCapture) {
86
+ const stdout = outputCapture.stdout();
87
+ const stderr = outputCapture.stderr();
88
+ if (stdout.length === 0 && stderr.length === 0) {
89
+ return result;
90
+ }
91
+ return {
92
+ ...result,
93
+ processOutput: {
94
+ stdout,
95
+ stderr,
96
+ },
97
+ };
98
+ }
99
+
100
+ function capturePhaseError(error, failureClass) {
101
+ error.failureClass = failureClass;
102
+ return error;
56
103
  }
57
104
 
58
105
  function findRegisterExport(module) {
@@ -67,3 +114,49 @@ function findRegisterExport(module) {
67
114
  }
68
115
  return null;
69
116
  }
117
+
118
+ function installProcessOutputCapture() {
119
+ const stdoutChunks = [];
120
+ const stderrChunks = [];
121
+ const originalStdoutWrite = process.stdout.write.bind(process.stdout);
122
+ const originalStderrWrite = process.stderr.write.bind(process.stderr);
123
+
124
+ process.stdout.write = (chunk, encoding, callback) => {
125
+ stdoutChunks.push(Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk));
126
+ invokeWriteCallback(encoding, callback);
127
+ return true;
128
+ };
129
+ process.stderr.write = (chunk, encoding, callback) => {
130
+ stderrChunks.push(Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk));
131
+ invokeWriteCallback(encoding, callback);
132
+ return true;
133
+ };
134
+
135
+ return {
136
+ originalStdoutWrite,
137
+ originalStderrWrite,
138
+ stdout: () => stdoutChunks.join(""),
139
+ stderr: () => stderrChunks.join(""),
140
+ };
141
+ }
142
+
143
+ function invokeWriteCallback(encoding, callback) {
144
+ if (typeof encoding === "function") {
145
+ encoding();
146
+ } else if (typeof callback === "function") {
147
+ callback();
148
+ }
149
+ }
150
+
151
+ async function drainAsyncOutput() {
152
+ await new Promise((resolve) => setTimeout(resolve, 0));
153
+ await new Promise((resolve) => setImmediate(resolve));
154
+ }
155
+
156
+ function writeRunnerStdout(text) {
157
+ (activeOutputCapture?.originalStdoutWrite ?? process.stdout.write.bind(process.stdout))(text);
158
+ }
159
+
160
+ function writeRunnerStderr(text) {
161
+ (activeOutputCapture?.originalStderrWrite ?? process.stderr.write.bind(process.stderr))(text);
162
+ }
@@ -71,13 +71,20 @@ export function renderRuntimeCaptureMarkdown(captureReport, options = {}) {
71
71
  result.fixture,
72
72
  result.status,
73
73
  result.entrypoint,
74
- (result.captured ?? []).map((item) => `${item.kind}:${item.name}`).join(", ") || result.error || "-",
74
+ (result.captured ?? []).map((item) => `${item.kind}:${item.name}`).join(", ") || formatCaptureError(result),
75
75
  ]),
76
76
  ["Fixture", "Status", "Entrypoint", "Captured"],
77
77
  ),
78
78
  ].join("\n");
79
79
  }
80
80
 
81
+ function formatCaptureError(result) {
82
+ if (!result.error) {
83
+ return "-";
84
+ }
85
+ return result.failureClass ? `${result.failureClass}: ${result.error}` : result.error;
86
+ }
87
+
81
88
  function captureTargets(fixture, rootDir) {
82
89
  return fixture.packages.flatMap((packageSummary) => {
83
90
  const packageRoot = path.dirname(path.resolve(rootDir, packageSummary.path));
@@ -124,6 +131,9 @@ async function captureTarget(target, options) {
124
131
  packagePath: target.packagePath,
125
132
  entrypoint: target.entrypoint.relativePath,
126
133
  error: error.message,
134
+ ...(error.failureClass ? { failureClass: error.failureClass } : {}),
135
+ ...(error.missingExport ? { missingExport: error.missingExport } : {}),
136
+ ...(error.missingModule ? { missingModule: error.missingModule } : {}),
127
137
  captured: [],
128
138
  };
129
139
  }