diff --git a/.github/workflows/build-cli-binaries.yml b/.github/workflows/build-cli-binaries.yml new file mode 100644 index 0000000000..3b16809358 --- /dev/null +++ b/.github/workflows/build-cli-binaries.yml @@ -0,0 +1,259 @@ +# 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. +# 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. +# +# SPDX-License-Identifier: Apache-2.0 + +name: Build CLI Binaries + +on: + push: + branches: + - '@invertase/cli-binary' + workflow_dispatch: + +jobs: + build: + strategy: + matrix: + include: + - os: ubuntu-latest + target: linux-x64 + - os: ubuntu-24.04-arm + target: linux-arm64 + - os: macos-13 # x64/Intel + target: darwin-x64 + - os: macos-latest # ARM64/M1 + target: darwin-arm64 + - os: windows-latest + target: win32-x64 + # Note: Windows ARM64 currently runs x64 binaries through emulation + # Native ARM64 support is not yet available in Bun + # See: https://github.com/oven-sh/bun/pull/11430 + # - os: windows-11-arm + # target: win32-arm64 + + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Setup pnpm + uses: pnpm/action-setup@v3 + with: + version: 10.11.0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + + - name: Install root dependencies + run: pnpm i + + - name: Install genkit-tools dependencies + run: pnpm pnpm-install-genkit-tools + + - name: Build genkit-tools + run: pnpm build:genkit-tools + + - name: Set binary extension + id: binary + shell: bash + run: | + if [[ "${{ matrix.target }}" == win32-* ]]; then + echo "ext=.exe" >> $GITHUB_OUTPUT + else + echo "ext=" >> $GITHUB_OUTPUT + fi + + - name: Compile CLI binary + shell: bash + run: | + cd genkit-tools/cli + pnpm compile:bun + + # Handle the binary name based on OS + if [[ "${{ matrix.os }}" == windows-* ]]; then + # On Windows, Bun outputs genkit.exe + mv dist/bin/genkit.exe "dist/bin/genkit-${{ matrix.target }}.exe" + else + # On Unix-like systems, no extension + mv dist/bin/genkit "dist/bin/genkit-${{ matrix.target }}" + fi + + - name: Upload binary artifact + uses: actions/upload-artifact@v4 + with: + name: genkit-${{ matrix.target }} + path: genkit-tools/cli/dist/bin/genkit-${{ matrix.target }}${{ steps.binary.outputs.ext }} + retention-days: 1 # TODO: Consider increasing to 7 days for better debugging capability + + test: + needs: build + strategy: + matrix: + include: + - os: ubuntu-latest + target: linux-x64 + - os: ubuntu-24.04-arm + target: linux-arm64 + - os: macos-13 + target: darwin-x64 + - os: macos-latest + target: darwin-arm64 + - os: windows-latest + target: win32-x64 + + runs-on: ${{ matrix.os }} + + steps: + - name: Set binary extension + id: binary + shell: bash + run: | + if [[ "${{ matrix.target }}" == win32-* ]]; then + echo "ext=.exe" >> $GITHUB_OUTPUT + else + echo "ext=" >> $GITHUB_OUTPUT + fi + + - name: Download binary artifact + uses: actions/download-artifact@v4 + with: + name: genkit-${{ matrix.target }} + path: ./ + + - name: Make binary executable (Unix) + if: runner.os != 'Windows' + run: chmod +x genkit-${{ matrix.target }} + + - name: Test --help command + shell: bash + run: | + echo "Testing genkit --help" + ./genkit-${{ matrix.target }}${{ steps.binary.outputs.ext }} --help + + - name: Test --version command + shell: bash + run: | + echo "Testing genkit --version" + ./genkit-${{ matrix.target }}${{ steps.binary.outputs.ext }} --version + + - name: Verify UI commands exist + shell: bash + run: | + echo "Verifying UI commands are available" + ./genkit-${{ matrix.target }}${{ steps.binary.outputs.ext }} ui:start --help + ./genkit-${{ matrix.target }}${{ steps.binary.outputs.ext }} ui:stop --help + + - name: Test UI start functionality (Unix only) + if: runner.os != 'Windows' + shell: bash + run: | + echo "Testing genkit ui:start" + + # Start UI in background, piping any prompts to accept them + (echo "" | ./genkit-${{ matrix.target }} ui:start 2>&1 | tee ui_output.log) & + UI_PID=$! + + # Give it time to start + sleep 5 + + # Check if it started successfully by looking for the expected output + if grep -q "Genkit Developer UI started at:" ui_output.log 2>/dev/null; then + echo "✓ UI started successfully" + cat ui_output.log + + # Try to stop it gracefully + echo "Testing genkit ui:stop" + ./genkit-${{ matrix.target }} ui:stop || true + + # Give it time to stop + sleep 2 + else + echo "UI output:" + cat ui_output.log 2>/dev/null || echo "No output captured" + + # Check if process is still running + if ps -p $UI_PID > /dev/null 2>&1; then + echo "Process is running but didn't produce expected output" + kill $UI_PID 2>/dev/null || true + else + echo "Process exited (might be due to cookie prompt or missing project)" + fi + fi + + # Clean up any remaining processes + pkill -f "genkit.*ui:start" 2>/dev/null || true + + - name: Test UI start functionality (Windows only) + if: runner.os == 'Windows' + shell: pwsh + run: | + Write-Host "Testing genkit ui:start" + + # Create empty input file first for redirecting stdin + "" | Out-File -FilePath ".\empty.txt" + + # Start UI in background, redirecting input to handle prompts + $process = Start-Process -FilePath ".\genkit-${{ matrix.target }}.exe" ` + -ArgumentList "ui:start" ` + -RedirectStandardInput ".\empty.txt" ` + -RedirectStandardOutput ".\ui_output.log" ` + -RedirectStandardError ".\ui_error.log" ` + -PassThru ` + -NoNewWindow + + # Give it time to start + Start-Sleep -Seconds 5 + + # Read the output + $output = Get-Content ".\ui_output.log" -ErrorAction SilentlyContinue + $errorOutput = Get-Content ".\ui_error.log" -ErrorAction SilentlyContinue + + if ($output -match "Genkit Developer UI started at:") { + Write-Host "✓ UI started successfully" + Write-Host "Output:" + $output | Write-Host + + # Try to stop it gracefully + Write-Host "Testing genkit ui:stop" + & ".\genkit-${{ matrix.target }}.exe" ui:stop + + Start-Sleep -Seconds 2 + } else { + Write-Host "UI did not start as expected" + Write-Host "Output:" + $output | Write-Host + Write-Host "Error:" + $errorOutput | Write-Host + + # Check if process is still running + if (-not $process.HasExited) { + Write-Host "Process is still running, terminating..." + Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue + } else { + Write-Host "Process exited (might be due to cookie prompt or missing project)" + } + } + + # Clean up any remaining genkit processes + Get-Process | Where-Object { $_.ProcessName -match "genkit" } | Stop-Process -Force -ErrorAction SilentlyContinue \ No newline at end of file diff --git a/bin/install_cli b/bin/install_cli new file mode 100644 index 0000000000..35873ec603 --- /dev/null +++ b/bin/install_cli @@ -0,0 +1,349 @@ +#!/usr/bin/env bash +# 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. +# 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. +# +# SPDX-License-Identifier: Apache-2.0 + + +## +## +## +## +## + +# Configuration variables +DOMAIN="genkit.tools" +TRACKING_ID="UA-XXXXXXXXX-X" # Not used when analytics is commented out + +: ========================================== +: Introduction +: ========================================== + +# This script allows you to install the latest version of the +# "genkit" command by running: +# +: curl -sL $DOMAIN | bash +# +# If you do not want to use this script, you can manually +# download the latest "genkit" binary. +# +: curl -Lo ./genkit_bin https://$DOMAIN/bin/linux/latest +# +# Alternatively, you can download a specific version. +# +: curl -Lo ./genkit_bin https://$DOMAIN/bin/linux/v1.12.0 +# +# Note: On Mac, replace "linux" with "macos" in the URL. +# +# For full details about installation options for the Genkit CLI +# please see our documentation. +# https://firebase.google.com/docs/genkit/ +# +# Please report bugs / issues with this script on GitHub. +# https://github.com/firebase/genkit +# + +: ========================================== +: Advanced Usage +: ========================================== + +# The behavior of this script can be modified at runtime by passing environmental +# variables to the `bash` process. +# +# For example, passing an argument called arg1 set to true and one called arg2 set +# to false would look like this. +# +: curl -sL $DOMAIN | arg1=true arg2=false bash +# +# These arguments are optional, but be aware that explicitly setting them will help +# ensure consistent behavior if / when defaults are changed. +# + +: ----------------------------------------- +: Upgrading - default: false +: ----------------------------------------- + +# By default, this script will not replace an existing "genkit" install. +# If you'd like to upgrade an existing install, set the "upgrade" variable to true. +# +: curl -sL $DOMAIN | upgrade=true bash +# +# This operation could (potentially) break an existing install, so use it with caution. +# + +: ----------------------------------------- +: Uninstalling - default false +: ----------------------------------------- + +# You can remove the binary by passing the "uninstall" flag. +# +: curl -sL $DOMAIN | uninstall=true bash +# +# This will remove the binary file and any cached data. +# + +: ----------------------------------------- +: Analytics - default true +: ----------------------------------------- + +# This script reports anonymous success / failure analytics. +# You can disable this reporting by setting the "analytics" variable to false. +# +: curl -sL $DOMAIN | analytics=false bash +# +# By default we report all data anonymously and do not collect any information +# except platform type (Darwin, Win, etc) in the case of an unsupported platform +# error. +# + +: ========================================== +: Source Code +: ========================================== + +# This script contains a large amount of comments so you can understand +# how it interacts with your system. If you're not interested in the +# technical details, you can just run the command above. + +# We begin by generating a unique ID for tracking the anonymous session. +CID=$(head -80 /dev/urandom | LC_ALL=c tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1) +# Credit: https://gist.github.com/earthgecko/3089509 + +# We can use this CID in all calls to the Google Analytics endpoint via +# this reusable function. +send_analytics_event() +{ + # Analytics tracking is currently disabled + # Uncomment the block below to enable analytics + + # if [ ! "$analytics" = "false" ]; then + # curl -s https://www.google-analytics.com/collect \ + # -d "tid=$TRACKING_ID" \ + # -d "t=event" \ + # -d "ec=$DOMAIN" \ + # -d "ea=$1" \ + # -d "v=1" \ + # -d "cid=$CID" \ + # -o /dev/null + # fi + + # For now, just return success + return 0 +} + +# We send one event to count the number of times this script is ran. At the +# end we also report success / failure, but it's possible that the script +# will crash before we get to that point, so we manually count invocations here. +send_analytics_event start + +# We try to detect any existing binaries on $PATH or two common locations. +GENKIT_BINARY=${GENKIT_BINARY:-$(which genkit)} +LOCAL_BINARY="$HOME/.local/bin/genkit" +# For info about why we place the binary at this location, see +# https://unix.stackexchange.com/a/8658 +GLOBAL_BINARY="/usr/local/bin/genkit" +if [[ -z "$GENKIT_BINARY" ]]; then + if [ -e "$LOCAL_BINARY" ]; then + GENKIT_BINARY="$LOCAL_BINARY" + elif [ -e "$GLOBAL_BINARY" ]; then + GENKIT_BINARY="$GLOBAL_BINARY" + fi +fi + +# If the user asked for us to uninstall genkit, then do so. +if [ "$uninstall" = "true" ]; then + if [[ -z "$GENKIT_BINARY" ]]; then + echo "Cannot detect any Genkit CLI installations." + echo "Please manually remove any \"genkit\" binaries not in \$PATH." + else + # Assuming binary install, skip npm check + echo "-- Removing binary file..." + sudo rm -- "$GENKIT_BINARY" + fi + echo "-- Removing genkit cache..." + rm -rf ~/.cache/genkit + + echo "-- genkit has been uninstalled" + echo "-- All Done!" + + send_analytics_event uninstall + exit 0 +fi + +# We need to ensure that we don't mess up an existing "genkit" +# install, so before doing anything we check to see if this system +# has "genkit" installed and if so, we exit out. +echo "-- Checking for existing genkit installation..." + +if [[ ! -z "$GENKIT_BINARY" ]]; then + INSTALLED_GENKIT_VERSION=$("$GENKIT_BINARY" --version) + + # In the case of a corrupt genkit install, we wont be able to + # retrieve a version number, so to keep the logs correct, we refer to + # your existing install as either the CLI version or as a "corrupt install" + if [[ ! -z "$INSTALLED_GENKIT_VERSION" ]]; then + GENKIT_NICKNAME="genkit@$INSTALLED_GENKIT_VERSION" + else + GENKIT_NICKNAME="a corrupted genkit binary" + fi + + # Skip npm check - assume binary install + # If the user didn't pass upgrade=true, then we print the command to do an upgrade and exit + if [ ! "$upgrade" = "true" ]; then + echo "Your machine has $GENKIT_NICKNAME installed." + echo "If you would like to upgrade your install run: curl -sL $DOMAIN | upgrade=true bash" + + send_analytics_event already_installed + exit 0 + else + # If the user did pass upgrade=true, then we allow the script to continue and overwrite the install. + echo "-- Your machine has $GENKIT_NICKNAME, attempting upgrade..." + + send_analytics_event upgrade + fi +fi + +echo "-- Checking your machine type..." + +# Now we need to detect the platform we're running on (Linux / Mac / Other) +# so we can fetch the correct binary and place it in the correct location +# on the machine. + +# We use "tr" to translate the uppercase "uname" output into lowercase +UNAME=$(uname -s | tr '[:upper:]' '[:lower:]') + +# Detect architecture +ARCH=$(uname -m) +case "$ARCH" in + x86_64) ARCH_SUFFIX="x64";; + aarch64|arm64) ARCH_SUFFIX="arm64";; + *) ARCH_SUFFIX="x64";; # Default to x64 +esac + +# Then we map the output to the names used on the GitHub releases page +case "$UNAME" in + linux*) MACHINE="linux-${ARCH_SUFFIX}";; + darwin*) MACHINE="darwin-${ARCH_SUFFIX}";; + mingw*|msys*|cygwin*) MACHINE="win32-x64";; +esac + +# If we never define the $MACHINE variable (because our platform is neither Mac, +# Linux, or Windows), then we can't finish our job, so just log out a helpful message +# and close. +if [[ -z "$MACHINE" ]]; then + echo "Your operating system is not supported, if you think it should be please file a bug." + echo "https://github.com/firebase/genkit/" + echo "-- All done!" + + send_analytics_event "missing_platform_${UNAME}_${ARCH}" + exit 0 +fi + +# We have enough information to generate the binary's download URL. +DOWNLOAD_URL="https://$DOMAIN/bin/$MACHINE/latest" +echo "-- Downloading binary from $DOWNLOAD_URL" + +# We use "curl" to download the binary with a flag set to follow redirects +# (GitHub download URLs redirect to CDNs) and a flag to show a progress bar. +curl -o "/tmp/genkit_standalone.tmp" -L --progress-bar $DOWNLOAD_URL + +GENKIT_BINARY=${GENKIT_BINARY:-$GLOBAL_BINARY} +INSTALL_DIR=$(dirname -- "$GENKIT_BINARY") + +# We need to ensure that the INSTALL_DIR exists. +# On some platforms like the Windows Subsystem for Linux it may not. +# We created it using a non-destructive mkdir command. +mkdir -p -- "$INSTALL_DIR" 2> /dev/null + +# If the directory does not exist or is not writable, we resort to sudo. +sudo="" +if [ ! -w "$INSTALL_DIR" ]; then + sudo="sudo" +fi + +$sudo mkdir -p -- "$INSTALL_DIR" +$sudo mv -f "/tmp/genkit_standalone.tmp" "$GENKIT_BINARY" + +# Once the download is complete, we mark the binary file as readable +# and executable (+rx). +echo "-- Setting permissions on binary... $GENKIT_BINARY" +$sudo chmod +rx "$GENKIT_BINARY" + +# If all went well, the "genkit" binary should be located on our PATH so +# we'll run it once, asking it to print out the version. This is helpful as +# standalone genkit binaries do a small amount of setup on the initial run +# so this not only allows us to make sure we got the right version, but it +# also does the setup so the first time the developer runs the binary, it'll +# be faster. +VERSION=$("$GENKIT_BINARY" --version) + +# If no version is detected then clearly the binary failed to install for +# some reason, so we'll log out an error message and report the failure +# to headquarters via an analytics event. +if [[ -z "$VERSION" ]]; then + echo "Something went wrong, genkit has not been installed." + echo "Please file a bug with your system information on GitHub." + echo "https://github.com/firebase/genkit/" + echo "-- All done!" + + send_analytics_event failure + exit 1 +fi + +# In order for the user to be able to actually run the "genkit" command +# without specifying the absolute location, the INSTALL_DIR path must +# be present inside of the PATH environment variable. + +echo "-- Checking your PATH variable..." +if [[ ! ":$PATH:" == *":$INSTALL_DIR:"* ]]; then + echo "" + echo "It looks like $INSTALL_DIR isn't on your PATH." + echo "Please add the following line to either your ~/.profile or ~/.bash_profile, then restart your terminal." + echo "" + echo "PATH=\$PATH:$INSTALL_DIR" + echo "" + echo "For more information about modifying PATHs, see https://unix.stackexchange.com/a/26059" + echo "" + send_analytics_event missing_path +fi + +# We also try to upgrade the local binary if it exists. +# This helps prevent having two mismatching versions of "genkit". +if [[ "$GENKIT_BINARY" != "$LOCAL_BINARY" ]] && [ -e "$LOCAL_BINARY" ]; then + echo "-- Upgrading the local binary installation $LOCAL_BINARY..." + cp "$GENKIT_BINARY" "$LOCAL_BINARY" # best effort, okay if it fails. + chmod +x "$LOCAL_BINARY" +fi + +# Since we've gotten this far we know everything succeeded. We'll just +# let the developer know everything is ready and take our leave. +echo "-- genkit@$VERSION is now installed" +echo "-- All Done!" + +send_analytics_event success +exit 0 + +# ------------------------------------------ +# Notes +# ------------------------------------------ +# +# This script contains hidden JavaScript which is used to improve +# readability in the browser (via syntax highlighting, etc), right-click +# and "View source" of this page to see the entire bash script! +# +# You'll also notice that we use the ":" character in the Introduction +# which allows our copy/paste commands to be syntax highlighted, but not +# ran. In bash : is equal to `true` and true can take infinite arguments +# while still returning true. This turns these commands into no-ops so +# when ran as a script, they're totally ignored. +# \ No newline at end of file diff --git a/genkit-tools/cli/package.json b/genkit-tools/cli/package.json index 6e0a5854d9..485b0e607a 100644 --- a/genkit-tools/cli/package.json +++ b/genkit-tools/cli/package.json @@ -17,6 +17,7 @@ "scripts": { "build": "pnpm genversion && tsc", "build:watch": "tsc --watch", + "compile:bun": "bun build src/bin/genkit.ts --compile --outfile dist/bin/genkit --minify", "test": "jest --verbose", "genversion": "genversion -esf src/utils/version.ts" }, @@ -42,9 +43,11 @@ "@types/inquirer": "^8.1.3", "@types/jest": "^29.5.12", "@types/node": "^20.11.19", + "bun-types": "^1.2.16", "genversion": "^3.2.0", "jest": "^29.7.0", "ts-jest": "^29.1.2", + "ts-node": "^10.9.2", "typescript": "^5.3.3" } } diff --git a/genkit-tools/cli/src/cli.ts b/genkit-tools/cli/src/cli.ts index b725582bd9..e44776ffd3 100644 --- a/genkit-tools/cli/src/cli.ts +++ b/genkit-tools/cli/src/cli.ts @@ -31,6 +31,10 @@ import { flowRun } from './commands/flow-run'; import { getPluginCommands, getPluginSubCommand } from './commands/plugins'; import { start } from './commands/start'; import { uiStart } from './commands/ui-start'; +import { + UI_START_SERVER_COMMAND, + uiStartServer, +} from './commands/ui-start-server'; import { uiStop } from './commands/ui-stop'; import { version } from './utils/version'; @@ -79,6 +83,12 @@ export async function startCLI(): Promise { await record(new RunCommandEvent(commandName)); }); + // When running as a spawned UI server process, argv[1] will be '__ui:start-server' + // instead of a normal command. This allows the same binary to serve both CLI and server roles. + if (process.argv[1] === UI_START_SERVER_COMMAND) { + program.addCommand(uiStartServer); + } + for (const command of commands) program.addCommand(command); for (const command of await getPluginCommands()) program.addCommand(command); diff --git a/genkit-tools/cli/src/commands/ui-start-server.ts b/genkit-tools/cli/src/commands/ui-start-server.ts new file mode 100644 index 0000000000..65ca2a7a40 --- /dev/null +++ b/genkit-tools/cli/src/commands/ui-start-server.ts @@ -0,0 +1,56 @@ +/** + * 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 { startServer } from '@genkit-ai/tools-common/server'; +import { Command } from 'commander'; +import fs from 'fs'; +import { startManager } from '../utils/manager-utils'; + +function redirectStdoutToFile(logFile: string) { + const myLogFileStream = fs.createWriteStream(logFile); + + const originalStdout = process.stdout.write; + function writeStdout() { + originalStdout.apply(process.stdout, arguments as any); + myLogFileStream.write.apply(myLogFileStream, arguments as any); + } + + process.stdout.write = writeStdout as any; + process.stderr.write = process.stdout.write; +} + +export const UI_START_SERVER_COMMAND = '__ui:start-server' as const; + +export const uiStartServer = new Command('__ui:start-server') + .argument('', 'Port to serve on') + .argument('', 'Log file path') + .action(async (port: string, logFile: string) => { + redirectStdoutToFile(logFile); + + process.on('error', (error): void => { + console.log(`Error in tools process: ${error}`); + }); + process.on('uncaughtException', (err, somethingelse) => { + console.log(`Uncaught error in tools process: ${err} ${somethingelse}`); + }); + process.on('unhandledRejection', (reason, p) => { + console.log(`Unhandled rejection in tools process: ${reason}`); + }); + + const portNum = Number.parseInt(port) || 4100; + const manager = await startManager(true); + await startServer(manager, portNum); + }); diff --git a/genkit-tools/cli/src/commands/ui-start.ts b/genkit-tools/cli/src/commands/ui-start.ts index dd270a4410..c99d1dfc55 100644 --- a/genkit-tools/cli/src/commands/ui-start.ts +++ b/genkit-tools/cli/src/commands/ui-start.ts @@ -126,14 +126,14 @@ async function startAndWaitUntilHealthy( serversDir: string ): Promise { return new Promise((resolve, reject) => { - const serverPath = path.join(__dirname, '../utils/server-harness.js'); const child = spawn( - 'node', - [serverPath, port.toString(), serversDir + '/devui.log'], + process.execPath, + ['__ui:start-server', port.toString(), serversDir + '/devui.log'], { stdio: ['ignore', 'ignore', 'ignore'], } ); + // Only print out logs from the child process to debug output. child.on('error', (error) => reject(error)); child.on('exit', (code) => diff --git a/genkit-tools/cli/src/utils/server-harness.ts b/genkit-tools/cli/src/utils/server-harness.ts deleted file mode 100644 index 02feeb07c2..0000000000 --- a/genkit-tools/cli/src/utils/server-harness.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * 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 { startServer } from '@genkit-ai/tools-common/server'; -import fs from 'fs'; -import { startManager } from './manager-utils'; - -const args = process.argv.slice(2); -const port = Number.parseInt(args[0]) || 4100; -redirectStdoutToFile(args[1]); - -async function start() { - const manager = await startManager(true); - await startServer(manager, port); -} - -function redirectStdoutToFile(logFile: string) { - var myLogFileStream = fs.createWriteStream(logFile); - - var originalStdout = process.stdout.write; - function writeStdout() { - originalStdout.apply(process.stdout, arguments as any); - myLogFileStream.write.apply(myLogFileStream, arguments as any); - } - - process.stdout.write = writeStdout as any; - process.stderr.write = process.stdout.write; -} - -process.on('error', (error): void => { - console.log(`Error in tools process: ${error}`); -}); -process.on('uncaughtException', (err, somethingelse) => { - console.log(`Uncaught error in tools process: ${err} ${somethingelse}`); -}); -process.on('unhandledRejection', (reason, p) => { - console.log(`Unhandled rejection in tools process: ${reason}`); -}); - -start(); diff --git a/genkit-tools/cli/tsconfig.json b/genkit-tools/cli/tsconfig.json index 4496203d6b..3673086655 100644 --- a/genkit-tools/cli/tsconfig.json +++ b/genkit-tools/cli/tsconfig.json @@ -2,7 +2,9 @@ "extends": "../tsconfig.base.json", "compilerOptions": { "module": "commonjs", - "outDir": "dist" + "outDir": "dist", + "types": ["bun-types"], + "resolveJsonModule": true }, "include": ["src"] } diff --git a/genkit-tools/common/package.json b/genkit-tools/common/package.json index b5fa05cd09..edb532e97a 100644 --- a/genkit-tools/common/package.json +++ b/genkit-tools/common/package.json @@ -48,6 +48,7 @@ "@types/json-schema": "^7.0.15", "@types/node": "^20.11.19", "@types/uuid": "^9.0.8", + "bun-types": "^1.2.16", "genversion": "^3.2.0", "jest": "^29.7.0", "npm-run-all": "^4.1.5", @@ -63,61 +64,61 @@ }, "author": "genkit", "license": "Apache-2.0", - "types": "./lib/types/types/index.d.ts", + "types": "./lib/types/src/types/index.d.ts", "exports": { ".": { - "require": "./lib/cjs/types/index.js", - "import": "./lib/esm/types/index.js", - "types": "./lib/types/types/index.d.ts", - "default": "./lib/esm/types/index.js" + "require": "./lib/cjs/src/types/index.js", + "import": "./lib/esm/src/types/index.js", + "types": "./lib/types/src/types/index.d.ts", + "default": "./lib/esm/src/types/index.js" }, "./eval": { - "types": "./lib/types/eval/index.d.ts", - "require": "./lib/cjs/eval/index.js", - "import": "./lib/esm/eval/index.js", - "default": "./lib/esm/eval/index.js" + "types": "./lib/types/src/eval/index.d.ts", + "require": "./lib/cjs/src/eval/index.js", + "import": "./lib/esm/src/eval/index.js", + "default": "./lib/esm/src/eval/index.js" }, "./plugin": { - "types": "./lib/types/plugin/index.d.ts", - "require": "./lib/cjs/plugin/index.js", - "import": "./lib/esm/plugin/index.js", - "default": "./lib/esm/plugin/index.js" + "types": "./lib/types/src/plugin/index.d.ts", + "require": "./lib/cjs/src/plugin/index.js", + "import": "./lib/esm/src/plugin/index.js", + "default": "./lib/esm/src/plugin/index.js" }, "./manager": { - "types": "./lib/manager/index.d.ts", - "require": "./lib/cjs/manager/index.js", - "import": "./lib/esm/manager/index.js", - "default": "./lib/esm/manager/index.js" + "types": "./lib/types/src/manager/index.d.ts", + "require": "./lib/cjs/src/manager/index.js", + "import": "./lib/esm/src/manager/index.js", + "default": "./lib/esm/src/manager/index.js" }, "./server": { - "types": "./lib/server/index.d.ts", - "require": "./lib/cjs/server/index.js", - "import": "./lib/esm/server/index.js", - "default": "./lib/esm/server/index.js" + "types": "./lib/types/src/server/index.d.ts", + "require": "./lib/cjs/src/server/index.js", + "import": "./lib/esm/src/server/index.js", + "default": "./lib/esm/src/server/index.js" }, "./utils": { - "types": "./lib/utils/index.d.ts", - "require": "./lib/cjs/utils/index.js", - "import": "./lib/esm/utils/index.js", - "default": "./lib/esm/utils/index.js" + "types": "./lib/types/src/utils/index.d.ts", + "require": "./lib/cjs/src/utils/index.js", + "import": "./lib/esm/src/utils/index.js", + "default": "./lib/esm/src/utils/index.js" } }, "typesVersions": { "*": { "eval": [ - "lib/types/eval" + "lib/types/src/eval" ], "plugin": [ - "lib/types/plugin" + "lib/types/src/plugin" ], "manager": [ - "lib/types/manager" + "lib/types/src/manager" ], "server": [ - "lib/types/server" + "lib/types/src/server" ], "utils": [ - "lib/types/utils" + "lib/types/src/utils" ] } } diff --git a/genkit-tools/common/src/utils/errors.ts b/genkit-tools/common/src/utils/errors.ts new file mode 100644 index 0000000000..00e3102d8d --- /dev/null +++ b/genkit-tools/common/src/utils/errors.ts @@ -0,0 +1,155 @@ +/** + * 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. + * 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. + */ + +// Connection error codes for different runtimes +const CONNECTION_ERROR_CODES = { + NODE_ECONNREFUSED: 'ECONNREFUSED', + BUN_CONNECTION_REFUSED: 'ConnectionRefused', + ECONNRESET: 'ECONNRESET', +} as const; + +const CONNECTION_ERROR_PATTERNS = [ + 'ECONNREFUSED', + 'Connection refused', + 'ConnectionRefused', + 'connect ECONNREFUSED', +] as const; + +type ErrorWithCode = { + code?: string; + message?: string; + cause?: ErrorWithCode; +}; + +/** + * Checks if an error is a connection refused error across Node.js and Bun runtimes. + * + * Node.js structure: error.cause.code === 'ECONNREFUSED' + * Bun structure: error.code === 'ConnectionRefused' or error.code === 'ECONNRESET' + */ +export function isConnectionRefusedError(error: unknown): boolean { + if (!error) { + return false; + } + + const errorCode = getErrorCode(error); + if (errorCode && isConnectionErrorCode(errorCode)) { + return true; + } + + // Fallback: check error message + if (isErrorWithMessage(error)) { + return CONNECTION_ERROR_PATTERNS.some((pattern) => + error.message.includes(pattern) + ); + } + + return false; +} + +/** + * Helper function to check if a code is a connection error code. + */ +function isConnectionErrorCode(code: string): boolean { + return Object.values(CONNECTION_ERROR_CODES).includes( + code as (typeof CONNECTION_ERROR_CODES)[keyof typeof CONNECTION_ERROR_CODES] + ); +} + +/** + * Type guard to check if an error has a message property. + */ +function isErrorWithMessage(error: unknown): error is { message: string } { + return ( + typeof error === 'object' && + error !== null && + 'message' in error && + typeof (error as any).message === 'string' + ); +} + +/** + * Extracts error code from an object, handling nested structures. + */ +function extractErrorCode(obj: unknown): string | undefined { + if ( + typeof obj === 'object' && + obj !== null && + 'code' in obj && + typeof (obj as ErrorWithCode).code === 'string' + ) { + return (obj as ErrorWithCode).code; + } + return undefined; +} + +/** + * Gets the error code from an error object, handling both Node.js and Bun styles. + */ +export function getErrorCode(error: unknown): string | undefined { + if (!error) { + return undefined; + } + + // Direct error code + const directCode = extractErrorCode(error); + if (directCode) { + return directCode; + } + + // Node.js style with cause + if (typeof error === 'object' && error !== null && 'cause' in error) { + const causeCode = extractErrorCode((error as ErrorWithCode).cause); + if (causeCode) { + return causeCode; + } + } + + return undefined; +} + +/** + * Extracts error message from various error formats. + */ +function extractErrorMessage(error: unknown): string | undefined { + if (error instanceof Error) { + return error.message; + } + + if (isErrorWithMessage(error)) { + return error.message; + } + + return undefined; +} + +/** + * Safely extracts error details for logging. + */ +export function getErrorDetails(error: unknown): string { + if (error === null || error === undefined) { + return 'Unknown error'; + } + + const code = getErrorCode(error); + const message = extractErrorMessage(error); + + if (message) { + return code ? `${message} (${code})` : message; + } + + return String(error); +} diff --git a/genkit-tools/common/src/utils/package.ts b/genkit-tools/common/src/utils/package.ts index 7e94aef735..88561673dc 100644 --- a/genkit-tools/common/src/utils/package.ts +++ b/genkit-tools/common/src/utils/package.ts @@ -14,8 +14,6 @@ * limitations under the License. */ -import { readFileSync } from 'fs'; -import { join } from 'path'; +import toolsPackage from '../../package.json'; -const packagePath = join(__dirname, '../../../package.json'); -export const toolsPackage = JSON.parse(readFileSync(packagePath, 'utf8')); +export { toolsPackage }; diff --git a/genkit-tools/common/src/utils/utils.ts b/genkit-tools/common/src/utils/utils.ts index 9368e22a3e..28531fd2af 100644 --- a/genkit-tools/common/src/utils/utils.ts +++ b/genkit-tools/common/src/utils/utils.ts @@ -17,6 +17,7 @@ import * as fs from 'fs/promises'; import * as path from 'path'; import type { Runtime } from '../manager/types'; +import { isConnectionRefusedError } from './errors'; import { logger } from './logger'; export interface DevToolsInfo { @@ -145,10 +146,7 @@ export async function checkServerHealth(url: string): Promise { const response = await fetch(`${url}/api/__health`); return response.status === 200; } catch (error) { - if ( - error instanceof Error && - (error.cause as any).code === 'ECONNREFUSED' - ) { + if (isConnectionRefusedError(error)) { return false; } } @@ -189,10 +187,7 @@ export async function waitUntilUnresponsive( try { const health = await fetch(`${url}/api/__health`); } catch (error) { - if ( - error instanceof Error && - (error.cause as any).code === 'ECONNREFUSED' - ) { + if (isConnectionRefusedError(error)) { return true; } } diff --git a/genkit-tools/common/tests/utils/errors_test.ts b/genkit-tools/common/tests/utils/errors_test.ts new file mode 100644 index 0000000000..d1f14b0100 --- /dev/null +++ b/genkit-tools/common/tests/utils/errors_test.ts @@ -0,0 +1,172 @@ +/** + * 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. + * 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 { describe, expect, it } from '@jest/globals'; +import { + getErrorCode, + getErrorDetails, + isConnectionRefusedError, +} from '../../src/utils/errors'; + +describe('errors.ts', () => { + describe('isConnectionRefusedError', () => { + it('should return false for null/undefined', () => { + expect(isConnectionRefusedError(null)).toBe(false); + expect(isConnectionRefusedError(undefined)).toBe(false); + }); + + it('should detect plain objects with connection error codes', () => { + expect(isConnectionRefusedError({ code: 'ECONNREFUSED' })).toBe(true); + expect(isConnectionRefusedError({ code: 'ConnectionRefused' })).toBe( + true + ); + expect(isConnectionRefusedError({ code: 'ECONNRESET' })).toBe(true); + expect(isConnectionRefusedError({ code: 'OTHER_ERROR' })).toBe(false); + }); + + it('should detect Error instances with direct code', () => { + const err = new Error('Connection failed'); + (err as any).code = 'ECONNREFUSED'; + expect(isConnectionRefusedError(err)).toBe(true); + + const err2 = new Error('Connection failed'); + (err2 as any).code = 'ConnectionRefused'; + expect(isConnectionRefusedError(err2)).toBe(true); + + const err3 = new Error('Connection failed'); + (err3 as any).code = 'ECONNRESET'; + expect(isConnectionRefusedError(err3)).toBe(true); + }); + + it('should detect Node.js style errors with cause', () => { + const err = new Error('Fetch failed'); + (err as any).cause = { code: 'ECONNREFUSED' }; + expect(isConnectionRefusedError(err)).toBe(true); + }); + + it('should fallback to checking error messages', () => { + expect( + isConnectionRefusedError( + new Error('connect ECONNREFUSED 127.0.0.1:3000') + ) + ).toBe(true); + expect( + isConnectionRefusedError(new Error('Connection refused to server')) + ).toBe(true); + expect( + isConnectionRefusedError( + new Error('ConnectionRefused: Unable to connect') + ) + ).toBe(true); + expect( + isConnectionRefusedError(new Error('Something else went wrong')) + ).toBe(false); + }); + + it('should handle complex nested structures', () => { + const err = new Error('Outer error'); + (err as any).cause = new Error('Inner error'); + (err as any).cause.code = 'ECONNREFUSED'; + expect(isConnectionRefusedError(err)).toBe(true); + }); + }); + + describe('getErrorCode', () => { + it('should return undefined for null/undefined', () => { + expect(getErrorCode(null)).toBeUndefined(); + expect(getErrorCode(undefined)).toBeUndefined(); + }); + + it('should extract code from plain objects', () => { + expect(getErrorCode({ code: 'ECONNREFUSED' })).toBe('ECONNREFUSED'); + expect(getErrorCode({ code: 'CUSTOM_ERROR' })).toBe('CUSTOM_ERROR'); + expect(getErrorCode({ message: 'No code here' })).toBeUndefined(); + }); + + it('should extract code from Error instances', () => { + const err = new Error('Test error'); + (err as any).code = 'TEST_CODE'; + expect(getErrorCode(err)).toBe('TEST_CODE'); + }); + + it('should extract code from cause property', () => { + const err = new Error('Outer error'); + (err as any).cause = { code: 'INNER_CODE' }; + expect(getErrorCode(err)).toBe('INNER_CODE'); + }); + + it('should prioritize direct code over cause code', () => { + const err = new Error('Test error'); + (err as any).code = 'DIRECT_CODE'; + (err as any).cause = { code: 'CAUSE_CODE' }; + expect(getErrorCode(err)).toBe('DIRECT_CODE'); + }); + + it('should handle non-string code values', () => { + expect(getErrorCode({ code: 123 })).toBeUndefined(); + expect(getErrorCode({ code: null })).toBeUndefined(); + expect(getErrorCode({ code: {} })).toBeUndefined(); + }); + }); + + describe('getErrorDetails', () => { + it('should return "Unknown error" for null/undefined', () => { + expect(getErrorDetails(null)).toBe('Unknown error'); + expect(getErrorDetails(undefined)).toBe('Unknown error'); + }); + + it('should format Error instances with code', () => { + const err = new Error('Connection failed'); + (err as any).code = 'ECONNREFUSED'; + expect(getErrorDetails(err)).toBe('Connection failed (ECONNREFUSED)'); + }); + + it('should format Error instances without code', () => { + const err = new Error('Simple error'); + expect(getErrorDetails(err)).toBe('Simple error'); + }); + + it('should format plain objects with message and code', () => { + expect(getErrorDetails({ message: 'Failed', code: 'ERR123' })).toBe( + 'Failed (ERR123)' + ); + expect(getErrorDetails({ message: 'No code here' })).toBe('No code here'); + }); + + it('should handle string errors', () => { + expect(getErrorDetails('String error')).toBe('String error'); + }); + + it('should handle number errors', () => { + expect(getErrorDetails(123)).toBe('123'); + }); + + it('should handle boolean errors', () => { + expect(getErrorDetails(true)).toBe('true'); + expect(getErrorDetails(false)).toBe('false'); + }); + + it('should handle objects without message', () => { + expect(getErrorDetails({ code: 'ERR_NO_MSG' })).toBe('[object Object]'); + }); + + it('should extract code from cause for formatting', () => { + const err = new Error('Outer error'); + (err as any).cause = { code: 'INNER_CODE' }; + expect(getErrorDetails(err)).toBe('Outer error (INNER_CODE)'); + }); + }); +}); diff --git a/genkit-tools/common/tsconfig.json b/genkit-tools/common/tsconfig.json index b63e7bcd20..035ea1b991 100644 --- a/genkit-tools/common/tsconfig.json +++ b/genkit-tools/common/tsconfig.json @@ -6,7 +6,8 @@ "outDir": "lib/esm", "esModuleInterop": true, "typeRoots": ["./node_modules/@types"], - "rootDirs": ["src"] + "rootDirs": ["src"], + "resolveJsonModule": true }, "include": ["src"] } diff --git a/genkit-tools/pnpm-lock.yaml b/genkit-tools/pnpm-lock.yaml index 7fa94f66f2..ee44519383 100644 --- a/genkit-tools/pnpm-lock.yaml +++ b/genkit-tools/pnpm-lock.yaml @@ -75,6 +75,9 @@ importers: '@types/node': specifier: ^20.11.19 version: 20.19.1 + bun-types: + specifier: ^1.2.16 + version: 1.2.16 genversion: specifier: ^3.2.0 version: 3.2.0 @@ -84,6 +87,9 @@ importers: ts-jest: specifier: ^29.1.2 version: 29.4.0(@babel/core@7.24.5)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.24.5))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.1)(ts-node@10.9.2(@types/node@20.19.1)(typescript@5.8.3)))(typescript@5.8.3) + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@20.19.1)(typescript@5.8.3) typescript: specifier: ^5.3.3 version: 5.8.3 @@ -202,6 +208,9 @@ importers: '@types/uuid': specifier: ^9.0.8 version: 9.0.8 + bun-types: + specifier: ^1.2.16 + version: 1.2.16 genversion: specifier: ^3.2.0 version: 3.2.0 @@ -1247,6 +1256,9 @@ packages: buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + bun-types@1.2.16: + resolution: {integrity: sha512-ciXLrHV4PXax9vHvUrkvun9VPVGOVwbbbBF/Ev1cXz12lyEZMoJpIJABOfPcN9gDJRaiKF9MVbSygLg4NXu3/A==} + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -4368,6 +4380,10 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + bun-types@1.2.16: + dependencies: + '@types/node': 20.19.1 + bytes@3.1.2: {} call-bind-apply-helpers@1.0.2: