Skip to content

Commit 25a50b0

Browse files
cabljacCorieW
andauthored
fix(cli): better runtime handling for ui:start (#3340)
Co-authored-by: Corie Watson <[email protected]>
1 parent 0ab66ea commit 25a50b0

File tree

6 files changed

+1924
-23
lines changed

6 files changed

+1924
-23
lines changed

genkit-tools/cli/src/commands/ui-start.ts

Lines changed: 70 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/**
2-
* Copyright 2024 Google LLC
2+
* Copyright 2025 Google LLC
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -14,6 +14,7 @@
1414
* limitations under the License.
1515
*/
1616

17+
import { GenkitToolsError } from '@genkit-ai/tools-common/manager';
1718
import {
1819
findProjectRoot,
1920
findServersDir,
@@ -30,10 +31,14 @@ import fs from 'fs/promises';
3031
import getPort, { makeRange } from 'get-port';
3132
import open from 'open';
3233
import path from 'path';
33-
import { SERVER_HARNESS_COMMAND } from './server-harness';
34+
import { detectCLIRuntime } from '../utils/runtime-detector';
35+
import {
36+
buildServerHarnessSpawnConfig,
37+
validateExecutablePath,
38+
} from '../utils/spawn-config';
3439

3540
interface StartOptions {
36-
port: string;
41+
port?: string;
3742
open?: boolean;
3843
}
3944

@@ -42,7 +47,7 @@ export const uiStart = new Command('ui:start')
4247
.description(
4348
'start the Developer UI which connects to runtimes in the same directory'
4449
)
45-
.option('-p, --port <number>', 'Port to serve on (defaults to 4000')
50+
.option('-p, --port <number>', 'Port to serve on (defaults to 4000)')
4651
.option('-o, --open', 'Open the browser on UI start up')
4752
.action(async (options: StartOptions) => {
4853
let port: number;
@@ -127,34 +132,76 @@ async function startAndWaitUntilHealthy(
127132
port: number,
128133
serversDir: string
129134
): Promise<ChildProcess> {
130-
return new Promise((resolve, reject) => {
131-
const child = spawn(
132-
process.execPath,
133-
[SERVER_HARNESS_COMMAND, port.toString(), serversDir + '/devui.log'],
134-
{
135-
stdio: ['ignore', 'ignore', 'ignore'],
136-
}
137-
);
135+
// Detect runtime environment
136+
const cliRuntime = detectCLIRuntime();
137+
logger.debug(
138+
`Detected CLI runtime: ${cliRuntime.type} at ${cliRuntime.execPath}`
139+
);
140+
if (cliRuntime.scriptPath) {
141+
logger.debug(`Script path: ${cliRuntime.scriptPath}`);
142+
}
138143

139-
// Only print out logs from the child process to debug output.
140-
child.on('error', (error) => reject(error));
141-
child.on('exit', (code) =>
142-
reject(new Error(`UI process exited (code ${code}) unexpectedly`))
144+
// Build spawn configuration
145+
const logPath = path.join(serversDir, 'devui.log');
146+
const spawnConfig = buildServerHarnessSpawnConfig(cliRuntime, port, logPath);
147+
148+
// Validate executable path
149+
const isExecutable = await validateExecutablePath(spawnConfig.command);
150+
if (!isExecutable) {
151+
throw new GenkitToolsError(
152+
`Unable to execute command: ${spawnConfig.command}. ` +
153+
`The file does not exist or is not executable.`
143154
);
155+
}
156+
157+
logger.debug(
158+
`Spawning: ${spawnConfig.command} ${spawnConfig.args.join(' ')}`
159+
);
160+
const child = spawn(
161+
spawnConfig.command,
162+
spawnConfig.args,
163+
spawnConfig.options
164+
);
165+
166+
// Wait for the process to be ready
167+
return new Promise<ChildProcess>((resolve, reject) => {
168+
// Handle process events
169+
child.on('error', (error) => {
170+
logger.error(`Failed to start UI process: ${error.message}`);
171+
reject(
172+
new GenkitToolsError(`Failed to start UI process: ${error.message}`, {
173+
cause: error,
174+
})
175+
);
176+
});
177+
178+
child.on('exit', (code) => {
179+
const msg = `UI process exited unexpectedly with code ${code}`;
180+
logger.error(msg);
181+
reject(new GenkitToolsError(msg));
182+
});
183+
184+
// Wait for the UI to become healthy
144185
waitUntilHealthy(`http://localhost:${port}`, 10000 /* 10 seconds */)
145186
.then((isHealthy) => {
146187
if (isHealthy) {
147188
child.unref();
148189
resolve(child);
149190
} else {
150-
reject(
151-
new Error(
152-
'Timed out while waiting for UI to become healthy. ' +
153-
'To view full logs, set DEBUG environment variable.'
154-
)
155-
);
191+
const msg =
192+
'Timed out while waiting for UI to become healthy. ' +
193+
'To view full logs, set DEBUG environment variable.';
194+
logger.error(msg);
195+
reject(new GenkitToolsError(msg));
156196
}
157197
})
158-
.catch((error) => reject(error));
198+
.catch((error) => {
199+
logger.error(`Health check failed: ${error.message}`);
200+
reject(
201+
new GenkitToolsError(`Health check failed: ${error.message}`, {
202+
cause: error,
203+
})
204+
);
205+
});
159206
});
160207
}
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
/**
2+
* Copyright 2024 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { existsSync } from 'fs';
18+
import { basename, extname } from 'path';
19+
20+
const RUNTIME_NODE = 'node';
21+
const RUNTIME_BUN = 'bun';
22+
const RUNTIME_COMPILED = 'compiled-binary';
23+
24+
const NODE_PATTERNS = ['node', 'nodejs'];
25+
const BUN_PATTERNS = ['bun'];
26+
27+
const SCRIPT_EXTENSIONS = ['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx'];
28+
29+
/**
30+
* CLI runtime types supported by the detector
31+
*/
32+
export type CLIRuntimeType = 'node' | 'bun' | 'compiled-binary';
33+
34+
/**
35+
* Information about the CLI runtime environment
36+
*/
37+
export interface CLIRuntimeInfo {
38+
/** Type of CLI runtime or execution mode */
39+
type: CLIRuntimeType;
40+
/** Path to the executable (node, bun, or the compiled binary itself) */
41+
execPath: string;
42+
/** Path to the script being executed (undefined for compiled binaries) */
43+
scriptPath?: string;
44+
/** Whether this is a compiled binary (e.g., Bun-compiled) */
45+
isCompiledBinary: boolean;
46+
/** Platform information */
47+
platform: NodeJS.Platform;
48+
}
49+
50+
/**
51+
* Safely checks if a file exists without throwing errors
52+
* @param path - File path to check
53+
* @returns true if the file exists, false otherwise
54+
*/
55+
function safeExistsSync(path: string | undefined): boolean {
56+
if (!path) return false;
57+
try {
58+
return existsSync(path);
59+
} catch {
60+
return false;
61+
}
62+
}
63+
64+
/**
65+
* Checks if the given path has a recognized script file extension
66+
* @param path - File path to check
67+
* @returns true if the path ends with a known script extension
68+
* @internal Kept for potential future use, though not currently used in detection logic
69+
*/
70+
function isLikelyScriptFile(path: string | undefined): boolean {
71+
if (!path) return false;
72+
const ext = extname(path).toLowerCase();
73+
return SCRIPT_EXTENSIONS.includes(ext);
74+
}
75+
76+
/**
77+
* Checks if executable name contains any of the given patterns
78+
* @param execName - Name of the executable
79+
* @param patterns - Array of patterns to match against
80+
* @returns true if any pattern is found in the executable name
81+
*/
82+
function matchesPatterns(execName: string, patterns: string[]): boolean {
83+
const lowerExecName = execName.toLowerCase();
84+
return patterns.some((pattern) => lowerExecName.includes(pattern));
85+
}
86+
87+
/**
88+
* Detects the current CLI runtime environment and execution context.
89+
* This helps determine how to properly spawn child processes.
90+
*
91+
* @returns CLI runtime information including type, paths, and platform
92+
* @throws Error if unable to determine CLI runtime executable path
93+
*/
94+
export function detectCLIRuntime(): CLIRuntimeInfo {
95+
const platform = process.platform;
96+
const execPath = process.execPath;
97+
98+
if (!execPath || execPath.trim() === '') {
99+
throw new Error('Unable to determine CLI runtime executable path');
100+
}
101+
102+
const argv0 = process.argv[0];
103+
const argv1 = process.argv[1];
104+
105+
const execBasename = basename(execPath);
106+
const argv0Basename = argv0 ? basename(argv0) : '';
107+
108+
const hasBunVersion = 'bun' in (process.versions || {});
109+
const hasNodeVersion = 'node' in (process.versions || {});
110+
111+
const execMatchesBun = matchesPatterns(execBasename, BUN_PATTERNS);
112+
const execMatchesNode = matchesPatterns(execBasename, NODE_PATTERNS);
113+
const argv0MatchesBun = matchesPatterns(argv0Basename, BUN_PATTERNS);
114+
const argv0MatchesNode = matchesPatterns(argv0Basename, NODE_PATTERNS);
115+
116+
const hasScriptArg = !!argv1;
117+
const scriptExists = hasScriptArg && safeExistsSync(argv1);
118+
119+
let type: CLIRuntimeType;
120+
let scriptPath: string | undefined;
121+
let isCompiledBinary: boolean;
122+
123+
// Determine runtime type based on most reliable indicators
124+
if (hasBunVersion || execMatchesBun || argv0MatchesBun) {
125+
// Check if this is a Bun-compiled binary
126+
// Bun compiled binaries have virtual paths like /$bunfs/root/...
127+
if (
128+
argv1 &&
129+
(argv1.startsWith('/$bunfs/') || /^[A-Za-z]:[\\/]+~BUN[\\/]+/.test(argv1))
130+
) {
131+
// This is a Bun-compiled binary
132+
type = RUNTIME_COMPILED;
133+
scriptPath = undefined;
134+
isCompiledBinary = true;
135+
} else {
136+
// Regular Bun runtime
137+
type = RUNTIME_BUN;
138+
scriptPath = argv1;
139+
isCompiledBinary = false;
140+
}
141+
} else if (hasNodeVersion || execMatchesNode || argv0MatchesNode) {
142+
// Definitely Node.js
143+
type = RUNTIME_NODE;
144+
scriptPath = argv1;
145+
isCompiledBinary = false;
146+
} else if (!hasScriptArg || !scriptExists) {
147+
// No script argument or script doesn't exist - likely compiled binary
148+
type = RUNTIME_COMPILED;
149+
scriptPath = undefined;
150+
isCompiledBinary = true;
151+
} else {
152+
// Have a script argument that exists but unknown runtime
153+
// This handles cases like custom Node.js builds with unusual names
154+
type = RUNTIME_NODE;
155+
scriptPath = argv1;
156+
isCompiledBinary = false;
157+
}
158+
159+
return {
160+
type,
161+
execPath,
162+
scriptPath,
163+
isCompiledBinary,
164+
platform,
165+
};
166+
}

0 commit comments

Comments
 (0)