@omercnet/paseo-context-mode 3.1.3 → 3.1.4-next.434.1

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/server/binary.ts CHANGED
@@ -1,8 +1,10 @@
1
+ import { execFile } from "node:child_process";
1
2
  import { constants } from "node:fs";
2
3
  import { access, readFile, realpath, stat } from "node:fs/promises";
3
4
  import { createRequire } from "node:module";
4
- import { join, posix, win32 } from "node:path";
5
- import type { ContextModeSettings } from "../shared";
5
+ import { dirname, join, win32 } from "node:path";
6
+ import { promisify } from "node:util";
7
+ import type { ContextModeSettings } from "../shared/settings";
6
8
  import type { ProviderConfigSource } from "./integration";
7
9
 
8
10
  export interface LaunchDescriptor {
@@ -15,18 +17,18 @@ export type BinaryResolution =
15
17
  | {
16
18
  state: "found";
17
19
  path: string;
18
- source: "settings" | "path" | "bundled";
20
+ source: "settings" | "bundled";
19
21
  launch: LaunchDescriptor;
20
22
  }
21
23
  | { state: "missing"; code: "not-installed" | "unsupported"; message: string };
22
24
 
23
25
  export interface BinaryResolutionDependencies {
24
- env?: NodeJS.ProcessEnv;
25
26
  platform?: NodeJS.Platform;
26
27
  nodePath?: string;
27
28
  electron?: boolean;
28
29
  canExecute?: (path: string) => Promise<boolean>;
29
30
  isFile?: (path: string) => Promise<boolean>;
31
+ readVersion?: (launch: LaunchDescriptor) => Promise<string | null>;
30
32
  paseo?: ProviderConfigSource;
31
33
  }
32
34
 
@@ -44,7 +46,9 @@ async function findBundledCli(paseo: ProviderConfigSource | undefined): Promise<
44
46
  try {
45
47
  const manifest = JSON.parse(await readFile(join(directory, "paseo-plugin.json"), "utf8"));
46
48
  if (manifest?.id !== "context-mode") continue;
47
- return createRequire(join(directory, "package.json")).resolve("context-mode/cli");
49
+ // The package exports only its OpenCode plugin (`<root>/opencode/plugin.js`); the CLI sits at the root.
50
+ const main = createRequire(join(directory, "package.json")).resolve("context-mode");
51
+ return join(dirname(dirname(main)), "cli.mjs");
48
52
  } catch {
49
53
  // Not this plugin, or its install has no bundled runtime.
50
54
  }
@@ -70,21 +74,6 @@ async function defaultCanExecute(path: string, platform: NodeJS.Platform): Promi
70
74
  }
71
75
  }
72
76
 
