@cloudgrid-io/mcp 0.7.0 → 0.7.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/package.json +2 -1
- package/src/cli-shim.mjs +49 -0
- package/src/tools.js +251 -23
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloudgrid-io/mcp",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "MCP server for CloudGrid. Three editions: a local stdio server (full toolset), a hosted web server (light, CLI-free toolset), and a public docs server (search_cloudgrid_documentation) over MCP Streamable HTTP.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"smoke:oauth": "node test/smoke-oauth.mjs",
|
|
27
27
|
"test:auth": "node test/auth.test.mjs",
|
|
28
28
|
"test:win-cli": "node test/windows-cli-resolution.test.mjs",
|
|
29
|
+
"test:electron-env": "node test/electron-spawn-env.test.mjs",
|
|
29
30
|
"smoke:docs": "node test/smoke-docs.mjs",
|
|
30
31
|
"start:docs": "node src/docs.js",
|
|
31
32
|
"snapshot:corpus": "node scripts/snapshot-corpus.mjs",
|
package/src/cli-shim.mjs
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// cli-shim.mjs — runs the bundled CloudGrid CLI as a child of the MCP server.
|
|
3
|
+
//
|
|
4
|
+
// <runtime> cli-shim.mjs <cli-entry> [cli args...]
|
|
5
|
+
//
|
|
6
|
+
// Why the MCP server spawns this shim instead of the CLI entry directly:
|
|
7
|
+
//
|
|
8
|
+
// Under Electron MCP hosts (Claude Desktop runs extensions inside an Electron
|
|
9
|
+
// utility process) `process.execPath` is the host's Electron binary, not Node.
|
|
10
|
+
// The server spawns the CLI with ELECTRON_RUN_AS_NODE=1 so an Electron runtime
|
|
11
|
+
// boots as Node — but that mode still exposes `process.versions.electron`, and
|
|
12
|
+
// commander (the CLI's argument parser) detects it: with `process.defaultApp`
|
|
13
|
+
// undefined it reads user arguments from `argv.slice(1)`, so the CLI entry path
|
|
14
|
+
// itself would be parsed as the command ("error: unknown command '/…/cli/dist/
|
|
15
|
+
// index.js'"). Under plain Node commander reads `argv.slice(2)` instead. This
|
|
16
|
+
// shim rewrites process.argv so BOTH slicings see exactly the user arguments,
|
|
17
|
+
// then imports the real CLI entry in-process.
|
|
18
|
+
//
|
|
19
|
+
// It also strips ELECTRON_RUN_AS_NODE from the environment: the variable has
|
|
20
|
+
// done its job once this file is executing, and letting it leak further would
|
|
21
|
+
// break anything Electron-based the CLI spawns later (e.g. a browser opened
|
|
22
|
+
// for `cloudgrid login` would silently boot as a headless Node process).
|
|
23
|
+
|
|
24
|
+
import { pathToFileURL } from "node:url";
|
|
25
|
+
|
|
26
|
+
delete process.env.ELECTRON_RUN_AS_NODE;
|
|
27
|
+
|
|
28
|
+
const [, , entry, ...args] = process.argv;
|
|
29
|
+
if (!entry) {
|
|
30
|
+
console.error("cli-shim: missing CLI entry path");
|
|
31
|
+
process.exit(2);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (process.versions?.electron && !process.defaultApp) {
|
|
35
|
+
// Electron-as-Node: commander takes user args from argv.slice(1) — the entry
|
|
36
|
+
// path must NOT appear there.
|
|
37
|
+
process.argv = [process.argv[0], ...args];
|
|
38
|
+
} else {
|
|
39
|
+
// Plain Node: commander takes the script path from argv[1] and user args
|
|
40
|
+
// from argv.slice(2) — the standard `node script.js args...` shape.
|
|
41
|
+
process.argv = [process.argv[0], entry, ...args];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
await import(pathToFileURL(entry).href);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
console.error(err?.stack || String(err));
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
package/src/tools.js
CHANGED
|
@@ -13,6 +13,7 @@ import { promisify } from "node:util";
|
|
|
13
13
|
import { readFile } from "node:fs/promises";
|
|
14
14
|
import { readFileSync, existsSync, statSync, readdirSync } from "node:fs";
|
|
15
15
|
import { basename, dirname, resolve, join } from "node:path";
|
|
16
|
+
import { homedir } from "node:os";
|
|
16
17
|
import { fileURLToPath } from "node:url";
|
|
17
18
|
import { z } from "zod";
|
|
18
19
|
import { newLoginCode, buildLoginUrl, pollStatusOnce, decodeJwt } from "./auth.js";
|
|
@@ -293,16 +294,202 @@ function winQuoteArg(arg) {
|
|
|
293
294
|
}
|
|
294
295
|
|
|
295
296
|
// execFile a command that may be a Windows `.cmd` shim, safely on every OS.
|
|
296
|
-
function execMaybeCmd(command, args, options) {
|
|
297
|
-
if (!IS_WIN) return
|
|
297
|
+
function execMaybeCmd(command, args, options, exec = execFileAsync) {
|
|
298
|
+
if (!IS_WIN) return exec(command, args, options);
|
|
298
299
|
const line = [command, ...args].map(winQuoteArg).join(" ");
|
|
299
|
-
return
|
|
300
|
+
return exec(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", line], {
|
|
300
301
|
...options,
|
|
301
302
|
windowsVerbatimArguments: true,
|
|
302
303
|
});
|
|
303
304
|
}
|
|
304
305
|
|
|
305
|
-
|
|
306
|
+
// ── Electron-safe Node runtime resolution ─────────────────────────────────────
|
|
307
|
+
//
|
|
308
|
+
// Claude Desktop (and other Electron MCP hosts) run this server inside an
|
|
309
|
+
// Electron utility process, so `process.execPath` is the host's Electron helper
|
|
310
|
+
// binary — NOT a Node interpreter. Spawning it as if it were Node either dies
|
|
311
|
+
// with FATAL "Unable to find helper app" (Claude Desktop's helper binary) or
|
|
312
|
+
// boots a GUI Electron app that hangs until the exec timeout and then reports a
|
|
313
|
+
// fake success. ELECTRON_RUN_AS_NODE=1 only helps when the host's Electron
|
|
314
|
+
// build keeps the runAsNode fuse enabled — Claude Desktop's does NOT (verified:
|
|
315
|
+
// the helper FATALs identically with the variable set). So the bundled-CLI rung
|
|
316
|
+
// must (a) never blindly spawn process.execPath under Electron, (b) prefer a
|
|
317
|
+
// real Node binary probed from common install locations (the utility process
|
|
318
|
+
// PATH is a bare /usr/bin:/bin:/usr/sbin:/sbin on macOS), and (c) use
|
|
319
|
+
// execPath-as-node only after a quick verified run-as-node probe.
|
|
320
|
+
|
|
321
|
+
const CLI_SHIM = fileURLToPath(new URL("./cli-shim.mjs", import.meta.url));
|
|
322
|
+
|
|
323
|
+
const NO_NODE_HINT =
|
|
324
|
+
"No usable Node.js runtime found for spawning the CloudGrid CLI (this MCP " +
|
|
325
|
+
"host runs on Electron, whose binary cannot execute Node scripts). Install " +
|
|
326
|
+
"Node.js (https://nodejs.org) or the CloudGrid CLI (npm install -g " +
|
|
327
|
+
"@cloudgrid-io/cli) and try again.";
|
|
328
|
+
|
|
329
|
+
// True when `execPath` is a real Node interpreter we can hand a script to.
|
|
330
|
+
function execPathIsRealNode({ execPath, versions, platform }) {
|
|
331
|
+
if (versions?.electron) return false;
|
|
332
|
+
const name = basename(execPath).toLowerCase();
|
|
333
|
+
return platform === "win32" ? name === "node.exe" || name === "node" : name === "node";
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Pick the highest semver-looking entry of a directory (nvm/fnm version dirs).
|
|
337
|
+
function highestVersionEntry(dir, fsReaddir) {
|
|
338
|
+
try {
|
|
339
|
+
const versions = fsReaddir(dir)
|
|
340
|
+
.filter((d) => /^v?\d+\.\d+\.\d+$/.test(d))
|
|
341
|
+
.sort((a, b) => {
|
|
342
|
+
const pa = a.replace(/^v/, "").split(".").map(Number);
|
|
343
|
+
const pb = b.replace(/^v/, "").split(".").map(Number);
|
|
344
|
+
return pa[0] - pb[0] || pa[1] - pb[1] || pa[2] - pb[2];
|
|
345
|
+
});
|
|
346
|
+
return versions.pop() || null;
|
|
347
|
+
} catch {
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Candidate real-Node binaries, most likely first. GUI-launched Electron hosts
|
|
353
|
+
// get a bare PATH, so fixed install locations and version-manager dirs are
|
|
354
|
+
// probed explicitly.
|
|
355
|
+
function listNodeCandidates({ platform, env, home, fsReaddir }) {
|
|
356
|
+
const out = [];
|
|
357
|
+
const binName = platform === "win32" ? "node.exe" : "node";
|
|
358
|
+
for (const dir of (env.PATH || "").split(platform === "win32" ? ";" : ":")) {
|
|
359
|
+
if (dir) out.push(join(dir, binName));
|
|
360
|
+
}
|
|
361
|
+
if (platform === "win32") {
|
|
362
|
+
for (const base of [env.ProgramFiles, env["ProgramFiles(x86)"]]) {
|
|
363
|
+
if (base) out.push(join(base, "nodejs", "node.exe"));
|
|
364
|
+
}
|
|
365
|
+
if (env.NVM_SYMLINK) out.push(join(env.NVM_SYMLINK, "node.exe"));
|
|
366
|
+
if (env.LOCALAPPDATA) out.push(join(env.LOCALAPPDATA, "Volta", "bin", "node.exe"));
|
|
367
|
+
return out;
|
|
368
|
+
}
|
|
369
|
+
out.push("/usr/local/bin/node", "/opt/homebrew/bin/node", "/usr/bin/node", "/opt/local/bin/node");
|
|
370
|
+
if (home) {
|
|
371
|
+
out.push(join(home, ".volta", "bin", "node"));
|
|
372
|
+
const nvmVersions = join(env.NVM_DIR || join(home, ".nvm"), "versions", "node");
|
|
373
|
+
const nvmBest = highestVersionEntry(nvmVersions, fsReaddir);
|
|
374
|
+
if (nvmBest) out.push(join(nvmVersions, nvmBest, "bin", "node"));
|
|
375
|
+
for (const base of [
|
|
376
|
+
env.FNM_DIR,
|
|
377
|
+
join(home, ".local", "share", "fnm"),
|
|
378
|
+
join(home, "Library", "Application Support", "fnm"),
|
|
379
|
+
join(home, ".fnm"),
|
|
380
|
+
]) {
|
|
381
|
+
if (!base) continue;
|
|
382
|
+
out.push(join(base, "aliases", "default", "bin", "node"));
|
|
383
|
+
const fnmVersions = join(base, "node-versions");
|
|
384
|
+
const fnmBest = highestVersionEntry(fnmVersions, fsReaddir);
|
|
385
|
+
if (fnmBest) out.push(join(fnmVersions, fnmBest, "installation", "bin", "node"));
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return out;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Verify a candidate actually behaves like Node (guards against broken shims
|
|
392
|
+
// and against Electron's fake-success mode where a timed-out GUI boot exits 0).
|
|
393
|
+
async function verifyNodeRuns(exec, command, env) {
|
|
394
|
+
try {
|
|
395
|
+
const { stdout } = await exec(command, ["-p", "process.version"], {
|
|
396
|
+
timeout: 5_000,
|
|
397
|
+
env,
|
|
398
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
399
|
+
});
|
|
400
|
+
return /^v\d+\./.test(String(stdout).trim());
|
|
401
|
+
} catch {
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Resolve the Node runtime used to spawn the bundled CLI. Returns
|
|
407
|
+
// { command, kind } or null when no usable runtime exists.
|
|
408
|
+
//
|
|
409
|
+
// The `deps` seam exists ONLY so the Electron regression tests can drive the
|
|
410
|
+
// Electron code paths deterministically on a plain-Node CI box. Production
|
|
411
|
+
// (runCloudgrid) only ever injects the real spawner, so the result is memoized.
|
|
412
|
+
let runtimeCache; // undefined = not yet resolved; null = resolved to "none"
|
|
413
|
+
export async function resolveNodeRuntime(deps = {}) {
|
|
414
|
+
// Cache unless the environment itself is simulated (`exec` is just the
|
|
415
|
+
// spawner) — probing spawns children, and the answer cannot change within
|
|
416
|
+
// one server process.
|
|
417
|
+
const cacheable = Object.keys(deps).every((k) => k === "exec");
|
|
418
|
+
if (cacheable && runtimeCache !== undefined) return runtimeCache;
|
|
419
|
+
const {
|
|
420
|
+
exec = execFileAsync,
|
|
421
|
+
execPath = process.execPath,
|
|
422
|
+
versions = process.versions,
|
|
423
|
+
platform = process.platform,
|
|
424
|
+
env = process.env,
|
|
425
|
+
home = homedir(),
|
|
426
|
+
fsExists = existsSync,
|
|
427
|
+
fsReaddir = readdirSync,
|
|
428
|
+
} = deps;
|
|
429
|
+
|
|
430
|
+
let result = null;
|
|
431
|
+
if (execPathIsRealNode({ execPath, versions, platform })) {
|
|
432
|
+
result = { command: execPath, kind: "exec-path" };
|
|
433
|
+
} else {
|
|
434
|
+
// 1. A real Node binary somewhere on this machine.
|
|
435
|
+
for (const candidate of listNodeCandidates({ platform, env, home, fsReaddir })) {
|
|
436
|
+
if (!fsExists(candidate)) continue;
|
|
437
|
+
if (await verifyNodeRuns(exec, candidate, env)) {
|
|
438
|
+
result = { command: candidate, kind: "system-node" };
|
|
439
|
+
break;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
// 2. Last resort: execPath with ELECTRON_RUN_AS_NODE — only if a quick
|
|
443
|
+
// probe proves the host's runAsNode fuse is enabled (Claude Desktop's
|
|
444
|
+
// is not; vanilla Electron's is).
|
|
445
|
+
if (!result) {
|
|
446
|
+
const probeEnv = { ...env, ELECTRON_RUN_AS_NODE: "1" };
|
|
447
|
+
if (await verifyNodeRuns(exec, execPath, probeEnv)) {
|
|
448
|
+
result = { command: execPath, kind: "electron-run-as-node" };
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
if (cacheable) runtimeCache = result;
|
|
453
|
+
return result;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// A step-1 error that means "the runtime never ran the CLI" (fall through to
|
|
457
|
+
// the global-CLI/npx rungs) as opposed to "the CLI ran and reported an error"
|
|
458
|
+
// (surface it).
|
|
459
|
+
function isRuntimeBootFailure(err) {
|
|
460
|
+
if (!err) return false;
|
|
461
|
+
if (err.code === "ENOENT" || err.code === "EACCES") return true;
|
|
462
|
+
const text = `${err.stderr || ""}\n${err.message || ""}`;
|
|
463
|
+
return text.includes("Unable to find helper app") || /FATAL:.*electron/i.test(text);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Environment for the global-CLI and npx fallback rungs. GUI-launched Electron
|
|
467
|
+
// hosts hand utility processes a bare PATH (macOS: /usr/bin:/bin:/usr/sbin:/sbin)
|
|
468
|
+
// that misses Homebrew/npm bin dirs, so `cloudgrid`/`npx` would be ENOENT even
|
|
469
|
+
// when installed — append the usual install locations. ELECTRON_RUN_AS_NODE is
|
|
470
|
+
// stripped so it cannot leak through a real-node CLI into anything it spawns.
|
|
471
|
+
function pathAugmentedEnv(env = process.env, platform = process.platform, home = homedir()) {
|
|
472
|
+
const out = { ...env };
|
|
473
|
+
delete out.ELECTRON_RUN_AS_NODE;
|
|
474
|
+
if (platform === "win32") return out;
|
|
475
|
+
const extras = ["/usr/local/bin", "/opt/homebrew/bin", join(home, ".volta", "bin"), join(home, ".local", "bin")];
|
|
476
|
+
const parts = (out.PATH || "").split(":").filter(Boolean);
|
|
477
|
+
for (const dir of extras) {
|
|
478
|
+
if (!parts.includes(dir)) parts.push(dir);
|
|
479
|
+
}
|
|
480
|
+
out.PATH = parts.join(":");
|
|
481
|
+
return out;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// The `deps` seam mirrors resolveBundledCli's: it exists ONLY so regression
|
|
485
|
+
// tests can spy the exec calls and simulate Electron hosts deterministically.
|
|
486
|
+
// Production always calls this with no deps.
|
|
487
|
+
export async function runCloudgrid(args, opts = {}, deps = {}) {
|
|
488
|
+
const {
|
|
489
|
+
exec = execFileAsync,
|
|
490
|
+
resolveCli = resolveBundledCli,
|
|
491
|
+
resolveRuntime = resolveNodeRuntime,
|
|
492
|
+
} = deps;
|
|
306
493
|
const cwd = resolveCwd(opts.cwd);
|
|
307
494
|
const execOpts = {
|
|
308
495
|
maxBuffer: 16 * 1024 * 1024,
|
|
@@ -310,6 +497,11 @@ async function runCloudgrid(args, opts = {}) {
|
|
|
310
497
|
stdio: ["ignore", "pipe", "pipe"],
|
|
311
498
|
...(cwd ? { cwd } : {}),
|
|
312
499
|
};
|
|
500
|
+
// PATH-augmented, ELECTRON_RUN_AS_NODE-free env for the fallback rungs.
|
|
501
|
+
const fallbackEnv = pathAugmentedEnv();
|
|
502
|
+
// Set when the bundled CLI had to be skipped for lack of a Node runtime, so
|
|
503
|
+
// the final error names the real cause instead of a raw npx/Electron error.
|
|
504
|
+
let runtimeHint = null;
|
|
313
505
|
|
|
314
506
|
const extract = (err) => {
|
|
315
507
|
const detail = [err && err.stdout, err && err.stderr, err && err.message]
|
|
@@ -319,18 +511,35 @@ async function runCloudgrid(args, opts = {}) {
|
|
|
319
511
|
return new Error(detail || "cloudgrid command failed");
|
|
320
512
|
};
|
|
321
513
|
|
|
322
|
-
// 1. Bundled CLI — own node_modules only, version-gated
|
|
323
|
-
|
|
514
|
+
// 1. Bundled CLI — own node_modules only, version-gated. Runs via
|
|
515
|
+
// cli-shim.mjs under a verified Node runtime (see resolveNodeRuntime):
|
|
516
|
+
// process.execPath is NOT assumed to be Node, because under Electron MCP
|
|
517
|
+
// hosts (Claude Desktop) it is the host's Electron helper binary.
|
|
518
|
+
// ELECTRON_RUN_AS_NODE=1 makes an Electron runtime boot as Node and is
|
|
519
|
+
// ignored by plain Node; the shim strips it again before the CLI runs and
|
|
520
|
+
// fixes commander's Electron argv slicing.
|
|
521
|
+
const bundled = resolveCli();
|
|
324
522
|
if (bundled && meetsMinVersion(bundled.version)) {
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
523
|
+
const runtime = await resolveRuntime({ exec });
|
|
524
|
+
if (runtime) {
|
|
525
|
+
try {
|
|
526
|
+
const { stdout, stderr } = await exec(
|
|
527
|
+
runtime.command,
|
|
528
|
+
[CLI_SHIM, bundled.entry, ...args],
|
|
529
|
+
{ ...execOpts, env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" } },
|
|
530
|
+
);
|
|
531
|
+
return (stdout || stderr || "").trim() || "Done.";
|
|
532
|
+
} catch (err) {
|
|
533
|
+
// A CLI-reported error surfaces; a runtime that failed to boot falls
|
|
534
|
+
// through to the global-CLI/npx rungs instead of killing the chain.
|
|
535
|
+
if (!isRuntimeBootFailure(err)) throw extract(err);
|
|
536
|
+
console.error(
|
|
537
|
+
`cloudgrid-mcp: bundled CLI spawn failed (${err?.code || "runtime boot failure"}); trying global CLI, then npx`,
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
} else {
|
|
541
|
+
runtimeHint = NO_NODE_HINT;
|
|
542
|
+
console.error(`cloudgrid-mcp: ${NO_NODE_HINT} Trying global CLI, then npx.`);
|
|
334
543
|
}
|
|
335
544
|
} else if (bundled) {
|
|
336
545
|
console.error(
|
|
@@ -344,10 +553,16 @@ async function runCloudgrid(args, opts = {}) {
|
|
|
344
553
|
const globalBin = IS_WIN ? "cloudgrid.cmd" : "cloudgrid";
|
|
345
554
|
let useGlobal = false;
|
|
346
555
|
try {
|
|
347
|
-
const { stdout: vOut } = await execMaybeCmd(
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
556
|
+
const { stdout: vOut } = await execMaybeCmd(
|
|
557
|
+
globalBin,
|
|
558
|
+
["--version"],
|
|
559
|
+
{
|
|
560
|
+
timeout: 5_000,
|
|
561
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
562
|
+
env: fallbackEnv,
|
|
563
|
+
},
|
|
564
|
+
exec,
|
|
565
|
+
);
|
|
351
566
|
const ver = vOut.trim().match(/(\d+\.\d+\.\d+)/)?.[1];
|
|
352
567
|
if (meetsMinVersion(ver)) {
|
|
353
568
|
useGlobal = true;
|
|
@@ -361,7 +576,12 @@ async function runCloudgrid(args, opts = {}) {
|
|
|
361
576
|
}
|
|
362
577
|
if (useGlobal) {
|
|
363
578
|
try {
|
|
364
|
-
const { stdout, stderr } = await execMaybeCmd(
|
|
579
|
+
const { stdout, stderr } = await execMaybeCmd(
|
|
580
|
+
globalBin,
|
|
581
|
+
args,
|
|
582
|
+
{ ...execOpts, env: fallbackEnv },
|
|
583
|
+
exec,
|
|
584
|
+
);
|
|
365
585
|
return (stdout || stderr || "").trim() || "Done.";
|
|
366
586
|
} catch (err) {
|
|
367
587
|
throw extract(err);
|
|
@@ -379,11 +599,14 @@ async function runCloudgrid(args, opts = {}) {
|
|
|
379
599
|
const { stdout, stderr } = await execMaybeCmd(
|
|
380
600
|
npx,
|
|
381
601
|
["-y", CLI_NPX_PKG, ...args],
|
|
382
|
-
execOpts,
|
|
602
|
+
{ ...execOpts, env: fallbackEnv },
|
|
603
|
+
exec,
|
|
383
604
|
);
|
|
384
605
|
return (stdout || stderr || "").trim() || "Done.";
|
|
385
606
|
} catch (err) {
|
|
386
|
-
|
|
607
|
+
const failure = extract(err);
|
|
608
|
+
if (runtimeHint) failure.message = `${runtimeHint}\n\n${failure.message}`;
|
|
609
|
+
throw failure;
|
|
387
610
|
}
|
|
388
611
|
}
|
|
389
612
|
|
|
@@ -407,8 +630,13 @@ function tryOpenBrowser(url) {
|
|
|
407
630
|
if (process.env.CLOUDGRID_NO_BROWSER === "1") return;
|
|
408
631
|
const cmd =
|
|
409
632
|
process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
633
|
+
// Strip ELECTRON_RUN_AS_NODE (present when the server itself runs under
|
|
634
|
+
// Electron-as-Node) so an Electron-based browser doesn't boot as a headless
|
|
635
|
+
// Node process instead of opening a window.
|
|
636
|
+
const env = { ...process.env };
|
|
637
|
+
delete env.ELECTRON_RUN_AS_NODE;
|
|
410
638
|
try {
|
|
411
|
-
execFile(cmd, [url], () => {});
|
|
639
|
+
execFile(cmd, [url], { env }, () => {});
|
|
412
640
|
} catch {
|
|
413
641
|
// ignore — the URL is returned to the user anyway
|
|
414
642
|
}
|