Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 70 additions & 23 deletions genkit-tools/cli/src/commands/ui-start.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* Copyright 2024 Google LLC
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -14,6 +14,7 @@
* limitations under the License.
*/

import { GenkitToolsError } from '@genkit-ai/tools-common/manager';
import {
findProjectRoot,
findServersDir,
Expand All @@ -30,10 +31,14 @@ import fs from 'fs/promises';
import getPort, { makeRange } from 'get-port';
import open from 'open';
import path from 'path';
import { SERVER_HARNESS_COMMAND } from './server-harness';
import { detectCLIRuntime } from '../utils/runtime-detector';
import {
buildServerHarnessSpawnConfig,
validateExecutablePath,
} from '../utils/spawn-config';

interface StartOptions {
port: string;
port?: string;
open?: boolean;
}

Expand All @@ -42,7 +47,7 @@ export const uiStart = new Command('ui:start')
.description(
'start the Developer UI which connects to runtimes in the same directory'
)
.option('-p, --port <number>', 'Port to serve on (defaults to 4000')
.option('-p, --port <number>', 'Port to serve on (defaults to 4000)')
.option('-o, --open', 'Open the browser on UI start up')
.action(async (options: StartOptions) => {
let port: number;
Expand Down Expand Up @@ -127,34 +132,76 @@ async function startAndWaitUntilHealthy(
port: number,
serversDir: string
): Promise<ChildProcess> {
return new Promise((resolve, reject) => {
const child = spawn(
process.execPath,
[SERVER_HARNESS_COMMAND, port.toString(), serversDir + '/devui.log'],
{
stdio: ['ignore', 'ignore', 'ignore'],
}
);
// Detect runtime environment
const cliRuntime = detectCLIRuntime();
logger.debug(
`Detected CLI runtime: ${cliRuntime.type} at ${cliRuntime.execPath}`
);
if (cliRuntime.scriptPath) {
logger.debug(`Script path: ${cliRuntime.scriptPath}`);
}

// Only print out logs from the child process to debug output.
child.on('error', (error) => reject(error));
child.on('exit', (code) =>
reject(new Error(`UI process exited (code ${code}) unexpectedly`))
// Build spawn configuration
const logPath = path.join(serversDir, 'devui.log');
const spawnConfig = buildServerHarnessSpawnConfig(cliRuntime, port, logPath);

// Validate executable path
const isExecutable = await validateExecutablePath(spawnConfig.command);
if (!isExecutable) {
throw new GenkitToolsError(
`Unable to execute command: ${spawnConfig.command}. ` +
`The file does not exist or is not executable.`
);
}

logger.debug(
`Spawning: ${spawnConfig.command} ${spawnConfig.args.join(' ')}`
);
const child = spawn(
spawnConfig.command,
spawnConfig.args,
spawnConfig.options
);

// Wait for the process to be ready
return new Promise<ChildProcess>((resolve, reject) => {
// Handle process events
child.on('error', (error) => {
logger.error(`Failed to start UI process: ${error.message}`);
reject(
new GenkitToolsError(`Failed to start UI process: ${error.message}`, {
cause: error,
})
);
});

child.on('exit', (code) => {
const msg = `UI process exited unexpectedly with code ${code}`;
logger.error(msg);
reject(new GenkitToolsError(msg));
});

// Wait for the UI to become healthy
waitUntilHealthy(`http://localhost:${port}`, 10000 /* 10 seconds */)
.then((isHealthy) => {
if (isHealthy) {
child.unref();
resolve(child);
} else {
reject(
new Error(
'Timed out while waiting for UI to become healthy. ' +
'To view full logs, set DEBUG environment variable.'
)
);
const msg =
'Timed out while waiting for UI to become healthy. ' +
'To view full logs, set DEBUG environment variable.';
logger.error(msg);
reject(new GenkitToolsError(msg));
}
})
.catch((error) => reject(error));
.catch((error) => {
logger.error(`Health check failed: ${error.message}`);
reject(
new GenkitToolsError(`Health check failed: ${error.message}`, {
cause: error,
})
);
});
});
}
166 changes: 166 additions & 0 deletions genkit-tools/cli/src/utils/runtime-detector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
/**
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { existsSync } from 'fs';
import { basename, extname } from 'path';

const RUNTIME_NODE = 'node';
const RUNTIME_BUN = 'bun';
const RUNTIME_COMPILED = 'compiled-binary';

const NODE_PATTERNS = ['node', 'nodejs'];
const BUN_PATTERNS = ['bun'];

const SCRIPT_EXTENSIONS = ['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx'];

/**
* CLI runtime types supported by the detector
*/
export type CLIRuntimeType = 'node' | 'bun' | 'compiled-binary';

/**
* Information about the CLI runtime environment
*/
export interface CLIRuntimeInfo {
/** Type of CLI runtime or execution mode */
type: CLIRuntimeType;
/** Path to the executable (node, bun, or the compiled binary itself) */
execPath: string;
/** Path to the script being executed (undefined for compiled binaries) */
scriptPath?: string;
/** Whether this is a compiled binary (e.g., Bun-compiled) */
isCompiledBinary: boolean;
/** Platform information */
platform: NodeJS.Platform;
}

/**
* Safely checks if a file exists without throwing errors
* @param path - File path to check
* @returns true if the file exists, false otherwise
*/
function safeExistsSync(path: string | undefined): boolean {
if (!path) return false;
try {
return existsSync(path);
} catch {
return false;
}
}

/**
* Checks if the given path has a recognized script file extension
* @param path - File path to check
* @returns true if the path ends with a known script extension
* @internal Kept for potential future use, though not currently used in detection logic
*/
function isLikelyScriptFile(path: string | undefined): boolean {
if (!path) return false;
const ext = extname(path).toLowerCase();
return SCRIPT_EXTENSIONS.includes(ext);
}

/**
* Checks if executable name contains any of the given patterns
* @param execName - Name of the executable
* @param patterns - Array of patterns to match against
* @returns true if any pattern is found in the executable name
*/
function matchesPatterns(execName: string, patterns: string[]): boolean {
const lowerExecName = execName.toLowerCase();
return patterns.some((pattern) => lowerExecName.includes(pattern));
}

/**
* Detects the current CLI runtime environment and execution context.
* This helps determine how to properly spawn child processes.
*
* @returns CLI runtime information including type, paths, and platform
* @throws Error if unable to determine CLI runtime executable path
*/
export function detectCLIRuntime(): CLIRuntimeInfo {
const platform = process.platform;
const execPath = process.execPath;

if (!execPath || execPath.trim() === '') {
throw new Error('Unable to determine CLI runtime executable path');
}

const argv0 = process.argv[0];
const argv1 = process.argv[1];

const execBasename = basename(execPath);
const argv0Basename = argv0 ? basename(argv0) : '';

const hasBunVersion = 'bun' in (process.versions || {});
const hasNodeVersion = 'node' in (process.versions || {});

const execMatchesBun = matchesPatterns(execBasename, BUN_PATTERNS);
const execMatchesNode = matchesPatterns(execBasename, NODE_PATTERNS);
const argv0MatchesBun = matchesPatterns(argv0Basename, BUN_PATTERNS);
const argv0MatchesNode = matchesPatterns(argv0Basename, NODE_PATTERNS);

const hasScriptArg = !!argv1;
const scriptExists = hasScriptArg && safeExistsSync(argv1);

let type: CLIRuntimeType;
let scriptPath: string | undefined;
let isCompiledBinary: boolean;

// Determine runtime type based on most reliable indicators
if (hasBunVersion || execMatchesBun || argv0MatchesBun) {
// Check if this is a Bun-compiled binary
// Bun compiled binaries have virtual paths like /$bunfs/root/...
if (
argv1 &&
(argv1.startsWith('/$bunfs/') || /^[A-Za-z]:[\\/]+~BUN[\\/]+/.test(argv1))
) {
// This is a Bun-compiled binary
type = RUNTIME_COMPILED;
scriptPath = undefined;
isCompiledBinary = true;
} else {
// Regular Bun runtime
type = RUNTIME_BUN;
scriptPath = argv1;
isCompiledBinary = false;
}
} else if (hasNodeVersion || execMatchesNode || argv0MatchesNode) {
// Definitely Node.js
type = RUNTIME_NODE;
scriptPath = argv1;
isCompiledBinary = false;
} else if (!hasScriptArg || !scriptExists) {
// No script argument or script doesn't exist - likely compiled binary
type = RUNTIME_COMPILED;
scriptPath = undefined;
isCompiledBinary = true;
} else {
// Have a script argument that exists but unknown runtime
// This handles cases like custom Node.js builds with unusual names
type = RUNTIME_NODE;
scriptPath = argv1;
isCompiledBinary = false;
}

return {
type,
execPath,
scriptPath,
isCompiledBinary,
platform,
};
}
Loading
Loading