73
- function pathCandidates(
74
- name: string,
75
- directory: string,
76
- platform: NodeJS.Platform,
77
- env: NodeJS.ProcessEnv,
78
- ) {
79
- const pathApi = platform === "win32" ? win32 : posix;
80
- if (platform !== "win32") return [pathApi.join(directory, name)];
81
- const extensions = (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean);
82
- return [
83
- pathApi.join(directory, name),
84
- ...extensions.map((extension) => pathApi.join(directory, `${name}${extension}`)),
85
- ];
86
- }
87
-
88
77
  async function windowsLaunchDescriptor(
89
78
  candidate: string,
90
79
  nodePath: string,
@@ -100,10 +89,8 @@ async function windowsLaunchDescriptor(
100
89
 
101
90
  const directory = win32.dirname(candidate);
102
91
  const moduleCandidates = [
103
- win32.join(directory, "node_modules", "context-mode", "cli.bundle.mjs"),
104
- win32.join(directory, "node_modules", "context-mode", "build", "cli.js"),
105
- win32.join(directory, "..", "context-mode", "cli.bundle.mjs"),
106
- win32.join(directory, "..", "context-mode", "build", "cli.js"),
92
+ win32.join(directory, "node_modules", "context-mode", "cli.mjs"),
93
+ win32.join(directory, "..", "context-mode", "cli.mjs"),
107
94
  ];
108
95
  for (const modulePath of moduleCandidates) {
109
96
  if (await isFile(modulePath)) return { program: nodePath, args: [modulePath] };
@@ -111,93 +98,131 @@ async function windowsLaunchDescriptor(
111
98
  return null;
112
99
  }
113
100
 
101
+ /**
102
+ * The user's own binary when one is configured, else the runtime bundled with the plugin. A
103
+ * configured binary that cannot run is reported, never silently replaced by the bundled one.
104
+ */
114
105
  export async function resolveContextModeBinary(
115
106
  settings: ContextModeSettings,
116
107
  dependencies: BinaryResolutionDependencies = {},
117
108
  ): Promise<BinaryResolution> {
118
- const env = dependencies.env ?? process.env;
119
109
  const platform = dependencies.platform ?? process.platform;
120
- const pathApi = platform === "win32" ? win32 : posix;
121
110
  const nodePath = dependencies.nodePath ?? process.execPath;
122
- const canExecute =
123
- dependencies.canExecute ?? ((path: string) => defaultCanExecute(path, platform));
124
- const isFile = dependencies.isFile ?? defaultIsFile;
125
- const configuredPath = settings.binaryPath.trim();
126
- let unsupportedShim: string | null = null;
127
-
128
- async function resolveCandidate(
129
- candidate: string,
130
- source: "settings" | "path",
131
- ): Promise<Extract<BinaryResolution, { state: "found" }> | null> {
132
- if (!(await canExecute(candidate))) return null;
133
- const canonicalPath = await realpath(candidate).catch(() => candidate);
134
- if (platform !== "win32") {
135
- return {
136
- state: "found",
137
- path: canonicalPath,
138
- source,
139
- launch: { program: canonicalPath, args: [] },
140
- };
141
- }
142
- const launch = await windowsLaunchDescriptor(canonicalPath, nodePath, isFile);
143
- if (!launch) {
144
- unsupportedShim = canonicalPath;
145
- return null;
146
- }
147
- return { state: "found", path: canonicalPath, source, launch };
111
+ if (settings.binaryPath !== null) {
112
+ return resolveConfiguredBinary(settings.binaryPath, {
113
+ platform,
114
+ nodePath,
115
+ canExecute: dependencies.canExecute ?? ((path: string) => defaultCanExecute(path, platform)),
116
+ isFile: dependencies.isFile ?? defaultIsFile,
117
+ readVersion: dependencies.readVersion ?? readContextModeVersion,
118
+ });
148
119
  }
149
120
 
150
- if (settings.binaryMode === "path" && configuredPath.length > 0) {
151
- if (!pathApi.isAbsolute(configuredPath)) {
152
- return {
153
- state: "missing",
154
- code: "not-installed",
155
- message: "The configured Context Mode binary path is not absolute.",
156
- };
157
- }
158
- const configured = await resolveCandidate(configuredPath, "settings");
159
- if (configured) return configured;
121
+ const bundledCli = await findBundledCli(dependencies.paseo);
122
+ if (!bundledCli) {
123
+ return {
124
+ state: "missing",
125
+ code: "not-installed",
126
+ message:
127
+ "The bundled Context Mode runtime is missing from this plugin install. Reinstall the plugin or configure your own binary.",
128
+ };
160
129
  }
130
+ const canonicalPath = await realpath(bundledCli).catch(() => bundledCli);
131
+ // Under the desktop app `nodePath` is Electron. Paseo strips this variable from agent
132
+ // environments, so an injected MCP server needs it to start as Node.
133
+ const electron = dependencies.electron ?? process.versions.electron !== undefined;
134
+ return {
135
+ state: "found",
136
+ path: canonicalPath,
137
+ source: "bundled",
138
+ launch: {
139
+ program: nodePath,
140
+ args: [canonicalPath],
141
+ ...(electron ? { env: { ELECTRON_RUN_AS_NODE: "1" } } : {}),
142
+ },
143
+ };
144
+ }
161
145
 
162
- for (const directory of (env.PATH ?? "").split(pathApi.delimiter).filter(Boolean)) {
163
- for (const candidate of pathCandidates("context-mode", directory, platform, env)) {
164
- const resolved = await resolveCandidate(candidate, "path");
165
- if (resolved) return resolved;
166
- }
167
- }
146
+ interface ConfiguredBinaryChecks {
147
+ platform: NodeJS.Platform;
148
+ nodePath: string;
149
+ canExecute: (path: string) => Promise<boolean>;
150
+ isFile: (path: string) => Promise<boolean>;
151
+ readVersion: (launch: LaunchDescriptor) => Promise<string | null>;
152
+ }
168
153
 
169
- const bundledCli = await findBundledCli(dependencies.paseo);
170
- if (bundledCli) {
171
- const canonicalPath = await realpath(bundledCli).catch(() => bundledCli);
172
- // Under the desktop app `nodePath` is Electron. Paseo strips this variable from agent
173
- // environments, so an injected MCP server needs it to start as Node.
174
- const electron = dependencies.electron ?? process.versions.electron !== undefined;
154
+ async function resolveConfiguredBinary(
155
+ configuredPath: string,
156
+ checks: ConfiguredBinaryChecks,
157
+ ): Promise<BinaryResolution> {
158
+ if (!(await checks.canExecute(configuredPath))) {
175
159
  return {
176
- state: "found",
177
- path: canonicalPath,
178
- source: "bundled",
179
- launch: {
180
- program: nodePath,
181
- args: [canonicalPath],
182
- ...(electron ? { env: { ELECTRON_RUN_AS_NODE: "1" } } : {}),
183
- },
160
+ state: "missing",
161
+ code: "not-installed",
162
+ message: `The configured Context Mode binary (${configuredPath}) is missing or not executable.`,
184
163
  };
185
164
  }
186
-
187
- if (unsupportedShim) {
165
+ const canonicalPath = await realpath(configuredPath).catch(() => configuredPath);
166
+ const launch =
167
+ checks.platform === "win32"
168
+ ? await windowsLaunchDescriptor(canonicalPath, checks.nodePath, checks.isFile)
169
+ : { program: canonicalPath, args: [] };
170
+ if (!launch) {
188
171
  return {
172
+ state: "missing",
189
173
  code: "unsupported",
174
+ message: `The configured Context Mode binary (${canonicalPath}) is a Windows shim whose CLI module could not be found. Configure the absolute cli.mjs path instead.`,
175
+ };
176
+ }
177
+ // 1.x starts a server but lacks the 2.x tool contracts the plugin and injected agents rely on.
178
+ const version = await checks.readVersion(launch);
179
+ if (version !== null && Number.parseInt(version, 10) < 2) {
180
+ return {
190
181
  state: "missing",
191
- message: `Context Mode was found at ${unsupportedShim}, but this Windows shim could not be safely resolved to its CLI module. Reinstall Context Mode with npm or configure the absolute cli.bundle.mjs path.`,
182
+ code: "unsupported",
183
+ message: `The configured Context Mode binary is version ${version}; Context Mode 2 is required. Point it at a 2.x install or switch back to the bundled runtime.`,
192
184
  };
193
185
  }
194
- const configuredDetail =
195
- settings.binaryMode === "path" && configuredPath.length > 0
196
- ? ` The configured path (${configuredPath}) was unavailable, and no fallback was found on PATH.`
197
- : "";
198
- return {
199
- code: "not-installed",
200
- state: "missing",
201
- message: `Context Mode is not installed or is not executable on this Paseo host.${configuredDetail}`,
202
- };
186
+ return { state: "found", path: canonicalPath, source: "settings", launch };
187
+ }
188
+
189
+ const VERSION_PATTERN = /\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/;
190
+ /** `--version` results per entry file; status, audit and every RPC resolve the binary again. */
191
+ const cliVersions = new Map<string, { mtimeMs: number; version: string | null }>();
192
+
193
+ /**
194
+ * The MCP handshake reports `dev` for npm builds, so the version comes from the package.json beside
195
+ * the CLI entry when it is a Context Mode install, else from `context-mode --version`, which only
196
+ * runs again once the entry file changes.
197
+ */
198
+ export async function readContextModeVersion(
199
+ launch: LaunchDescriptor,
200
+ timeoutMs = 5_000,
201
+ ): Promise<string | null> {
202
+ const entry = launch.args[0] ?? launch.program;
203
+ try {
204
+ const manifest = JSON.parse(await readFile(join(dirname(entry), "package.json"), "utf8"));
205
+ if (manifest?.name === "context-mode" && typeof manifest.version === "string") {
206
+ return manifest.version.slice(0, 128);
207
+ }
208
+ } catch {
209
+ // Not a package directory; ask the CLI.
210
+ }
211
+ const mtimeMs = (await stat(entry).catch(() => null))?.mtimeMs;
212
+ const cached = cliVersions.get(entry);
213
+ if (cached && cached.mtimeMs === mtimeMs) return cached.version;
214
+ let version: string | null = null;
215
+ try {
216
+ const { stdout } = await promisify(execFile)(launch.program, [...launch.args, "--version"], {
217
+ timeout: timeoutMs,
218
+ windowsHide: true,
219
+ env: launch.env ? { ...process.env, ...launch.env } : undefined,
220
+ });
221
+ version = VERSION_PATTERN.exec(stdout)?.[0] ?? null;
222
+ } catch {
223
+ // Unknown version: not treated as 1.x.
224
+ }
225
+ // An unknown version is not cached: a timed-out probe must not let a 1.x binary past the gate.
226
+ if (mtimeMs !== undefined && version !== null) cliVersions.set(entry, { mtimeMs, version });
227
+ return version;
203
228
  }