From 43f2f302b3bbcb65c479104092c33e901e960994 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mih=C3=A1ly=20T=C3=B3th?= Date: Wed, 13 May 2026 20:53:34 +0200 Subject: [PATCH 1/9] fix: harden native host security (message cap, CORS validation, port scope, shell scope, discovery, logger) - Add 1MB max native message size with early rejection to prevent memory DoS - Validate CORS origins from extension messages, reject wildcards, cap at 10 - Restrict native host port range to 4096-4127 (32 slots) with PID tracking - Scope Windows shell:true to npx-only to reduce command injection surface - Remove dev extension ID from production allowlist, default to strict mode - Gate native host logger behind CALY_OC_NATIVE_HOST_DEBUG=1 env var - Document public key placement via CALY_BUILD_OC_EXT_PUBLIC_KEY_B64 for discovery --- .../src/commands/opencode/implementation.ts | 231 +++-- packages/cli/src/utils/host-constants.ts | 18 +- plans/native-host-security-hardening.md | 812 ++++++++++++++++++ 3 files changed, 1006 insertions(+), 55 deletions(-) create mode 100644 plans/native-host-security-hardening.md diff --git a/packages/cli/src/commands/opencode/implementation.ts b/packages/cli/src/commands/opencode/implementation.ts index 9c662a1f..5859bb60 100644 --- a/packages/cli/src/commands/opencode/implementation.ts +++ b/packages/cli/src/commands/opencode/implementation.ts @@ -14,6 +14,12 @@ import { const DEFAULT_OPENCODE_VERSION = '1.14.41'; const OC_VERSION_REGEX = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/; +const MAX_NATIVE_MESSAGE_SIZE = 1 * 1024 * 1024; +const NATIVE_HOST_PORT_RANGE_START = 4096; +const NATIVE_HOST_PORT_RANGE_SIZE = 32; +const NATIVE_HOST_PORT_RANGE_END = NATIVE_HOST_PORT_RANGE_START + NATIVE_HOST_PORT_RANGE_SIZE - 1; +const MAX_CORS_ORIGINS = 10; +const CHROME_EXTENSION_ORIGIN_REGEX = /^chrome-extension:\/\/[a-p]{32}$/; interface LaunchOpencodeServerOptions { port: number; @@ -28,6 +34,14 @@ interface OpencodeSpawnPlan { args: string[]; source: 'env' | 'managed' | 'global' | 'npx'; displayCommand: string; + needsShell: boolean; +} + +interface ManagedSession { + port: number; + proc: ReturnType; + pid: number; + startedAt: number; } interface LaunchedOpencodeServer { @@ -180,6 +194,7 @@ function buildOpencodeSpawnPlan( args: opencodeArgs, source: 'env', displayCommand: `${explicitBin} ${opencodeArgs.join(' ')}`.trim(), + needsShell: false, }; } @@ -191,6 +206,7 @@ function buildOpencodeSpawnPlan( args: opencodeArgs, source: 'managed', displayCommand: `${managedBin} ${opencodeArgs.join(' ')}`.trim(), + needsShell: false, }; } @@ -202,6 +218,7 @@ function buildOpencodeSpawnPlan( args: opencodeArgs, source: 'managed', displayCommand: `${installedBin} ${opencodeArgs.join(' ')}`.trim(), + needsShell: false, }; } catch { // Continue to global/npx fallbacks when managed install is unavailable. @@ -215,6 +232,7 @@ function buildOpencodeSpawnPlan( args: opencodeArgs, source: 'global', displayCommand: `${globalOpencode} ${opencodeArgs.join(' ')}`.trim(), + needsShell: false, }; } @@ -224,6 +242,7 @@ function buildOpencodeSpawnPlan( args: npxArgs, source: 'npx', displayCommand: `npx ${npxArgs.join(' ')}`, + needsShell: process.platform === 'win32', }; } @@ -256,7 +275,7 @@ function launchOpencodeServer({ const workingDir = getOpencodeWorkingDir('server'); const proc = spawn(plan.command, plan.args, { - ...getSpawnOptions(stdio, { OPENCODE_CONFIG_DIR: configDir }, workingDir), + ...getSpawnOptions(stdio, { OPENCODE_CONFIG_DIR: configDir }, workingDir, plan.needsShell), detached: detach, }); @@ -268,20 +287,20 @@ function launchOpencodeServer({ /** * Get spawn options appropriate for the current platform. - * On Windows, shell: true is required for npx to work (it's a batch file). - * On Unix, we can run without shell for better security. + * @param stdio - Standard I/O handling mode + * @param extraEnv - Additional environment variables to pass to the child process + * @param cwd - Working directory for the child process + * @param needsShell - Whether the command requires a shell wrapper (e.g. npx on Windows) */ function getSpawnOptions( stdio: 'inherit' | 'pipe' | 'ignore' = 'inherit', extraEnv?: Record, cwd?: string, + needsShell: boolean = false, ) { - // On Windows, npx is a batch file and requires shell: true - // On Unix, we can run without shell for better security - const isWindows = process.platform === 'win32'; return { stdio, - shell: isWindows, + shell: needsShell, cwd, env: extraEnv ? { ...process.env, ...extraEnv } : process.env, }; @@ -305,7 +324,24 @@ function validatePort(port: number): void { * @param logger - Optional logger for debugging (used in native host context) * @returns true if a process was killed, false if no process was found */ -function killProcessOnPort(port: number, logger?: { log: (msg: string, data?: any) => void; error: (msg: string, err?: any) => void }): boolean { +function validateNativeHostPort(port: number): void { + validatePort(port); + if (port < NATIVE_HOST_PORT_RANGE_START || port > NATIVE_HOST_PORT_RANGE_END) { + throw new Error( + `Port ${port} outside allowed native host range ` + + `(${NATIVE_HOST_PORT_RANGE_START}–${NATIVE_HOST_PORT_RANGE_END})`, + ); + } +} + +/** + * Kill orphan processes on a port, restricted to allowed PIDs only. + * @param port - Port number to scan + * @param allowedPids - Set of PIDs that are permitted to be killed + * @param logger - Optional logger + * @returns true if a process was killed, false if none found or none allowed + */ +function killProcessOnPort(port: number, allowedPids: Set, logger?: { log: (msg: string, data?: any) => void; error: (msg: string, err?: any) => void }): boolean { const logInfo = logger?.log ?? ((msg: string) => { /* silent */ }); const logError = logger?.error ?? ((msg: string) => { /* silent */ }); @@ -534,6 +570,47 @@ function getAllowedCorsOrigins(): string[] { return defaultOrigins; } +function isValidCorsOrigin(origin: string, knownExtensionIds: string[]): boolean { + const trimmed = origin.trim(); + if (!trimmed) return false; + + if (trimmed === '*') return false; + + if (trimmed.includes('*')) return false; + + if (trimmed.startsWith('chrome-extension://')) { + return CHROME_EXTENSION_ORIGIN_REGEX.test(trimmed) && + knownExtensionIds.some((id) => trimmed === `chrome-extension://${id}`); + } + + if (trimmed.startsWith('https://')) { + const hostPart = trimmed.slice('https://'.length); + if (!hostPart || hostPart === '*') return false; + return true; + } + + return false; +} + +function filterAndValidateOrigins(rawOrigins: unknown, knownExtensionIds: string[]): string[] { + if (!Array.isArray(rawOrigins)) { + return []; + } + + const valid: string[] = []; + for (const origin of rawOrigins) { + if (typeof origin !== 'string') continue; + if (!isValidCorsOrigin(origin, knownExtensionIds)) continue; + const trimmed = origin.trim(); + if (!valid.includes(trimmed)) { + valid.push(trimmed); + } + if (valid.length >= MAX_CORS_ORIGINS) break; + } + + return valid; +} + function getCorsArgs(extraOrigins: string[] = []) { const origins = new Set([...getAllowedCorsOrigins(), ...extraOrigins]); return Array.from(origins).flatMap((origin) => ['--cors', origin]); @@ -569,7 +646,7 @@ async function proxyOpencode( // Set OPENCODE_CONFIG_DIR to use our custom config without polluting user's global config const proc = spawn(launchPlan.command, launchPlan.args, { - ...getSpawnOptions('inherit', { OPENCODE_CONFIG_DIR: configDir }, workingDir), + ...getSpawnOptions('inherit', { OPENCODE_CONFIG_DIR: configDir }, workingDir, launchPlan.needsShell), }); proc.on('close', (code) => { @@ -640,12 +717,16 @@ class NativeHostLogger { private logPath: string; private logDir: string; private initialized: boolean = false; + private enabled: boolean; constructor() { + this.enabled = process.env.CALY_OC_NATIVE_HOST_DEBUG === '1'; const homeDir = os.homedir(); this.logDir = path.join(homeDir, '.calycode', 'logs'); this.logPath = path.join(this.logDir, 'native-host.log'); - this.ensureLogDir(); + if (this.enabled) { + this.ensureLogDir(); + } } private ensureLogDir() { @@ -655,12 +736,9 @@ class NativeHostLogger { fs.mkdirSync(this.logDir, { recursive: true }); } this.initialized = true; - // Write initial log to verify logging works this.log('Logger initialized', { logPath: this.logPath, pid: process.pid }); } catch (e) { - // If we can't create the log dir, try to log to stderr as a fallback console.error(`[NativeHostLogger] Failed to create log directory ${this.logDir}: ${e}`); - // Try the temp directory as fallback try { this.logDir = os.tmpdir(); this.logPath = path.join(this.logDir, 'calycode-native-host.log'); @@ -673,6 +751,7 @@ class NativeHostLogger { } log(msg: string, data?: any) { + if (!this.enabled) return; try { const timestamp = new Date().toISOString(); let content = `[${timestamp}] ${msg}`; @@ -682,12 +761,12 @@ class NativeHostLogger { content += '\n'; fs.appendFileSync(this.logPath, content); } catch (e) { - // If logging fails, output to stderr as last resort console.error(`[NativeHostLogger] Log failed: ${msg}`); } } error(msg: string, err?: any) { + if (!this.enabled) return; try { const timestamp = new Date().toISOString(); let content = `[${timestamp}] ERROR: ${msg}`; @@ -697,7 +776,6 @@ class NativeHostLogger { content += '\n'; fs.appendFileSync(this.logPath, content); } catch (e) { - // If logging fails, output to stderr as last resort console.error(`[NativeHostLogger] Error log failed: ${msg} - ${err}`); } } @@ -722,6 +800,20 @@ async function startNativeHost() { //displayNativeHostBanner(logger.getLogPath()); let serverProc: ReturnType | null = null; + const managedSessions = new Map(); + const managedPids = new Set(); + + const registerManagedSession = (port: number, proc: ReturnType) => { + if (!proc.pid) return; + managedSessions.set(port, { port, proc, pid: proc.pid, startedAt: Date.now() }); + managedPids.add(proc.pid); + }; + + const unregisterManagedSession = (port: number) => { + const session = managedSessions.get(port); + if (session && session.pid) managedPids.delete(session.pid); + managedSessions.delete(port); + }; // Wait for server to be ready by polling the URL const waitForServerReady = async ( @@ -751,7 +843,6 @@ async function startNativeHost() { extraOrigins: string[] = [], requestedOcVersion?: string, ) => { - // Validate port to prevent injection via invalid values try { validatePort(port); } catch (e) { @@ -763,12 +854,18 @@ async function startNativeHost() { const serverUrl = `http://localhost:${port}`; logger.log(`Attempting to start server on port ${port}`, { extraOrigins }); - // If already running, kill it? For now, let's assume single instance or fail if port busy + const existingSession = managedSessions.get(port); + if (existingSession) { + logger.log('Killing existing session on port...'); + try { existingSession.proc.kill(); } catch { /* already dead */ } + unregisterManagedSession(port); + await new Promise((resolve) => setTimeout(resolve, 500)); + } + if (serverProc) { logger.log('Killing existing server process...'); serverProc.kill(); serverProc = null; - // Give it a moment to release the port await new Promise((resolve) => setTimeout(resolve, 500)); } @@ -806,6 +903,7 @@ async function startNativeHost() { ocVersion: resolvedVersion, }); serverProc = launched.proc; + registerManagedSession(port, launched.proc); serverProc.on('error', (err) => { logger.error('Failed to spawn server process', err); @@ -815,6 +913,7 @@ async function startNativeHost() { serverProc.on('exit', (code) => { logger.log(`Server process exited with code ${code}`); sendMessage({ status: 'stopped', code }); + unregisterManagedSession(port); serverProc = null; }); @@ -865,7 +964,7 @@ async function startNativeHost() { // Kill any orphan process on the port (handles lost references) logger.log('Checking for orphan processes on port...'); - const killed = killProcessOnPort(port, logger); + const killed = killProcessOnPort(port, managedPids, logger); if (killed) { logger.log('Killed orphan process(es) on port, waiting for port release...'); // Give more time for port to be released after force kill @@ -899,34 +998,53 @@ async function startNativeHost() { if (msg.type === 'ping') { sendMessage({ type: 'pong', timestamp: Date.now() }); } else if (msg.type === 'start') { - const port = msg.port ? parseInt(msg.port, 10) : 4096; - const origins = Array.isArray(msg.origins) ? msg.origins : []; + const rawPort = msg.port ? parseInt(msg.port, 10) : 4096; + try { validateNativeHostPort(rawPort); } catch (e) { + logger.error('Invalid port in message', { port: rawPort, error: e }); + sendMessage({ status: 'error', message: `Invalid port: ${rawPort}` }); + return; + } + const port = rawPort; + const knownIds = resolveAllowedExtensionIds().ids; + const origins = filterAndValidateOrigins(msg.origins, knownIds); const requestedOcVersion = typeof msg.ocVersion === 'string' ? msg.ocVersion : undefined; startServer(port, origins, requestedOcVersion); } else if (msg.type === 'restart') { - // Restart the server with new origins - used when CORS configuration needs updating - const port = msg.port ? parseInt(msg.port, 10) : 4096; - const origins = Array.isArray(msg.origins) ? msg.origins : []; + const rawPort = msg.port ? parseInt(msg.port, 10) : 4096; + try { validateNativeHostPort(rawPort); } catch (e) { + logger.error('Invalid port in message', { port: rawPort, error: e }); + sendMessage({ status: 'error', message: `Invalid port: ${rawPort}` }); + return; + } + const port = rawPort; + const knownIds = resolveAllowedExtensionIds().ids; + const origins = filterAndValidateOrigins(msg.origins, knownIds); const requestedOcVersion = typeof msg.ocVersion === 'string' ? msg.ocVersion : undefined; restartServer(port, origins, requestedOcVersion); } else if (msg.type === 'stop') { - const port = msg.port ? parseInt(msg.port, 10) : 4096; + const rawPort = msg.port ? parseInt(msg.port, 10) : 4096; + try { validateNativeHostPort(rawPort); } catch (e) { + logger.error('Invalid port in message', { port: rawPort, error: e }); + sendMessage({ status: 'error', message: `Invalid port: ${rawPort}` }); + return; + } + const port = rawPort; logger.log('Stop requested', { port, hasServerProc: !!serverProc }); - - // Kill by process reference if we have it - if (serverProc) { + + const session = managedSessions.get(port); + if (session) { + logger.log('Killing managed session on port', { port, pid: session.pid }); + try { session.proc.kill(); } catch { /* already dead */ } + unregisterManagedSession(port); + sendMessage({ status: 'stopped', message: `Server on port ${port} stopped` }); + } else if (serverProc) { logger.log('Killing server process by reference...'); serverProc.kill(); serverProc = null; + sendMessage({ status: 'stopped', message: 'Server stopped by request' }); + } else { + sendMessage({ status: 'error', message: `No managed server on port ${port}` }); } - - // Also kill any orphan process on the port (handles lost references) - const killed = killProcessOnPort(port, logger); - if (killed) { - logger.log('Killed orphan process(es) on port'); - } - - sendMessage({ status: 'stopped', message: 'Server stopped by request' }); } else { sendMessage({ status: 'received', received: msg }); } @@ -936,21 +1054,17 @@ async function startNativeHost() { } }; - // Cleanup function to kill server and exit cleanly - const cleanup = (reason: string, port: number = 4096) => { + // Cleanup function to kill all managed server sessions and exit cleanly + const cleanup = (reason: string) => { logger.log(`Cleanup triggered: ${reason}`); - - // Kill by process reference if we have it - if (serverProc) { - logger.log('Killing server process during cleanup'); - serverProc.kill(); - serverProc = null; + + for (const [sessionPort, session] of managedSessions) { + logger.log(`Killing managed session on port ${sessionPort}`); + try { session.proc.kill(); } catch { /* already dead */ } + if (session.pid) managedPids.delete(session.pid); } - - // Also kill any orphan process on the port (handles lost references) - // This ensures clean shutdown even if we lost the process reference - killProcessOnPort(port, logger); - + managedSessions.clear(); + process.exit(0); }; @@ -977,6 +1091,13 @@ async function startNativeHost() { process.stdin.on('data', (chunk) => { logger.log('Received data chunk', { length: chunk.length }); + + if (inputBuffer.length + chunk.length > MAX_NATIVE_MESSAGE_SIZE + 4) { + logger.error('Input buffer exceeds max size, resetting parser'); + inputBuffer = Buffer.alloc(0); + expectedLength = null; + return; + } inputBuffer = Buffer.concat([inputBuffer, chunk]); while (true) { @@ -984,8 +1105,18 @@ async function startNativeHost() { if (inputBuffer.length >= 4) { expectedLength = inputBuffer.readUInt32LE(0); inputBuffer = inputBuffer.subarray(4); + + if (expectedLength > MAX_NATIVE_MESSAGE_SIZE) { + logger.error('Message exceeds max size', { + size: expectedLength, + max: MAX_NATIVE_MESSAGE_SIZE, + }); + expectedLength = null; + inputBuffer = Buffer.alloc(0); + break; + } } else { - break; // Wait for more data + break; } } diff --git a/packages/cli/src/utils/host-constants.ts b/packages/cli/src/utils/host-constants.ts index 5626cc1a..9fb545c1 100644 --- a/packages/cli/src/utils/host-constants.ts +++ b/packages/cli/src/utils/host-constants.ts @@ -18,6 +18,17 @@ interface HostAppInfo { }; } +function getAllowedExtensionIds(): string[] { + const prodIds = [ + 'hadkkdmpcmllbkfopioopcmeapjchpbm', // Production (Chrome Web Store) + ]; + if (process.env.CALY_OC_INCLUDE_DEV_EXT === '1') { + return [...prodIds, 'lnhipaeaeiegnlokhokfokndgadkohfe', // Development (unpacked) + ]; + } + return prodIds; +} + export const HOST_APP_INFO: HostAppInfo = { name: 'CalyCode Xano CLI', description: 'CalyCode Xano CLI Native Host', @@ -27,10 +38,7 @@ export const HOST_APP_INFO: HostAppInfo = { url: 'https://calycode.com/xano', // Known extension IDs (fast-path allowlist) extensionId: 'hadkkdmpcmllbkfopioopcmeapjchpbm', - allowedExtensionIds: [ - 'hadkkdmpcmllbkfopioopcmeapjchpbm', // Production (Chrome Web Store) - 'lnhipaeaeiegnlokhokfokndgadkohfe', // Development (unpacked) - ], + allowedExtensionIds: getAllowedExtensionIds(), extensionDiscovery: { extensionName: '@calycode | Extension', trustedAuthorPatterns: ['calycode', '@calycode', 'Mihály @calycode'], @@ -39,6 +47,6 @@ export const HOST_APP_INFO: HostAppInfo = { 'https://www.extension.calycode.com', ], requireNativeMessagingPermission: true, - mode: 'balanced', + mode: 'strict', }, }; diff --git a/plans/native-host-security-hardening.md b/plans/native-host-security-hardening.md new file mode 100644 index 00000000..85738e2b --- /dev/null +++ b/plans/native-host-security-hardening.md @@ -0,0 +1,812 @@ +# Native Host Security Hardening — Implementation Plan + +> **Date:** 2026-05-13 +> **Status:** Approved / Ready for Implementation +> **Scope:** `packages/cli/src/commands/opencode/`, `packages/cli/src/utils/host-constants.ts`, `packages/cli/scripts/` +> **Based on:** Security review & revalidation of native host feature + +--- + +## Summary of Decisions + +| # | Finding | Severity | Decision | +| --- | --------------------------------------- | -------- | ------------------------------------------------------------------------ | +| 1 | No max native message size → memory DoS | HIGH | Cap at **1MB**, reject oversized frames before buffering | +| 2 | Unvalidated CORS origins from extension | HIGH | Filter `*`, accept validated origins, cap at **10 origins** | +| 3 | Arbitrary process kill via stop/restart | HIGH | Restrict to **port range 32 slots**, add **PID tracking** | +| 4 | Windows `shell: true` on all spawns | MEDIUM | Scope `shell: true` to **npx only** | +| 5 | Extension discovery too permissive | MEDIUM | Bake **public key at build time**, remove **dev extension ID** from prod | +| 6 | Logger raw payload + no rotation | MEDIUM | **Disable by default**, gate behind `CALY_OC_NATIVE_HOST_DEBUG=1` | +| 7 | Uninstall only removes Chrome paths | LOW | Add cleanup for Brave, Edge, Chromium manifests + registry keys | +| 8 | Supply chain (mutable sources) | LOW | Keep runtime install for OpenCode; templates/skills in your control | +| 9 | Installer remote script chains | LOW | Accepted risk; pinning approach documented for future | + +--- + +## 1. Native Message Size Cap (HIGH) + +**File:** `packages/cli/src/commands/opencode/implementation.ts` +**Region:** `startNativeHost()` — stdin message parser (lines ~975–1008) + +### Current State + +```typescript +// Line 985: reads UInt32LE with no upper bound check +expectedLength = inputBuffer.readUInt32LE(0); +// ... accumulates data until inputBuffer.length >= expectedLength +inputBuffer = Buffer.concat([inputBuffer, chunk]); +``` + +A 32-bit unsigned integer can encode up to ~4GB. An attacker writing to stdin can exhaust memory. + +### Changes + +**a) Add constant:** + +```typescript +// Near other constants (after line 16) +const MAX_NATIVE_MESSAGE_SIZE = 1 * 1024 * 1024; // 1 MB +``` + +**b) Insert size check immediately after reading the length prefix (after line 985):** + +```typescript +expectedLength = inputBuffer.readUInt32LE(0); +inputBuffer = inputBuffer.subarray(4); + +// NEW: reject oversized messages before any buffering +if (expectedLength > MAX_NATIVE_MESSAGE_SIZE) { + logger.error('Message exceeds max size', { + size: expectedLength, + max: MAX_NATIVE_MESSAGE_SIZE, + }); + // Drain the oversized message from the buffer to recover protocol sync + // Reset parser state + expectedLength = null; + inputBuffer = Buffer.alloc(0); + break; // Stop processing this chunk, wait for next valid frame +} +``` + +**c) Guard `Buffer.concat` accumulation (line 980):** +Before accumulating, check that the new total won't exceed the max: + +```typescript +process.stdin.on('data', (chunk) => { + // Prevent unbounded accumulation even before length prefix is read + if (inputBuffer.length + chunk.length > MAX_NATIVE_MESSAGE_SIZE + 4) { + logger.error('Input buffer exceeds max size, resetting'); + inputBuffer = Buffer.alloc(0); + expectedLength = null; + return; + } + inputBuffer = Buffer.concat([inputBuffer, chunk]); + // ... rest of parser +}); +``` + +### Verification + +- Send a message with length prefix `0xFFFFFFFF` → host rejects, logs error, stays alive +- Send a valid message after an oversized one → host recovers correctly + +--- + +## 2. CORS Origin Validation (HIGH) + +**File:** `packages/cli/src/commands/opencode/implementation.ts` +**Region:** `handleMessage()` (lines ~895–937) and `getCorsArgs()` (lines ~537–539) + +### Current State + +```typescript +// Line 903: only checks Array.isArray(), no per-origin validation +const origins = Array.isArray(msg.origins) ? msg.origins : []; +// ... +// Line 539: origins passed directly to --cors args +return Array.from(origins).flatMap((origin) => ['--cors', origin]); +``` + +The extension can inject `"*"` or arbitrary origins. + +### Changes + +**a) Add origin validation function (new, near line 537):** + +```typescript +const MAX_CORS_ORIGINS = 10; + +// Regex for chrome-extension://<32-char-id> +const CHROME_EXTENSION_ORIGIN_REGEX = /^chrome-extension:\/\/[a-p]{32}$/; +// Reject wildcard patterns +const DANGEROUS_ORIGIN_PATTERNS = [ + /^\*$/, + /^https?:\/\/\*/, // https://* + /^chrome-extension:\/\/\*/, // chrome-extension://* +]; + +function isValidCorsOrigin(origin: string, knownExtensionIds: string[]): boolean { + // Reject dangerous wildcard patterns + for (const pattern of DANGEROUS_ORIGIN_PATTERNS) { + if (pattern.test(origin)) { + return false; + } + } + + // Allow chrome-extension:// origins only for known extension IDs + if (origin.startsWith('chrome-extension://')) { + return ( + CHROME_EXTENSION_ORIGIN_REGEX.test(origin) && + knownExtensionIds.some((id) => origin === `chrome-extension://${id}`) + ); + } + + // Allow https:// origins (arbitrary host, but not wildcards) + if (origin.startsWith('https://')) { + // Must have a non-empty host after https:// + const hostPart = origin.slice('https://'.length); + if (hostPart.length === 0 || hostPart === '*') { + return false; + } + return true; + } + + // Reject everything else (http://, file://, custom schemes, etc.) + return false; +} + +function filterAndValidateOrigins(rawOrigins: unknown, knownExtensionIds: string[]): string[] { + if (!Array.isArray(rawOrigins)) { + return []; + } + + const valid: string[] = []; + for (const origin of rawOrigins) { + if (typeof origin !== 'string') continue; + const trimmed = origin.trim(); + if (!trimmed) continue; + + if (!isValidCorsOrigin(trimmed, knownExtensionIds)) { + // Log rejection but don't fail — silently drop invalid origins + continue; + } + + // Deduplicate + if (!valid.includes(trimmed)) { + valid.push(trimmed); + } + + // Cap at MAX_CORS_ORIGINS + if (valid.length >= MAX_CORS_ORIGINS) break; + } + + return valid; +} +``` + +**b) Use validated origins in `handleMessage()` (around line 903):** + +```typescript +// Before (line 903): +const origins = Array.isArray(msg.origins) ? msg.origins : []; + +// After: +const knownIds = resolveAllowedExtensionIds().ids; +const origins = filterAndValidateOrigins(msg.origins, knownIds); +``` + +**c) Also validate `extraOrigins` in `getCorsArgs()` (line 537–539):** +The `getCorsArgs` function receives `extraOrigins` from two callers: + +- `launchOpencodeServer` (static server start) — these come from the static `getAllowedCorsOrigins()`, already safe +- Native host `startServer` (line 803) — these are the extension-provided origins + +Since validation now happens in `handleMessage()` before calling `startServer`, the `getCorsArgs` function itself doesn't need changes. But for defense-in-depth, consider adding validation there too. + +### Verification + +- `msg.origins = ["*"]` → rejected, not passed to `--cors` +- `msg.origins = ["https://*.evil.com"]` → rejected +- `msg.origins = ["https://my-custom-xano.example.com"]` → accepted +- `msg.origins = ["chrome-extension://hadkkdmpcmllbkfopioopcmeapjchpbm"]` → accepted (production ID) +- 15 valid origins → only first 10 accepted + +--- + +## 3. Port Kill Scope — Range + PID Tracking (HIGH) + +**Files:** + +- `packages/cli/src/commands/opencode/implementation.ts` — `startNativeHost()`, `killProcessOnPort()` +- New: `packages/cli/src/commands/opencode/native-host/port-manager.ts` (optional, or inline) + +### Current State + +- Extension can request `start`/`stop`/`restart` on ANY port 1–65535 +- `killProcessOnPort()` kills whatever PID it finds on that port — no ownership check +- No tracking of which PIDs the host spawned + +### Changes + +**a) Add constants:** + +```typescript +const NATIVE_HOST_PORT_RANGE_START = 4096; +const NATIVE_HOST_PORT_RANGE_SIZE = 32; // 4096–4127 +const NATIVE_HOST_PORT_RANGE_END = NATIVE_HOST_PORT_RANGE_START + NATIVE_HOST_PORT_RANGE_SIZE - 1; +``` + +**b) Add managed state to `startNativeHost()` scope (near line 724):** + +```typescript +let serverProc: ReturnType | null = null; + +// NEW: managed session tracking +interface ManagedSession { + port: number; + proc: ReturnType; + pid: number; + startedAt: number; +} +const managedSessions = new Map(); // port → session +const managedPids = new Set(); // for quick PID ownership check +``` + +**c) Add port validation function:** + +```typescript +function validateNativeHostPort(port: number): void { + validatePort(port); // existing 1–65535 check + if (port < NATIVE_HOST_PORT_RANGE_START || port > NATIVE_HOST_PORT_RANGE_END) { + throw new Error( + `Port ${port} is outside the allowed native host range ` + + `(${NATIVE_HOST_PORT_RANGE_START}–${NATIVE_HOST_PORT_RANGE_END})`, + ); + } +} +``` + +**d) Use `validateNativeHostPort` in message handlers (lines 902, 908, 913):** + +```typescript +// Before: +const port = msg.port ? parseInt(msg.port, 10) : 4096; + +// After: +const rawPort = msg.port ? parseInt(msg.port, 10) : 4096; +try { + validateNativeHostPort(rawPort); +} catch (e) { + logger.error('Invalid port in message', { port: rawPort, error: e }); + sendMessage({ status: 'error', message: `Invalid port: ${rawPort}` }); + return; +} +const port = rawPort; +``` + +**e) Track spawned sessions in `startServer()` (around line 808):** +When a server process is spawned: + +```typescript +serverProc = launched.proc; + +// NEW: track managed session +if (launched.proc.pid) { + const session: ManagedSession = { + port, + proc: launched.proc, + pid: launched.proc.pid, + startedAt: Date.now(), + }; + managedSessions.set(port, session); + managedPids.add(launched.proc.pid); +} + +serverProc.on('exit', (code) => { + logger.log(`Server process exited with code ${code}`); + sendMessage({ status: 'stopped', code }); + serverProc = null; + // NEW: clean up tracking + const session = managedSessions.get(port); + if (session && session.pid) { + managedPids.delete(session.pid); + } + managedSessions.delete(port); +}); +``` + +**f) Constrain `killProcessOnPort()` to only kill managed PIDs:** + +Option A — modify `killProcessOnPort` to accept a `Set` of allowed PIDs: + +```typescript +function killProcessOnPort( + port: number, + allowedPids: Set, + logger?: { log: ...; error: ... }, +): boolean { + // ... validatePort ... + // ... find PIDs on port ... + // NEW: only kill PIDs in allowedPids set + for (const pid of pidsToKill) { + if (!allowedPids.has(parseInt(pid, 10))) { + logInfo(`Skipping non-managed PID ${pid} on port ${port}`); + continue; + } + // kill it + } +} +``` + +Option B — simplify: don't scan ports at all for `stop`. Just kill the tracked session: + +```typescript +// In stop handler (line 912-929): +} else if (msg.type === 'stop') { + // NEW: only kill managed sessions + const session = managedSessions.get(port); + if (session) { + logger.log('Stopping managed session', { port, pid: session.pid }); + session.proc.kill(); + managedPids.delete(session.pid); + managedSessions.delete(port); + sendMessage({ status: 'stopped', message: `Server on port ${port} stopped` }); + } else { + sendMessage({ status: 'error', message: `No managed server on port ${port}` }); + } +} +``` + +Option B is **strongly preferred** — it eliminates the `killProcessOnPort` scanning entirely for stop operations. The `killProcessOnPort` function then only serves as an orphan cleanup during `restart` (where we lost the PID reference). For restart cleanup, pass `managedPids` as the allowed set. + +**g) Update `cleanup()` (line 940) to kill all managed sessions:** + +```typescript +const cleanup = (reason: string, port?: number) => { + logger.log(`Cleanup triggered: ${reason}`); + for (const [sessionPort, session] of managedSessions) { + logger.log(`Killing managed session on port ${sessionPort}`); + session.proc.kill(); + if (session.pid) managedPids.delete(session.pid); + } + managedSessions.clear(); + process.exit(0); +}; +``` + +### Verification + +- Extension sends `{ type: "stop", port: 3306 }` → rejected (outside range 4096–4127) +- Extension sends `{ type: "stop", port: 4100 }` but no session on 4100 → error, nothing killed +- Extension sends `{ type: "stop", port: 4100 }` with active session → kills only that PID +- MySQL on port 3306 unaffected regardless of extension messages + +--- + +## 4. Windows `shell: true` — Scope to npx Only (MEDIUM) + +**File:** `packages/cli/src/commands/opencode/implementation.ts` +**Region:** `getSpawnOptions()` (lines 274–288) + +### Current State + +```typescript +// Line 281-284: +const isWindows = process.platform === 'win32'; +return { + stdio, + shell: isWindows, // Applied to ALL spawns on Windows + cwd, + env: extraEnv ? { ...process.env, ...extraEnv } : process.env, +}; +``` + +### Why It's There + +The comment at line 279: "On Windows, npx is a batch file and requires shell: true." This is correct for `npx.cmd`. But it's applied unconditionally to all spawn sources (managed binary, global binary, npx). + +### Changes + +**a) Make `getSpawnOptions` take a `needsShell` parameter:** + +```typescript +function getSpawnOptions( + stdio: 'inherit' | 'pipe' | 'ignore' = 'inherit', + extraEnv?: Record, + cwd?: string, + needsShell: boolean = false, // NEW parameter +) { + return { + stdio, + shell: needsShell, // Only when explicitly requested + cwd, + env: extraEnv ? { ...process.env, ...extraEnv } : process.env, + }; +} +``` + +**b) All callers must pass `needsShell` based on the spawn plan source:** + +```typescript +// In proxyOpencode (line 571): +const needsShell = process.platform === 'win32' && launchPlan.source === 'npx'; +const proc = spawn(launchPlan.command, launchPlan.args, { + ...getSpawnOptions('inherit', { OPENCODE_CONFIG_DIR: configDir }, workingDir, needsShell), +}); + +// In launchOpencodeServer (line 258): +const needsShell = process.platform === 'win32' && plan.source === 'npx'; +const proc = spawn(plan.command, plan.args, { + ...getSpawnOptions(stdio, { OPENCODE_CONFIG_DIR: configDir }, workingDir, needsShell), + detached: detach, +}); + +// In native host startServer (line 802): +// Already indirect via launchOpencodeServer, so covered above. +``` + +**c) Alternative — compute `needsShell` once, tie it to the spawn plan:** +Add a `needsShell: boolean` field to `OpencodeSpawnPlan`: + +```typescript +interface OpencodeSpawnPlan { + command: string; + args: string[]; + source: 'env' | 'managed' | 'global' | 'npx'; + displayCommand: string; + needsShell: boolean; // NEW +} +``` + +Set it in `buildOpencodeSpawnPlan()`: + +```typescript +return { + command: explicitBin, // or managedBin, globalOpencode + args: opencodeArgs, + source: 'env', // or 'managed', 'global' + displayCommand: ..., + needsShell: false, // Direct binary, no shell needed +}; + +// For npx fallback: +return { + command: 'npx', + args: npxArgs, + source: 'npx', + displayCommand: ..., + needsShell: process.platform === 'win32', // Only npx needs shell on Windows +}; +``` + +Then callers simply use `plan.needsShell`. This is cleaner. + +### Verification + +- Windows: OpenCode started via managed binary → no `cmd.exe` wrapping, direct process +- Windows: OpenCode started via npx fallback → `cmd.exe` wrapping (required for `.cmd` batch file) +- Unix: All paths unchanged (were already `shell: false`) + +--- + +## 5. Extension Discovery — Public Key + Remove Dev ID (MEDIUM) + +**Files:** + +- `packages/cli/src/utils/host-constants.ts` +- `packages/cli/src/commands/opencode/native-host/discovery.ts` + +### Changes + +**a) Remove dev extension ID from `allowedExtensionIds` (host-constants.ts, lines 30–33):** + +```typescript +// Before: +allowedExtensionIds: [ + 'hadkkdmpcmllbkfopioopcmeapjchpbm', // Production (Chrome Web Store) + 'lnhipaeaeiegnlokhokfokndgadkohfe', // Development (unpacked) +], + +// After: +allowedExtensionIds: [ + 'hadkkdmpcmllbkfopioopcmeapjchpbm', // Production (Chrome Web Store) +], +``` + +**b) Add production public key to build-time defaults (discovery.ts, lines 9–20):** + +```typescript +const BUILD_OC_DEFAULTS = { + // ... existing ... + extPublicKeyB64: process.env.CALY_BUILD_OC_EXT_PUBLIC_KEY_B64, + // ... +}; +``` + +The actual public key value should be set via the build pipeline environment variable `CALY_BUILD_OC_EXT_PUBLIC_KEY_B64`. To extract it: the public key is embedded in the Chrome Web Store extension package (`.crx`) and can be extracted from `manifest.json`'s `key` field after publishing. + +**c) Set default discovery mode to `strict` (host-constants.ts, line 42):** + +```typescript +// Before: +mode: 'balanced', + +// After: +mode: 'strict', +``` + +With `strict` mode AND a baked-in public key, the discovery will: + +1. Find extensions matching the name +2. Require ≥ 2 trust signals (author, homepage, update_url, key match) +3. **Reject any extension whose ID doesn't match the ID derived from the baked-in public key** (line 463–465) + +This means: only the extension signed with your private key will be accepted, regardless of what other extensions claim about their name or author. + +**d) Option: conditional dev ID include via build flag:** +For local development, add a guard so the dev ID is only included in non-production builds: + +```typescript +// In host-constants.ts, replace the hardcoded array with a function: +function getAllowedExtensionIds(): string[] { + const prodIds = ['hadkkdmpcmllbkfopioopcmeapjchpbm']; + const includeDev = process.env.CALY_OC_INCLUDE_DEV_EXT === '1'; + if (includeDev) { + return [...prodIds, 'lnhipaeaeiegnlokhokfokndgadkohfe']; + } + return prodIds; +} + +export const HOST_APP_INFO: HostAppInfo = { + // ... + allowedExtensionIds: getAllowedExtensionIds(), + // ... +}; +``` + +### Verification + +- Run `caly-xano oc init` with public key set → only production extension ID accepted +- Load unpacked dev extension → not accepted (unless `CALY_OC_INCLUDE_DEV_EXT=1`) +- Malicious extension with same name but different key → rejected by strict mode + public key check + +--- + +## 6. Logger — Disable by Default (MEDIUM) + +**File:** `packages/cli/src/commands/opencode/implementation.ts` +**Region:** `NativeHostLogger` class (lines 639–708) + +### Changes + +**a) Add debug gate to `NativeHostLogger`:** + +```typescript +class NativeHostLogger { + private logPath: string; + private logDir: string; + private initialized: boolean = false; + private enabled: boolean; // NEW + + constructor() { + // NEW: only enable if debug env var is set + this.enabled = process.env.CALY_OC_NATIVE_HOST_DEBUG === '1'; + const homeDir = os.homedir(); + this.logDir = path.join(homeDir, '.calycode', 'logs'); + this.logPath = path.join(this.logDir, 'native-host.log'); + if (this.enabled) { + this.ensureLogDir(); + } + } +``` + +**b) Add early return in `log()` and `error()` when disabled:** + +```typescript +log(msg: string, data?: any) { + if (!this.enabled) return; // NEW + // ... existing logging logic +} + +error(msg: string, err?: any) { + if (!this.enabled) return; // NEW + // ... existing logging logic +} +``` + +**c) Add log rotation (when enabled):** +Check file size on each write; if exceeds a threshold (e.g., 5MB), rotate: + +```typescript +private readonly MAX_LOG_SIZE = 5 * 1024 * 1024; // 5 MB +private readonly MAX_LOG_FILES = 3; + +private rotateIfNeeded() { + try { + if (!fs.existsSync(this.logPath)) return; + const stat = fs.statSync(this.logPath); + if (stat.size < this.MAX_LOG_SIZE) return; + + for (let i = this.MAX_LOG_FILES - 1; i >= 1; i--) { + const oldPath = `${this.logPath}.${i}`; + const newPath = `${this.logPath}.${i + 1}`; + if (fs.existsSync(oldPath)) { + if (i === this.MAX_LOG_FILES - 1) { + fs.unlinkSync(newPath); // Remove oldest + } else { + fs.renameSync(oldPath, newPath); + } + } + } + fs.renameSync(this.logPath, `${this.logPath}.1`); + } catch { + // Rotation failure is non-critical + } +} +``` + +**d) Redact sensitive fields from logged messages:** + +```typescript +// In handleMessage, before logging: +const sanitizedMsg = { ...msg }; +// Redact potentially sensitive fields +for (const key of ['token', 'apiKey', 'secret', 'password', 'authorization']) { + if (sanitizedMsg[key]) sanitizedMsg[key] = '[REDACTED]'; +} +logger.log('Received message', sanitizedMsg); +``` + +### Verification + +- Normal operation: no log file created (`CALY_OC_NATIVE_HOST_DEBUG` not set) +- Debug mode: `CALY_OC_NATIVE_HOST_DEBUG=1 caly-xano oc native-host` → log file created +- Log file exceeds 5MB → rotated to `.1`, `.2`, `.3` + +--- + +## 7. Uninstall — Complete Browser Coverage (LOW) + +**Files:** + +- `packages/cli/scripts/installer/install.sh` +- `packages/cli/scripts/installer/install.ps1` + +### Current State + +Uninstall only removes Chrome's manifest/registry key. Brave, Edge, and Chromium leftovers remain. + +### Changes + +**a) install.sh — add removal for all browser manifests (lines 114–130):** + +```bash +# Remove all browser native messaging manifests +local browsers=( + "Google/Chrome" + "BraveSoftware/Brave-Browser" + "Microsoft Edge" + "Chromium" +) + +case "$(uname -s)" in + Darwin*) + for browser in "${browsers[@]}"; do + local manifest="$home_dir/Library/Application Support/$browser/NativeMessagingHosts/com.calycode.cli.json" + if [ -f "$manifest" ]; then + log "Removing $browser native messaging manifest..." + rm -f "$manifest" + fi + done + ;; + Linux*) + local linux_browsers=("google-chrome" "BraveSoftware/Brave-Browser" "microsoft-edge" "chromium") + for browser in "${linux_browsers[@]}"; do + local manifest="$home_dir/.config/$browser/NativeMessagingHosts/com.calycode.cli.json" + if [ -f "$manifest" ]; then + log "Removing $browser native messaging manifest..." + rm -f "$manifest" + fi + done + ;; +esac +``` + +**b) install.ps1 — add removal for all browser registry keys (lines 306–311):** + +```powershell +# Remove all browser registry keys +$browserKeys = @( + "HKCU:\Software\Google\Chrome\NativeMessagingHosts\$NativeHostId", + "HKCU:\Software\BraveSoftware\Brave-Browser\NativeMessagingHosts\$NativeHostId", + "HKCU:\Software\Microsoft\Edge\NativeMessagingHosts\$NativeHostId", + "HKCU:\Software\Chromium\NativeMessagingHosts\$NativeHostId" +) + +foreach ($regKey in $browserKeys) { + if (Test-Path $regKey) { + Write-Log "Removing registry key: $regKey" "INFO" + Remove-Item $regKey -Force + } +} +``` + +**c) Also remove wrapper directory (currently missing from uninstall):** + +```bash +# install.sh — also clean up wrapper dir and config +if [ -f "$home_dir/.calycode/bin/calycode-host.sh" ]; then + rm -f "$home_dir/.calycode/bin/calycode-host.sh" +fi +# Remove bin dir if empty +rmdir "$home_dir/.calycode/bin" 2>/dev/null || true +``` + +```powershell +# install.ps1 — also remove bin directory +$binDir = Join-Path $calyDir "bin" +if (Test-Path $binDir) { + Write-Log "Removing bin directory..." "INFO" + Remove-Item $binDir -Recurse -Force +} +``` + +### Verification + +- Install on macOS with Chrome + Brave → uninstall → both manifests removed +- Install on Windows with Chrome + Edge → uninstall → both registry keys removed +- Wrapper script and bin directory cleaned up + +--- + +## Implementation Order (Recommended) + +| Phase | Tasks | Dependencies | Risk Profile | +| ----------------------- | -------------------------------------------------------------------------- | ------------------ | ------------------ | +| **Phase 1 (Critical)** | 1. Message size cap, 2. CORS validation, 3. Port kill scope + PID tracking | None (independent) | Eliminates HIGHs | +| **Phase 2 (Hardening)** | 4. Shell:true scope, 5. Extension discovery | None (independent) | Eliminates MEDIUMs | +| **Phase 3 (Polish)** | 6. Logger gating, 7. Uninstall completeness | None (independent) | Eliminates LOWs | + +Each phase can be implemented and PR'd independently. No phase depends on another. + +--- + +## Files Changed Summary + +| File | Phase | Changes | +| ------------------------------------------------------------- | ------------- | --------------------------------------------------------------------------------------- | +| `packages/cli/src/commands/opencode/implementation.ts` | 1, 2, 3, 4, 6 | Message cap, CORS validation, port range + PID tracking, spawn shell scope, logger gate | +| `packages/cli/src/commands/opencode/native-host/discovery.ts` | 5 | Public key validation hardening (minor — already supports it) | +| `packages/cli/src/utils/host-constants.ts` | 5 | Remove dev ID, set default mode to strict, add conditional dev ID function | +| `packages/cli/scripts/installer/install.sh` | 7 | Complete browser manifest uninstall + wrapper dir cleanup | +| `packages/cli/scripts/installer/install.ps1` | 7 | Complete browser registry key uninstall + bin dir cleanup | + +--- + +## Testing Strategy + +### Unit Tests (new `*.test.ts` files in `packages/cli/src/commands/opencode/`) + +1. **Message size cap:** Send frames with length 0, 1, 1MB, 1MB+1, 4GB → verify rejection at boundary +2. **CORS validation:** Test `filterAndValidateOrigins` with `["*"]`, `["https://*.evil.com"]`, `["https://good.com"]`, `["chrome-extension://valid-id"]`, mixed arrays, 15+ origins +3. **Port validation:** Test `validateNativeHostPort` at boundaries 4095, 4096, 4127, 4128 +4. **Origin regex:** Test `isValidCorsOrigin` against all dangerous patterns + +### Integration Tests + +5. **PID tracking:** Start server on port 4100, send stop message for port 4100 → server killed. Send stop for 4101 → error (not managed). +6. **Logger gate:** Start native host without/with `CALY_OC_NATIVE_HOST_DEBUG=1` → verify log file absence/presence +7. **Shell scope:** On Windows, verify managed binary spawn uses `shell: false`, npx fallback uses `shell: true` + +--- + +## Rollback / Rollout Plan + +All changes are additive with backward-compatible defaults: + +- Port range default is 4096 — same as current default port +- `CALY_OC_NATIVE_HOST_DEBUG` not set → logger disabled (new behavior, safe default) +- Strict mode + public key → only effective if `CALY_BUILD_OC_EXT_PUBLIC_KEY_B64` is set at build time +- Dev ID removal → only affects `allowedExtensionIds` in production builds; dev builds set `CALY_OC_INCLUDE_DEV_EXT=1` + +Rollback: revert to previous version. No data migration, no config file format changes, no database changes. From c29b34ec781d9ae9bc092eb0fbc42d9c7f9e3875 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mih=C3=A1ly=20T=C3=B3th?= Date: Fri, 15 May 2026 06:21:05 +0200 Subject: [PATCH 2/9] chore: add extension public key to release flow --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9b147dd7..ef81b7b2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -56,3 +56,4 @@ jobs: NPM_CONFIG_PROVENANCE: true NPM_TOKEN: ${{ secrets.NPM_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + CALY_BUILD_OC_EXT_PUBLIC_KEY_B64: ${{ secrets.CALY_BUILD_OC_EXT_PUBLIC_KEY_B64 }} From 3a81f5ec0d66459845a9cc573fa53c219b820b36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mih=C3=A1ly=20T=C3=B3th?= Date: Mon, 18 May 2026 04:03:46 +0200 Subject: [PATCH 3/9] feat: add bootstrapper installer flow and strict OpenCode version pinning Introduce a cross-platform bootstrapper with persistent step-based installer UI and release artifact automation. Enforce native-host launcher version parity by preferring managed installs, rejecting mismatched global binaries, and falling back to versioned npx when needed. --- .github/workflows/build-bootstrapper.yml | 82 +++++++ bootstrapper/Makefile | 43 ++++ bootstrapper/go.mod | 3 + bootstrapper/install/cli.go | 55 +++++ bootstrapper/install/exec_darwin.go | 12 + bootstrapper/install/exec_unsupported.go | 7 + bootstrapper/install/exec_windows.go | 15 ++ bootstrapper/install/node.go | 56 +++++ bootstrapper/install/node_darwin.go | 64 +++++ bootstrapper/install/node_unsupported.go | 8 + bootstrapper/install/node_windows.go | 74 ++++++ bootstrapper/main.go | 72 ++++++ bootstrapper/ui/dialog_darwin.go | 122 ++++++++++ bootstrapper/ui/dialog_unsupported.go | 13 + bootstrapper/ui/dialog_windows.go | 110 +++++++++ bootstrapper/ui/session.go | 29 +++ bootstrapper/ui/session_darwin.go | 9 + bootstrapper/ui/session_unsupported.go | 7 + bootstrapper/ui/session_windows.go | 167 +++++++++++++ packages/cli/scripts/README.md | 228 ++++++------------ .../src/commands/opencode/implementation.ts | 102 ++++++-- 21 files changed, 1108 insertions(+), 170 deletions(-) create mode 100644 .github/workflows/build-bootstrapper.yml create mode 100644 bootstrapper/Makefile create mode 100644 bootstrapper/go.mod create mode 100644 bootstrapper/install/cli.go create mode 100644 bootstrapper/install/exec_darwin.go create mode 100644 bootstrapper/install/exec_unsupported.go create mode 100644 bootstrapper/install/exec_windows.go create mode 100644 bootstrapper/install/node.go create mode 100644 bootstrapper/install/node_darwin.go create mode 100644 bootstrapper/install/node_unsupported.go create mode 100644 bootstrapper/install/node_windows.go create mode 100644 bootstrapper/main.go create mode 100644 bootstrapper/ui/dialog_darwin.go create mode 100644 bootstrapper/ui/dialog_unsupported.go create mode 100644 bootstrapper/ui/dialog_windows.go create mode 100644 bootstrapper/ui/session.go create mode 100644 bootstrapper/ui/session_darwin.go create mode 100644 bootstrapper/ui/session_unsupported.go create mode 100644 bootstrapper/ui/session_windows.go diff --git a/.github/workflows/build-bootstrapper.yml b/.github/workflows/build-bootstrapper.yml new file mode 100644 index 00000000..2b640e04 --- /dev/null +++ b/.github/workflows/build-bootstrapper.yml @@ -0,0 +1,82 @@ +# .github/workflows/build-bootstrapper.yml +name: Build Bootstrapper + +on: + push: + branches: + - main + paths: + - "bootstrapper/**" + - ".github/workflows/build-bootstrapper.yml" + workflow_dispatch: + release: + types: [published] + +permissions: + contents: write + +jobs: + build: + name: Build all targets + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: "1.21" + + - name: Build Windows x64 + working-directory: bootstrapper + run: | + mkdir -p dist + GOOS=windows GOARCH=amd64 CGO_ENABLED=0 \ + go build -ldflags "-s -w -H=windowsgui" \ + -o dist/calycode-installer-windows-x64.exe \ + . + + - name: Build macOS x64 + working-directory: bootstrapper + run: | + GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 \ + go build -ldflags "-s -w" \ + -o dist/calycode-installer-darwin-x64 \ + . + + - name: Build macOS arm64 + working-directory: bootstrapper + run: | + GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 \ + go build -ldflags "-s -w" \ + -o dist/calycode-installer-darwin-arm64 \ + . + + - name: Generate checksums + working-directory: bootstrapper/dist + run: sha256sum * > SHA256SUMS + + # --- Release attachment --- + # Triggered by changesets creating a GitHub Release after npm publish. + # `gh release upload` attaches to the release that fired this workflow. + - name: Attach binaries to GitHub Release + if: github.event_name == 'release' + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release upload "${{ github.event.release.tag_name }}" \ + bootstrapper/dist/calycode-installer-windows-x64.exe \ + bootstrapper/dist/calycode-installer-darwin-x64 \ + bootstrapper/dist/calycode-installer-darwin-arm64 \ + bootstrapper/dist/SHA256SUMS \ + --clobber + + # --- Floating tag --- + # Keeps `bootstrapper-latest` pointing at HEAD so + # dev builds are always downloadable at a stable URL. + - name: Update bootstrapper-latest tag + if: github.event_name != 'workflow_dispatch' + run: | + git tag -f bootstrapper-latest + git push -f origin bootstrapper-latest diff --git a/bootstrapper/Makefile b/bootstrapper/Makefile new file mode 100644 index 00000000..04ed25fb --- /dev/null +++ b/bootstrapper/Makefile @@ -0,0 +1,43 @@ +# CalyCode Bootstrapper Build +# Produces standalone installers for Windows (exe) and macOS (macho binary). +# +# Prerequisites: Go 1.21+ +# +# Usage: +# make all Build all targets +# make windows Build Windows .exe (from any OS) +# make darwin-amd64 Build macOS Intel binary +# make darwin-arm64 Build macOS Apple Silicon binary +# make clean Remove build artifacts + +OUT_DIR := dist +BINARY := calycode-installer +MODULE := github.com/calycode/xano-tools/bootstrapper + +.PHONY: all windows darwin-amd64 darwin-arm64 clean + +all: windows darwin-amd64 darwin-arm64 + +windows: + @echo "Building Windows x64..." + GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build \ + -ldflags "-s -w -H=windowsgui" \ + -o $(OUT_DIR)/$(BINARY)-windows-x64.exe \ + $(MODULE) + +darwin-amd64: + @echo "Building macOS Intel x64..." + GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build \ + -ldflags "-s -w" \ + -o $(OUT_DIR)/$(BINARY)-darwin-x64 \ + $(MODULE) + +darwin-arm64: + @echo "Building macOS Apple Silicon..." + GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build \ + -ldflags "-s -w" \ + -o $(OUT_DIR)/$(BINARY)-darwin-arm64 \ + $(MODULE) + +clean: + rm -rf $(OUT_DIR) diff --git a/bootstrapper/go.mod b/bootstrapper/go.mod new file mode 100644 index 00000000..c4854a61 --- /dev/null +++ b/bootstrapper/go.mod @@ -0,0 +1,3 @@ +module github.com/calycode/xano-tools/bootstrapper + +go 1.21 diff --git a/bootstrapper/install/cli.go b/bootstrapper/install/cli.go new file mode 100644 index 00000000..ee0ce3a6 --- /dev/null +++ b/bootstrapper/install/cli.go @@ -0,0 +1,55 @@ +package install + +import ( + "bytes" + "fmt" + "os/exec" + "strings" +) + +// InstallCLI installs @calycode/cli globally via npm. +// Returns the installed version string on success, empty string on failure. +// On failure, stderr is captured for diagnostic dialogs. +func InstallCLI(version string) (output string, errOut string) { + if version == "" { + version = "latest" + } + pkg := fmt.Sprintf("@calycode/cli@%s", version) + + var stderr bytes.Buffer + cmd := exec.Command("npm", "install", "-g", pkg) + setupSilent(cmd) + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return "", stderr.String() + } + + // Verify installation + verCmd := exec.Command("caly-xano", "--version") + setupSilent(verCmd) + out, err := verCmd.Output() + if err != nil { + return "", "" + } + return strings.TrimSpace(string(out)), "" +} + +// InitNativeHost runs `caly-xano opencode init` to configure the Chrome native messaging host. +// Returns stderr on failure. +func InitNativeHost() (errOut string) { + if !CommandExists("caly-xano") { + return "caly-xano command not found" + } + + var stderr bytes.Buffer + cmd := exec.Command("caly-xano", "opencode", "init") + setupSilent(cmd) + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + if stderr.Len() > 0 { + return stderr.String() + } + return err.Error() + } + return "" +} diff --git a/bootstrapper/install/exec_darwin.go b/bootstrapper/install/exec_darwin.go new file mode 100644 index 00000000..2e4fc5a5 --- /dev/null +++ b/bootstrapper/install/exec_darwin.go @@ -0,0 +1,12 @@ +//go:build darwin + +package install + +import "os/exec" + +// setupSilent on macOS keeps child process output connected to the terminal. +// The binary itself opens Terminal when double-clicked — this is standard macOS CLI UX. +// osascript dialogs provide the primary progress feedback. +func setupSilent(cmd *exec.Cmd) { + // no-op: Terminal output is the expected macOS experience +} diff --git a/bootstrapper/install/exec_unsupported.go b/bootstrapper/install/exec_unsupported.go new file mode 100644 index 00000000..e49f4105 --- /dev/null +++ b/bootstrapper/install/exec_unsupported.go @@ -0,0 +1,7 @@ +//go:build !windows && !darwin + +package install + +import "os/exec" + +func setupSilent(cmd *exec.Cmd) {} diff --git a/bootstrapper/install/exec_windows.go b/bootstrapper/install/exec_windows.go new file mode 100644 index 00000000..c16d51dc --- /dev/null +++ b/bootstrapper/install/exec_windows.go @@ -0,0 +1,15 @@ +//go:build windows + +package install + +import ( + "os/exec" + "syscall" +) + +// setupSilent configures a command to run without visible windows. +// On Windows this hides the console; child output goes to discard +// (caller should capture or check exit code). +func setupSilent(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} +} diff --git a/bootstrapper/install/node.go b/bootstrapper/install/node.go new file mode 100644 index 00000000..f3c35548 --- /dev/null +++ b/bootstrapper/install/node.go @@ -0,0 +1,56 @@ +package install + +import ( + "os/exec" + "runtime" + "strconv" + "strings" +) + +const MinNodeVersion = 18 + +// NodeVersion holds a parsed Node.js semver major version. +type NodeVersion struct { + Major int + Raw string + Found bool +} + +// CheckNode returns the installed Node.js version, or a zero value if not found. +func CheckNode() NodeVersion { + cmd := exec.Command("node", "--version") + out, err := cmd.Output() + if err != nil { + return NodeVersion{} + } + raw := strings.TrimSpace(string(out)) + verStr := strings.TrimPrefix(raw, "v") + parts := strings.SplitN(verStr, ".", 2) + major, err := strconv.Atoi(parts[0]) + if err != nil { + return NodeVersion{Raw: raw, Found: true} + } + return NodeVersion{Major: major, Raw: raw, Found: true} +} + +// NodeOK returns true if Node.js >= MinNodeVersion is installed. +func NodeOK() bool { + v := CheckNode() + return v.Found && v.Major >= MinNodeVersion +} + +// CommandExists checks if a command is available in PATH. +func CommandExists(cmd string) bool { + _, err := exec.LookPath(cmd) + return err == nil +} + +// IsWindows returns true on Windows. +func IsWindows() bool { + return runtime.GOOS == "windows" +} + +// IsDarwin returns true on macOS. +func IsDarwin() bool { + return runtime.GOOS == "darwin" +} diff --git a/bootstrapper/install/node_darwin.go b/bootstrapper/install/node_darwin.go new file mode 100644 index 00000000..4021c954 --- /dev/null +++ b/bootstrapper/install/node_darwin.go @@ -0,0 +1,64 @@ +//go:build darwin + +package install + +import ( + "bytes" + "os" + "os/exec" +) + +// InstallNode attempts to install Node.js via Homebrew. +// Returns stderr output on failure. +func InstallNode() (errOut string) { + // If Homebrew is not installed, install it first + if !CommandExists("brew") { + if out := installHomebrew(); out != "" { + return out + } + } + + var stderr bytes.Buffer + cmd := exec.Command("brew", "install", "node") + setupSilent(cmd) + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + if stderr.Len() > 0 { + return stderr.String() + } + return err.Error() + } + + if !NodeOK() { + return "Node.js was installed but is not available in PATH. Restart Terminal and try again." + } + return "" +} + +func installHomebrew() string { + var stderr bytes.Buffer + cmd := exec.Command("/bin/bash", "-c", + `"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"`, + ) + setupSilent(cmd) + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + if stderr.Len() > 0 { + return stderr.String() + } + return "Failed to install Homebrew. Install it manually from https://brew.sh" + } + + // Add Homebrew to PATH for Apple Silicon + if _, err := os.Stat("/opt/homebrew/bin/brew"); err == nil { + os.Setenv("PATH", "/opt/homebrew/bin:"+os.Getenv("PATH")) + } + if _, err := os.Stat("/usr/local/bin/brew"); err == nil { + os.Setenv("PATH", "/usr/local/bin:"+os.Getenv("PATH")) + } + + if !CommandExists("brew") { + return "Homebrew was installed but is not available in PATH. Restart Terminal and try again." + } + return "" +} diff --git a/bootstrapper/install/node_unsupported.go b/bootstrapper/install/node_unsupported.go new file mode 100644 index 00000000..32f72058 --- /dev/null +++ b/bootstrapper/install/node_unsupported.go @@ -0,0 +1,8 @@ +//go:build !windows && !darwin + +package install + +// InstallNode is a no-op on unsupported platforms. +func InstallNode() string { + return "Unsupported platform. Please install Node.js 18+ manually from https://nodejs.org" +} diff --git a/bootstrapper/install/node_windows.go b/bootstrapper/install/node_windows.go new file mode 100644 index 00000000..e02ad117 --- /dev/null +++ b/bootstrapper/install/node_windows.go @@ -0,0 +1,74 @@ +//go:build windows + +package install + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" +) + +// InstallNode attempts to install Node.js LTS via winget (primary) or Chocolatey (fallback). +// Returns stderr output on failure. +func InstallNode() (errOut string) { + // Primary: winget + if CommandExists("winget") { + var stderr bytes.Buffer + cmd := exec.Command("winget", + "install", "-e", + "--id", "OpenJS.NodeJS.LTS", + "--silent", + "--accept-package-agreements", + "--accept-source-agreements", + ) + setupSilent(cmd) + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + // winget failed, fall through to Chocolatey + } else { + refreshPathWindows() + if NodeOK() { + return "" + } + } + } + + // Fallback: Chocolatey + if CommandExists("choco") { + var stderr bytes.Buffer + cmd := exec.Command("choco", "install", "nodejs-lts", "-y", "--limit-output") + setupSilent(cmd) + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + if stderr.Len() > 0 { + return stderr.String() + } + return err.Error() + } + + refreshenv := filepath.Join(os.Getenv("ChocolateyInstall"), "bin", "refreshenv.cmd") + if _, err := os.Stat(refreshenv); err == nil { + exec.Command("cmd", "/c", refreshenv).Run() + } + refreshPathWindows() + if NodeOK() { + return "" + } + } + + return "Could not install Node.js. Install it manually from https://nodejs.org" +} + +func refreshPathWindows() { + commonPaths := []string{ + filepath.Join(os.Getenv("ProgramFiles"), "nodejs"), + filepath.Join(os.Getenv("APPDATA"), "npm"), + filepath.Join(os.Getenv("LOCALAPPDATA"), "Programs", "nodejs"), + } + for _, p := range commonPaths { + if _, err := os.Stat(p); err == nil { + os.Setenv("PATH", p+";"+os.Getenv("PATH")) + } + } +} diff --git a/bootstrapper/main.go b/bootstrapper/main.go new file mode 100644 index 00000000..b5909cf6 --- /dev/null +++ b/bootstrapper/main.go @@ -0,0 +1,72 @@ +package main + +import ( + "os" + + "github.com/calycode/xano-tools/bootstrapper/install" + "github.com/calycode/xano-tools/bootstrapper/ui" +) + +func main() { + ui.ShowWelcome() + session := ui.StartInstallerSession() + + fail := func(title, message string) { + session.Close() + ui.ShowError(title, message) + os.Exit(1) + } + + // --- STEP 1: Ensure Node.js >= 18 --- + session.Update(1, 3, "Checking prerequisites", "Checking Node.js 18+...") + if !install.NodeOK() { + session.Update(1, 3, "Installing dependencies", "Node.js not found. Installing Node.js 18+...") + if errOut := install.InstallNode(); errOut != "" { + fail( + "Node.js Required", + "Node.js 18+ is required but could not be installed automatically.\n\n"+ + errOut+"\n\n"+ + "Please install it manually from https://nodejs.org\n"+ + "Then run this installer again.", + ) + } + if !install.NodeOK() { + fail( + "Node.js Installation Failed", + "Node.js was installed but is not available in PATH.\n\n"+ + "Please restart your computer and run this installer again.", + ) + } + } + + // --- STEP 2: Install @calycode/cli --- + session.Update(2, 3, "Installing CalyCode CLI", "Installing @calycode/cli globally...") + cliVersion, errOut := install.InstallCLI("latest") + if errOut != "" { + fail( + "Installation Failed", + "Could not install @calycode/cli.\n\n"+ + errOut+"\n\n"+ + "Check your internet connection and try again.\n"+ + "Or install manually: npm install -g @calycode/cli", + ) + } + + // --- STEP 3: Configure native messaging host --- + session.Update(3, 3, "Configuring browser integration", "Setting up native messaging host...") + if errOut := install.InitNativeHost(); errOut != "" { + session.Close() + ui.ShowWarning( + "Extension Setup Incomplete", + "CLI is installed but the browser extension native host could not be configured.\n\n"+ + errOut+"\n\n"+ + "Run this command in terminal to complete setup:\n"+ + " caly-xano opencode init\n\n"+ + "Then reload your Chrome extension.", + ) + } + + // --- DONE --- + session.Close() + ui.ShowSuccess(cliVersion) +} diff --git a/bootstrapper/ui/dialog_darwin.go b/bootstrapper/ui/dialog_darwin.go new file mode 100644 index 00000000..42bd37f4 --- /dev/null +++ b/bootstrapper/ui/dialog_darwin.go @@ -0,0 +1,122 @@ +//go:build darwin + +package ui + +import ( + "fmt" + "os" + "os/exec" + "strings" +) + +func osascript(script string) { + cmd := exec.Command("osascript", "-e", script) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Run() +} + +func escapeAppleScript(s string) string { + s = strings.ReplaceAll(s, "\\", "\\\\") + s = strings.ReplaceAll(s, "\"", "\\\"") + s = strings.ReplaceAll(s, "\n", "\\n") + return s +} + +func displayDialog(title, message string, buttons string, defaultButton string, icon string) { + script := fmt.Sprintf( + `display dialog "%s" with title "%s" buttons {%s} default button "%s" with icon %s`, + escapeAppleScript(message), + escapeAppleScript(title), + buttons, + defaultButton, + icon, + ) + osascript(script) +} + +// ShowWelcome displays the welcome dialog on macOS. +func ShowWelcome() { + displayDialog( + "CalyCode Setup", + "This will install the CalyCode CLI and configure your browser extension.\n\nThe process takes 1-2 minutes.\n\nClick OK to continue.", + `"OK", "Cancel"`, + "OK", + "note", + ) +} + +// ShowInstallingNode displays progress: installing Node.js. +func ShowInstallingNode() { + displayDialog( + "CalyCode Setup", + "Node.js 18+ is required but not found.\n\nInstalling Node.js now via Homebrew...\nThis may take a few minutes.\n\nClick OK to proceed.", + `"OK"`, + "OK", + "note", + ) +} + +// ShowInstallingCLI displays progress: installing CLI. +func ShowInstallingCLI() { + displayDialog( + "CalyCode Setup", + "Installing CalyCode CLI...\nThis may take a moment.", + `"OK"`, + "OK", + "note", + ) +} + +// ShowConfiguringNativeHost displays progress: configuring browser extension. +func ShowConfiguringNativeHost() { + displayDialog( + "CalyCode Setup", + "Configuring browser extension connection...", + `"OK"`, + "OK", + "note", + ) +} + +// ShowError displays a fatal error dialog. +func ShowError(title, message string) { + displayDialog( + title, + message, + `"OK"`, + "OK", + "stop", + ) +} + +// ShowWarning displays a warning dialog (non-fatal). +func ShowWarning(title, message string) { + displayDialog( + title, + message, + `"OK"`, + "OK", + "caution", + ) +} + +// ShowSuccess displays the completion dialog with next steps. +func ShowSuccess(cliVersion string) { + msg := "CalyCode CLI has been installed successfully!\n\n" + if cliVersion != "" { + msg += "Version: " + cliVersion + "\n\n" + } + msg += "Next steps:\n" + msg += " - Reload your Chrome extension\n" + msg += " - Open Terminal and run: caly-xano --help\n" + msg += "\nClick OK to finish." + + displayDialog( + "CalyCode Setup - Complete", + msg, + `"OK"`, + "OK", + "note", + ) +} diff --git a/bootstrapper/ui/dialog_unsupported.go b/bootstrapper/ui/dialog_unsupported.go new file mode 100644 index 00000000..29ce9a28 --- /dev/null +++ b/bootstrapper/ui/dialog_unsupported.go @@ -0,0 +1,13 @@ +//go:build !windows && !darwin + +package ui + +// Stub implementations for unsupported platforms. + +func ShowWelcome() {} +func ShowInstallingNode() {} +func ShowInstallingCLI() {} +func ShowConfiguringNativeHost() {} +func ShowError(title, message string) {} +func ShowWarning(title, message string) {} +func ShowSuccess(cliVersion string) {} diff --git a/bootstrapper/ui/dialog_windows.go b/bootstrapper/ui/dialog_windows.go new file mode 100644 index 00000000..e0f5764b --- /dev/null +++ b/bootstrapper/ui/dialog_windows.go @@ -0,0 +1,110 @@ +//go:build windows + +package ui + +import ( + "syscall" + "unsafe" +) + +var ( + user32 = syscall.NewLazyDLL("user32.dll") + messageBoxW = user32.NewProc("MessageBoxW") +) + +const ( + MB_OK = 0x00000000 + MB_OKCANCEL = 0x00000001 + MB_ICONINFORMATION = 0x00000040 + MB_ICONWARNING = 0x00000030 + MB_ICONERROR = 0x00000010 + MB_ICONQUESTION = 0x00000020 + MB_SETFOREGROUND = 0x00010000 + MB_TOPMOST = 0x00040000 + + IDOK = 1 + IDCANCEL = 2 +) + +func messageBox(title, message string, flags uintptr) int { + t, _ := syscall.UTF16PtrFromString(title) + m, _ := syscall.UTF16PtrFromString(message) + ret, _, _ := messageBoxW.Call( + 0, + uintptr(unsafe.Pointer(m)), + uintptr(unsafe.Pointer(t)), + flags, + ) + return int(ret) +} + +// ShowWelcome displays the welcome/setup-start dialog. +func ShowWelcome() { + messageBox( + "CalyCode Setup", + "This will install the CalyCode CLI and configure your browser extension.\n\nThe process takes 1-2 minutes.\n\nClick OK to continue.", + MB_OK|MB_ICONINFORMATION|MB_SETFOREGROUND|MB_TOPMOST, + ) +} + +// ShowInstallingNode displays progress: installing Node.js. +func ShowInstallingNode() { + messageBox( + "CalyCode Setup", + "Node.js 18+ is required but not found.\n\nInstalling Node.js now via Winget...\nThis may take a few minutes.", + MB_OK|MB_ICONINFORMATION|MB_SETFOREGROUND|MB_TOPMOST, + ) +} + +// ShowInstallingCLI displays progress: installing CLI. +func ShowInstallingCLI() { + messageBox( + "CalyCode Setup", + "Installing CalyCode CLI...\nThis may take a moment.", + MB_OK|MB_ICONINFORMATION|MB_SETFOREGROUND|MB_TOPMOST, + ) +} + +// ShowConfiguringNativeHost displays progress: configuring browser extension. +func ShowConfiguringNativeHost() { + messageBox( + "CalyCode Setup", + "Configuring browser extension connection...", + MB_OK|MB_ICONINFORMATION|MB_SETFOREGROUND|MB_TOPMOST, + ) +} + +// ShowError displays a fatal error dialog. +func ShowError(title, message string) { + messageBox( + title, + message, + MB_OK|MB_ICONERROR|MB_SETFOREGROUND|MB_TOPMOST, + ) +} + +// ShowWarning displays a warning dialog (non-fatal). +func ShowWarning(title, message string) { + messageBox( + title, + message, + MB_OK|MB_ICONWARNING|MB_SETFOREGROUND|MB_TOPMOST, + ) +} + +// ShowSuccess displays the completion dialog with next steps. +func ShowSuccess(cliVersion string) { + msg := "CalyCode CLI has been installed successfully!\n\n" + if cliVersion != "" { + msg += "Version: " + cliVersion + "\n\n" + } + msg += "Next steps:\n" + msg += " - Reload your Chrome extension\n" + msg += " - Open a terminal and run: caly-xano --help\n\n" + msg += "Click OK to finish." + messageBox( + "CalyCode Setup - Complete", + msg, + MB_OK|MB_ICONINFORMATION|MB_SETFOREGROUND|MB_TOPMOST, + ) +} diff --git a/bootstrapper/ui/session.go b/bootstrapper/ui/session.go new file mode 100644 index 00000000..50cc106f --- /dev/null +++ b/bootstrapper/ui/session.go @@ -0,0 +1,29 @@ +package ui + +// InstallerSession tracks installer progress in a persistent window when available. +// On platforms without a persistent UI implementation, methods are no-ops. +type InstallerSession struct { + updateFn func(step, total int, title, detail string) + closeFn func() +} + +// StartInstallerSession starts a persistent installer session UI. +func StartInstallerSession() *InstallerSession { + return newInstallerSession() +} + +// Update updates progress step and text. +func (s *InstallerSession) Update(step, total int, title, detail string) { + if s == nil || s.updateFn == nil { + return + } + s.updateFn(step, total, title, detail) +} + +// Close closes the installer session UI. +func (s *InstallerSession) Close() { + if s == nil || s.closeFn == nil { + return + } + s.closeFn() +} diff --git a/bootstrapper/ui/session_darwin.go b/bootstrapper/ui/session_darwin.go new file mode 100644 index 00000000..a4b927c3 --- /dev/null +++ b/bootstrapper/ui/session_darwin.go @@ -0,0 +1,9 @@ +//go:build darwin + +package ui + +func newInstallerSession() *InstallerSession { + // macOS: keep setup non-blocking and avoid repeated modal prompts. + // A native persistent window can be added later via .app bundle. + return &InstallerSession{} +} diff --git a/bootstrapper/ui/session_unsupported.go b/bootstrapper/ui/session_unsupported.go new file mode 100644 index 00000000..ec9ebef9 --- /dev/null +++ b/bootstrapper/ui/session_unsupported.go @@ -0,0 +1,7 @@ +//go:build !windows && !darwin + +package ui + +func newInstallerSession() *InstallerSession { + return &InstallerSession{} +} diff --git a/bootstrapper/ui/session_windows.go b/bootstrapper/ui/session_windows.go new file mode 100644 index 00000000..63e5972f --- /dev/null +++ b/bootstrapper/ui/session_windows.go @@ -0,0 +1,167 @@ +//go:build windows + +package ui + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" +) + +type installerStatus struct { + Title string `json:"title"` + Detail string `json:"detail"` + Step int `json:"step"` + Total int `json:"total"` + Done bool `json:"done"` +} + +func newInstallerSession() *InstallerSession { + tmpDir, err := os.MkdirTemp("", "calycode-installer-*") + if err != nil { + return &InstallerSession{} + } + + statusPath := filepath.Join(tmpDir, "status.json") + scriptPath := filepath.Join(tmpDir, "viewer.ps1") + + writeStatusFile := func(st installerStatus) { + payload, _ := json.Marshal(st) + tmp := statusPath + ".tmp" + _ = os.WriteFile(tmp, payload, 0600) + _ = os.Rename(tmp, statusPath) + } + + writeStatusFile(installerStatus{Title: "Preparing...", Detail: "Starting installer", Step: 0, Total: 3, Done: false}) + + statusEscaped := strings.ReplaceAll(statusPath, "'", "''") + viewerScript := `Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing + +$statusPath = '` + statusEscaped + `' + +$form = New-Object System.Windows.Forms.Form +$form.Text = 'CalyCode Installer' +$form.StartPosition = 'CenterScreen' +$form.Size = New-Object System.Drawing.Size(560, 220) +$form.FormBorderStyle = 'FixedDialog' +$form.MaximizeBox = $false +$form.MinimizeBox = $false +$form.TopMost = $true + +$titleLabel = New-Object System.Windows.Forms.Label +$titleLabel.Location = New-Object System.Drawing.Point(24, 24) +$titleLabel.Size = New-Object System.Drawing.Size(510, 32) +$titleLabel.Font = New-Object System.Drawing.Font('Segoe UI', 11, [System.Drawing.FontStyle]::Bold) +$titleLabel.Text = 'Preparing installer...' +$form.Controls.Add($titleLabel) + +$detailLabel = New-Object System.Windows.Forms.Label +$detailLabel.Location = New-Object System.Drawing.Point(24, 64) +$detailLabel.Size = New-Object System.Drawing.Size(510, 44) +$detailLabel.Font = New-Object System.Drawing.Font('Segoe UI', 9) +$detailLabel.Text = 'Please wait while setup starts.' +$form.Controls.Add($detailLabel) + +$progress = New-Object System.Windows.Forms.ProgressBar +$progress.Location = New-Object System.Drawing.Point(24, 122) +$progress.Size = New-Object System.Drawing.Size(510, 22) +$progress.Minimum = 0 +$progress.Maximum = 100 +$progress.Style = 'Continuous' +$progress.Value = 5 +$form.Controls.Add($progress) + +$stepLabel = New-Object System.Windows.Forms.Label +$stepLabel.Location = New-Object System.Drawing.Point(24, 152) +$stepLabel.Size = New-Object System.Drawing.Size(510, 24) +$stepLabel.Font = New-Object System.Drawing.Font('Segoe UI', 8) +$stepLabel.Text = 'Step 0 of 3' +$form.Controls.Add($stepLabel) + +$timer = New-Object System.Windows.Forms.Timer +$timer.Interval = 350 +$timer.Add_Tick({ + try { + if (-not (Test-Path -LiteralPath $statusPath)) { + return + } + $raw = Get-Content -LiteralPath $statusPath -Raw + if (-not $raw) { + return + } + $status = $raw | ConvertFrom-Json + + if ($status.title) { $titleLabel.Text = [string]$status.title } + if ($status.detail) { $detailLabel.Text = [string]$status.detail } + + $total = [int]$status.total + if ($total -lt 1) { $total = 1 } + $step = [int]$status.step + if ($step -lt 0) { $step = 0 } + if ($step -gt $total) { $step = $total } + + $pct = [int](($step * 100) / $total) + if ($pct -lt 1) { $pct = 1 } + if ($pct -gt 100) { $pct = 100 } + $progress.Value = $pct + $stepLabel.Text = 'Step ' + $step + ' of ' + $total + + if ($status.done -eq $true) { + $timer.Stop() + $form.Close() + } + } catch { + # Ignore transient parse/read errors. + } +}) + +$timer.Start() +[void]$form.ShowDialog() +` + + _ = os.WriteFile(scriptPath, []byte(viewerScript), 0600) + + cmd := exec.Command( + "powershell.exe", + "-NoProfile", + "-WindowStyle", + "Hidden", + "-ExecutionPolicy", + "Bypass", + "-File", + scriptPath, + ) + _ = cmd.Start() + + var mu sync.Mutex + closed := false + + return &InstallerSession{ + updateFn: func(step, total int, title, detail string) { + mu.Lock() + defer mu.Unlock() + if closed { + return + } + writeStatusFile(installerStatus{Title: title, Detail: detail, Step: step, Total: total, Done: false}) + }, + closeFn: func() { + mu.Lock() + defer mu.Unlock() + if closed { + return + } + closed = true + writeStatusFile(installerStatus{Title: "Done", Detail: "Finalizing...", Step: 3, Total: 3, Done: true}) + go func() { + time.Sleep(2 * time.Second) + _ = os.RemoveAll(tmpDir) + }() + }, + } +} diff --git a/packages/cli/scripts/README.md b/packages/cli/scripts/README.md index dcbebea7..bbfd06de 100644 --- a/packages/cli/scripts/README.md +++ b/packages/cli/scripts/README.md @@ -1,193 +1,115 @@ -# CalyCode CLI Installation Scripts +# CalyCode CLI Installation -This directory contains installation scripts for the CalyCode CLI and Chrome Native Messaging Host. +## Primary: Download & Run (No Terminal Needed) -## Directory Structure - -``` -scripts/ -├── installer/ # Production installers (for end-users) -│ ├── install.sh # Existing Unix installer (kept for compatibility) -│ ├── install-unix.sh # Explicit Unix/macOS entrypoint -> install.sh -│ ├── install.ps1 # Existing Windows PowerShell installer (kept) -│ ├── install-windows.ps1 # Explicit Windows PowerShell entrypoint -> install.ps1 -│ ├── install.bat # Existing Windows CMD wrapper (kept) -│ └── install-windows.bat # Explicit Windows CMD entrypoint -> install.bat -├── dev/ # Development scripts (for CLI developers) -│ ├── install-unix.sh # Unix development setup -│ └── install-win.bat # Windows development setup -└── README.md # This file -``` +Download the installer for your platform, double-click it, and follow the prompts. +No terminal commands required. -## For End-Users +| Platform | Download | +|---|---| +| **Windows** | `https://github.com/calycode/xano-tools/releases/latest/download/calycode-installer-windows-x64.exe` | +| **macOS Intel** | `https://github.com/calycode/xano-tools/releases/latest/download/calycode-installer-darwin-x64` | +| **macOS Apple Silicon** | `https://github.com/calycode/xano-tools/releases/latest/download/calycode-installer-darwin-arm64` | -### One-line Installation Commands (Recommended for Extension) +> **macOS note:** After downloading, right-click the file and select **Open** to bypass Gatekeeper. +> Apple requires notarization for seamless opening — this will be added in a future release. -Use these per detected user agent/shell. +> **Checksums** are available alongside each release in the `SHA256SUMS` file. -**Unix/macOS (Bash):** - -```bash -curl -fsSL https://links.calycode.com/install-cli-unix | bash -``` +### What the installer does +1. Checks for Node.js 18+ (installs automatically via winget/brew if missing) +2. Installs `@calycode/cli` globally via npm +3. Configures the Chrome Native Messaging Host for the CalyCode extension +4. Shows a completion dialog with next steps -**Unix/macOS (legacy URL kept):** +--- -```bash -curl -fsSL https://links.calycode.com/install-cli | bash -``` +## Alternative: Terminal Install (Shell Scripts) -**Windows (PowerShell):** +For users who prefer terminal or need headless/CI installs: +### Windows (PowerShell) ```powershell irm https://links.calycode.com/install-cli-windows-ps1 | iex ``` -**Windows (PowerShell, legacy URL kept):** - -```powershell -irm https://links.calycode.com/install-cli.ps1 | iex -``` - -**Windows (CMD):** - -```cmd -curl -fsSL https://links.calycode.com/install-cli-windows-bat -o install-windows.bat && install-windows.bat -``` - -**Windows (CMD, legacy URL kept):** - -```cmd -curl -fsSL https://links.calycode.com/install-cli.bat -o install.bat && install.bat +### macOS / Linux (Bash) +```bash +curl -fsSL https://links.calycode.com/install-cli-unix | bash ``` -### What the Installer Does - -1. Checks for Node.js v18+ (installs if missing) -2. Installs `@calycode/cli` globally via npm -3. Configures Chrome Native Messaging Host -4. Verifies the installation - -### Installation Options - -**Unix (`install-unix.sh` or `install.sh`):** - +### Options ```bash -# Install specific version +# Specific version curl -fsSL https://links.calycode.com/install-cli-unix | bash -s -- --version 1.2.3 -# Skip native host configuration +# Skip native host setup curl -fsSL https://links.calycode.com/install-cli-unix | bash -s -- --skip-native-host # Uninstall curl -fsSL https://links.calycode.com/install-cli-unix | bash -s -- --uninstall ``` -**Windows PowerShell (`install-windows.ps1` or `install.ps1`):** - -```powershell -# Install latest -irm https://links.calycode.com/install-cli-windows-ps1 | iex +--- -# Local script usage: install specific version -.\install-windows.ps1 -Version 1.2.3 +## Extension Mapping (User Agent → Download) -# Skip native host configuration -.\install-windows.ps1 -SkipNativeHost +Suggested mapping for detecting the right installer from a browser extension: -# Uninstall -.\install-windows.ps1 -Uninstall +``` +win32 → calycode-installer-windows-x64.exe +darwin → calycode-installer-darwin-arm64 (Apple Silicon) or darwin-x64 (Intel) +linux → Fall back to shell script: curl ... | bash ``` -### Environment Variables (Unix) +Architecture detection on macOS: check `navigator.userAgentData` or serve a universal page. -`install.sh` / `install-unix.sh` supports: +--- -- `CALYCODE_VERSION=1.2.3` - install a specific version -- `CALYCODE_SKIP_NATIVE_HOST=1` - skip `caly-xano opencode init` +## For Developers -Example: +The bootstrapper source is at `bootstrapper/`. It's a Go application that cross-compiles +to Windows and macOS from any platform. +### Build locally ```bash -curl -fsSL https://links.calycode.com/install-cli-unix | CALYCODE_VERSION=1.2.3 CALYCODE_SKIP_NATIVE_HOST=1 bash -s -- +cd bootstrapper +make all # Build all targets +make windows # Windows only +make darwin-arm64 # macOS ARM only ``` -## For Developers - -The `dev/` scripts are for developers working on the CLI itself. They assume: - -- The repository has been cloned -- Dependencies have been installed (`pnpm install`) -- The CLI has been built (`pnpm build`) or linked (`npm link`) - -### Development Setup - -1. Clone the repository -2. Install dependencies: `pnpm install` -3. Build the CLI: `pnpm build` -4. Link for local use: `cd packages/cli && npm link` -5. Run the dev installer: - - **macOS/Linux:** `./scripts/dev/install-unix.sh` - - **Windows:** `scripts\dev\install-win.bat` - -### Differences from Production Installer - -| Feature | Production | Development | -| ---------------------- | ------------------- | ------------------------- | -| Installs CLI via npm | Yes | No (assumes linked/built) | -| Checks Node.js | Yes | Yes | -| Installs Node.js | Yes | Yes | -| Configures native host | Yes | Yes | -| Version selection | Yes (`--version`) | No | -| Uninstall support | Yes (`--uninstall`) | No | - -## Hosting - -The production installers are hosted at: - -- Legacy URLs (kept): - - `https://links.calycode.com/install-cli` (Unix) - - `https://links.calycode.com/install-cli.ps1` (Windows PowerShell) - - `https://links.calycode.com/install-cli.bat` (Windows CMD) -- Explicit OS URLs (recommended): - - `https://links.calycode.com/install-cli-unix` -> `installer/install-unix.sh` - - `https://links.calycode.com/install-cli-windows-ps1` -> `installer/install-windows.ps1` - - `https://links.calycode.com/install-cli-windows-bat` -> `installer/install-windows.bat` - -These URLs should be configured as a GitHub Pages site or CDN pointing to the `scripts/installer/` directory. +### CI +The `.github/workflows/build-bootstrapper.yml` workflow: +- Builds on push to `main` (when `bootstrapper/` files change) +- Builds and attaches binaries when a GitHub Release is published +- Maintains a floating `bootstrapper-latest` tag for development -## Extension Mapping (User Agent -> Command) +--- -Suggested mapping for extension-side install prompts: - -- `win32` + PowerShell available -> `irm https://links.calycode.com/install-cli-windows-ps1 | iex` -- `win32` fallback -> `curl -fsSL https://links.calycode.com/install-cli-windows-bat -o install-windows.bat && install-windows.bat` -- `darwin` -> `curl -fsSL https://links.calycode.com/install-cli-unix | bash` -- `linux` -> `curl -fsSL https://links.calycode.com/install-cli-unix | bash` - -## Troubleshooting - -### "caly-xano command not found" after installation - -The PATH may not have been updated in your current terminal session. - -- **Solution:** Close and reopen your terminal, or run `source ~/.bashrc` (Unix) / restart PowerShell (Windows) - -### Node.js installation fails - -- **Windows:** Ensure Winget or Chocolatey is available -- **macOS:** Ensure Homebrew is installed or can be installed -- **Linux:** Supported distributions: Debian/Ubuntu, RHEL/Fedora, Arch - -### Native host not connecting - -1. Verify the manifest was created: - - **macOS:** `~/Library/Application Support/Google/Chrome/NativeMessagingHosts/com.calycode.cli.json` - - **Linux:** `~/.config/google-chrome/NativeMessagingHosts/com.calycode.cli.json` - - **Windows:** `%USERPROFILE%\.calycode\com.calycode.cli.json` - -2. Reload the Chrome extension - -3. Check logs at `~/.calycode/logs/native-host.log` +## Directory Structure -4. Re-run: `caly-xano opencode init` +``` +bootstrapper/ # Go bootstrapper source (primary) +├── main.go # Entry point — orchestrate install flow +├── install/ +│ ├── node.go # Node.js version detection (cross-platform) +│ ├── node_windows.go # Windows: winget install +│ ├── node_darwin.go # macOS: brew install +│ ├── node_unsupported.go # Stub for other platforms +│ └── cli.go # npm install + oc init +├── ui/ +│ ├── dialog_windows.go # Windows: MessageBox native dialogs +│ ├── dialog_darwin.go # macOS: osascript dialogs +│ └── dialog_unsupported.go# Stub for other platforms +└── Makefile # Cross-compile targets + +scripts/installer/ # Shell fallbacks (kept for CI / headless) +├── install.sh # Unix installer +├── install.ps1 # Windows PowerShell installer +└── install.bat # Windows CMD wrapper + +scripts/dev/ # Development-only (for CLI contributors) +├── install-unix.sh +└── install-win.bat +``` diff --git a/packages/cli/src/commands/opencode/implementation.ts b/packages/cli/src/commands/opencode/implementation.ts index 5859bb60..f46d240b 100644 --- a/packages/cli/src/commands/opencode/implementation.ts +++ b/packages/cli/src/commands/opencode/implementation.ts @@ -27,6 +27,13 @@ interface LaunchOpencodeServerOptions { stdio?: 'inherit' | 'pipe' | 'ignore'; detach?: boolean; ocVersion?: string; + allowGlobalFallback?: boolean; + onManagedFail?: (err: Error) => void; + onGlobalVersionMismatch?: (details: { + expectedVersion: string; + actualVersion?: string; + globalBinaryPath: string; + }) => void; } interface OpencodeSpawnPlan { @@ -145,6 +152,23 @@ function findGlobalOpencodeBinary(): string | undefined { } } +function getOpencodeBinaryVersion(binaryPath: string): string | undefined { + try { + const output = execFileSync(binaryPath, ['--version'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + windowsHide: true, + }) + .toString() + .trim(); + + const match = output.match(/\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?/); + return match?.[0]; + } catch { + return undefined; + } +} + function ensureManagedOpencodeInstalled(version: string): string { const managedBinPath = getManagedOpencodeBinPath(version); if (fileExists(managedBinPath)) { @@ -182,7 +206,16 @@ function ensureManagedOpencodeInstalled(version: string): string { function buildOpencodeSpawnPlan( version: string, opencodeArgs: string[], - options?: { ensureManagedInstall?: boolean }, + options?: { + ensureManagedInstall?: boolean; + allowGlobalFallback?: boolean; + onManagedFail?: (err: Error) => void; + onGlobalVersionMismatch?: (details: { + expectedVersion: string; + actualVersion?: string; + globalBinaryPath: string; + }) => void; + }, ): OpencodeSpawnPlan { const explicitBin = process.env.CALY_OC_OPENCODE_BIN?.trim(); if (explicitBin) { @@ -220,20 +253,39 @@ function buildOpencodeSpawnPlan( displayCommand: `${installedBin} ${opencodeArgs.join(' ')}`.trim(), needsShell: false, }; - } catch { - // Continue to global/npx fallbacks when managed install is unavailable. + } catch (err) { + if (options?.onManagedFail) { + options.onManagedFail(err instanceof Error ? err : new Error(String(err))); + } } } - const globalOpencode = findGlobalOpencodeBinary(); - if (globalOpencode) { - return { - command: globalOpencode, - args: opencodeArgs, - source: 'global', - displayCommand: `${globalOpencode} ${opencodeArgs.join(' ')}`.trim(), - needsShell: false, - }; + const allowGlobalFallback = options?.allowGlobalFallback !== false; + if (allowGlobalFallback) { + const globalOpencode = findGlobalOpencodeBinary(); + if (globalOpencode) { + const globalVersion = getOpencodeBinaryVersion(globalOpencode); + if (globalVersion === version) { + return { + command: globalOpencode, + args: opencodeArgs, + source: 'global', + displayCommand: `${globalOpencode} ${opencodeArgs.join(' ')}`.trim(), + needsShell: false, + }; + } + + if (options?.onGlobalVersionMismatch) { + options.onGlobalVersionMismatch({ + expectedVersion: version, + actualVersion: globalVersion, + globalBinaryPath: globalOpencode, + }); + } + + // Global binary exists but does not match requested version; fall back to npx. + // This preserves strict version pinning behavior. + } } const npxArgs = ['-y', getOpencodePackageSpecifier(version), ...opencodeArgs]; @@ -260,6 +312,9 @@ function launchOpencodeServer({ stdio = 'inherit', detach = false, ocVersion, + allowGlobalFallback, + onManagedFail, + onGlobalVersionMismatch, }: LaunchOpencodeServerOptions) { validatePort(port); @@ -270,7 +325,11 @@ function launchOpencodeServer({ String(port), ...getCorsArgs(extraOrigins), ]; - const plan = buildOpencodeSpawnPlan(resolvedVersion, opencodeArgs); + const plan = buildOpencodeSpawnPlan(resolvedVersion, opencodeArgs, { + allowGlobalFallback, + onManagedFail, + onGlobalVersionMismatch, + }); const configDir = getCalycodeOpencodeConfigDir(); const workingDir = getOpencodeWorkingDir('server'); @@ -883,10 +942,6 @@ async function startNativeHost() { const resolvedVersion = resolveOcVersion( requestedOcVersion || parseOcVersionFromArgv(process.argv), ); - const opencodeArgs = ['serve', '--port', String(port), ...getCorsArgs(extraOrigins)]; - const launchPlan = buildOpencodeSpawnPlan(resolvedVersion, opencodeArgs); - logger.log(`Spawning ${launchPlan.displayCommand}`); - logger.log(`OpenCode launcher source: ${launchPlan.source}`); logger.log(`Using OpenCode version: ${resolvedVersion}`); logger.log(`Using OpenCode config directory: ${getCalycodeOpencodeConfigDir()}`); logger.log(`Using OpenCode working directory: ${getOpencodeWorkingDir('server')}`); @@ -901,7 +956,20 @@ async function startNativeHost() { extraOrigins, stdio: 'ignore', ocVersion: resolvedVersion, + allowGlobalFallback: false, + onManagedFail: (err) => + logger.log('Managed OpenCode install failed, falling back', { + error: err.message, + }), + onGlobalVersionMismatch: ({ expectedVersion, actualVersion, globalBinaryPath }) => + logger.log('Global OpenCode version mismatch; falling back to npx', { + expectedVersion, + actualVersion, + globalBinaryPath, + }), }); + logger.log(`Spawning ${launched.plan.displayCommand}`); + logger.log(`OpenCode launcher source: ${launched.plan.source}`); serverProc = launched.proc; registerManagedSession(port, launched.proc); From 6e2118eb5503b50c705707aa79e4d1069285679c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mih=C3=A1ly=20T=C3=B3th?= Date: Thu, 21 May 2026 16:49:49 +0200 Subject: [PATCH 4/9] chore: style for mac os and extension installer flow docs --- bootstrapper/ui/session_darwin.go | 25 +++++- docs/extension-installer-flow.md | 90 +++++++++++++++++++ packages/cli/scripts/README.md | 2 + .../src/commands/opencode/implementation.ts | 75 ++++++++++++++++ 4 files changed, 189 insertions(+), 3 deletions(-) create mode 100644 docs/extension-installer-flow.md diff --git a/bootstrapper/ui/session_darwin.go b/bootstrapper/ui/session_darwin.go index a4b927c3..be832fc6 100644 --- a/bootstrapper/ui/session_darwin.go +++ b/bootstrapper/ui/session_darwin.go @@ -2,8 +2,27 @@ package ui +import "fmt" + func newInstallerSession() *InstallerSession { - // macOS: keep setup non-blocking and avoid repeated modal prompts. - // A native persistent window can be added later via .app bundle. - return &InstallerSession{} + // macOS: use a persistent terminal step indicator (single session output) + // to avoid repeated modal prompts. A fully native .app-style installer + // window can be added later if needed. + return &InstallerSession{ + updateFn: func(step, total int, title, detail string) { + if total < 1 { + total = 1 + } + if step < 0 { + step = 0 + } + if step > total { + step = total + } + fmt.Printf("\r[CalyCode Installer] Step %d/%d - %s: %s", step, total, title, detail) + }, + closeFn: func() { + fmt.Print("\n") + }, + } } diff --git a/docs/extension-installer-flow.md b/docs/extension-installer-flow.md new file mode 100644 index 00000000..7e4b9b48 --- /dev/null +++ b/docs/extension-installer-flow.md @@ -0,0 +1,90 @@ +# Extension Installer Flow (Bootstrapper) + +This document explains how the browser extension should guide users through installing the CalyCode CLI using the bootstrapper binaries. + +## Goal + +Provide a **download + run** flow with minimal friction: + +1. Extension detects platform/architecture +2. Extension shows one-click download +3. User runs installer binary +4. Installer performs setup silently in steps +5. Extension verifies CLI/native host connectivity + +## Platform Mapping + +Use these release assets: + +- Windows x64: + - `https://github.com/calycode/xano-tools/releases/latest/download/calycode-installer-windows-x64.exe` +- macOS Intel (x64): + - `https://github.com/calycode/xano-tools/releases/latest/download/calycode-installer-darwin-x64` +- macOS Apple Silicon (arm64): + - `https://github.com/calycode/xano-tools/releases/latest/download/calycode-installer-darwin-arm64` + +Optional checksum: + +- `https://github.com/calycode/xano-tools/releases/latest/download/SHA256SUMS` + +The release assets are also available under the: +- https://links.calycode.com/cli-installer-- + +## Extension Detection Logic + +Recommended selection order: + +1. If platform is Windows: use Windows x64 installer. +2. If platform is macOS: + - if architecture is `arm64`, use macOS arm64 installer + - otherwise use macOS x64 installer +3. Otherwise (Linux/unknown): show shell-script fallback instructions. + +Notes: + +- On macOS in browser contexts where architecture is ambiguous, prefer arm64 only when explicitly detected. +- If architecture cannot be resolved, default to macOS x64 and provide a manual switch. + +## User-Facing Copy (Suggested) + +### Step 1: Download + +"Download the CalyCode installer for your system." + +### Step 2: Run + +"Open the downloaded installer and follow the setup steps." + +### Step 3: Return + +"Come back here after installation. We'll verify the connection." + +macOS note copy: + +"If macOS blocks opening the file, right-click it and choose **Open**." + +## What the Installer Does + +The bootstrapper performs: + +1. Node.js check/install (18+) +2. Global install of `@calycode/cli` +3. `caly-xano opencode init` +4. Final success/failure message + +## Verification in Extension + +After user returns, extension should: + +1. Attempt native host `ping` +2. If `pong` succeeds, proceed +3. If not, show troubleshooting CTA: + - rerun installer + - open terminal and run `caly-xano opencode init` + +## Fallback (No Bootstrapper) + +For unsupported platforms or manual flow: + +- Windows PowerShell: `irm https://links.calycode.com/install-cli-windows-ps1 | iex` +- macOS/Linux Bash: `curl -fsSL https://links.calycode.com/install-cli-unix | bash` diff --git a/packages/cli/scripts/README.md b/packages/cli/scripts/README.md index bbfd06de..248389ab 100644 --- a/packages/cli/scripts/README.md +++ b/packages/cli/scripts/README.md @@ -64,6 +64,8 @@ linux → Fall back to shell script: curl ... | bash Architecture detection on macOS: check `navigator.userAgentData` or serve a universal page. +For a full extension integration guide, see `docs/extension-installer-flow.md`. + --- ## For Developers diff --git a/packages/cli/src/commands/opencode/implementation.ts b/packages/cli/src/commands/opencode/implementation.ts index f46d240b..877897cc 100644 --- a/packages/cli/src/commands/opencode/implementation.ts +++ b/packages/cli/src/commands/opencode/implementation.ts @@ -115,6 +115,78 @@ function getManagedOpencodeBinPath(version: string): string { return path.join(getManagedOpencodeInstallDir(version), 'node_modules', '.bin', binName); } +function parseManagedVersion(version: string): { + major: number; + minor: number; + patch: number; + prerelease?: string; +} | null { + if (!OC_VERSION_REGEX.test(version)) { + return null; + } + + const normalized = version.split('+')[0]; + const [core, prerelease] = normalized.split('-', 2); + const parts = (core || '').split('.').map((n) => Number.parseInt(n, 10)); + if (parts.length < 3 || parts.some((n) => Number.isNaN(n))) { + return null; + } + + return { + major: parts[0], + minor: parts[1], + patch: parts[2], + prerelease, + }; +} + +function compareManagedVersionsDesc(a: string, b: string): number { + const av = parseManagedVersion(a); + const bv = parseManagedVersion(b); + if (!av && !bv) return b.localeCompare(a); + if (!av) return 1; + if (!bv) return -1; + + if (av.major !== bv.major) return bv.major - av.major; + if (av.minor !== bv.minor) return bv.minor - av.minor; + if (av.patch !== bv.patch) return bv.patch - av.patch; + + const aPre = av.prerelease; + const bPre = bv.prerelease; + if (!aPre && bPre) return -1; // stable > prerelease + if (aPre && !bPre) return 1; + if (!aPre && !bPre) return 0; + return (bPre || '').localeCompare(aPre || ''); +} + +function pruneManagedOpencodeVersions(keepLatest: number = 5): void { + try { + const versionsDir = getManagedOpencodeVersionsDir(); + if (!fs.existsSync(versionsDir)) { + return; + } + + const entries = fs + .readdirSync(versionsDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .filter((name) => OC_VERSION_REGEX.test(name)) + .sort(compareManagedVersionsDesc); + + const toDelete = entries.slice(Math.max(keepLatest, 0)); + for (const version of toDelete) { + const target = path.join(versionsDir, version); + try { + fs.rmSync(target, { recursive: true, force: true }); + } catch { + // Best effort cleanup only. + } + } + } catch { + // Best effort cleanup only. + } +} + function fileExists(candidatePath: string): boolean { try { return fs.existsSync(candidatePath); @@ -172,6 +244,7 @@ function getOpencodeBinaryVersion(binaryPath: string): string | undefined { function ensureManagedOpencodeInstalled(version: string): string { const managedBinPath = getManagedOpencodeBinPath(version); if (fileExists(managedBinPath)) { + pruneManagedOpencodeVersions(5); return managedBinPath; } @@ -200,6 +273,8 @@ function ensureManagedOpencodeInstalled(version: string): string { } } + pruneManagedOpencodeVersions(5); + return managedBinPath; } From e09087e5f2013fbcb18b6db6f506e33b33a05df0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mih=C3=A1ly=20T=C3=B3th?= Date: Thu, 21 May 2026 18:41:44 +0200 Subject: [PATCH 5/9] feat: localize bootstrapper prompts and expand language support --- .github/workflows/build-bootstrapper.yml | 16 +- bootstrapper/Makefile | 2 + bootstrapper/i18n/i18n.go | 389 +++++++++++++++++++++++ bootstrapper/install/cli.go | 2 +- bootstrapper/install/messages.go | 9 + bootstrapper/install/node_darwin.go | 6 +- bootstrapper/install/node_windows.go | 2 +- bootstrapper/main.go | 41 ++- bootstrapper/ui/dialog_darwin.go | 50 ++- bootstrapper/ui/dialog_windows.go | 53 +-- bootstrapper/ui/messages.go | 9 + bootstrapper/ui/session_darwin.go | 2 +- bootstrapper/ui/session_windows.go | 14 +- docs/extension-installer-flow.md | 6 +- packages/cli/scripts/README.md | 6 +- 15 files changed, 517 insertions(+), 90 deletions(-) create mode 100644 bootstrapper/i18n/i18n.go create mode 100644 bootstrapper/install/messages.go create mode 100644 bootstrapper/ui/messages.go diff --git a/.github/workflows/build-bootstrapper.yml b/.github/workflows/build-bootstrapper.yml index 2b640e04..e00eee62 100644 --- a/.github/workflows/build-bootstrapper.yml +++ b/.github/workflows/build-bootstrapper.yml @@ -44,6 +44,8 @@ jobs: go build -ldflags "-s -w" \ -o dist/calycode-installer-darwin-x64 \ . + cd dist + zip -9 calycode-installer-darwin-x64.zip calycode-installer-darwin-x64 - name: Build macOS arm64 working-directory: bootstrapper @@ -52,6 +54,8 @@ jobs: go build -ldflags "-s -w" \ -o dist/calycode-installer-darwin-arm64 \ . + cd dist + zip -9 calycode-installer-darwin-arm64.zip calycode-installer-darwin-arm64 - name: Generate checksums working-directory: bootstrapper/dist @@ -65,12 +69,12 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - gh release upload "${{ github.event.release.tag_name }}" \ - bootstrapper/dist/calycode-installer-windows-x64.exe \ - bootstrapper/dist/calycode-installer-darwin-x64 \ - bootstrapper/dist/calycode-installer-darwin-arm64 \ - bootstrapper/dist/SHA256SUMS \ - --clobber + gh release upload "${{ github.event.release.tag_name }}" \ + bootstrapper/dist/calycode-installer-windows-x64.exe \ + bootstrapper/dist/calycode-installer-darwin-x64.zip \ + bootstrapper/dist/calycode-installer-darwin-arm64.zip \ + bootstrapper/dist/SHA256SUMS \ + --clobber # --- Floating tag --- # Keeps `bootstrapper-latest` pointing at HEAD so diff --git a/bootstrapper/Makefile b/bootstrapper/Makefile index 04ed25fb..26d1d55b 100644 --- a/bootstrapper/Makefile +++ b/bootstrapper/Makefile @@ -31,6 +31,7 @@ darwin-amd64: -ldflags "-s -w" \ -o $(OUT_DIR)/$(BINARY)-darwin-x64 \ $(MODULE) + cd $(OUT_DIR) && zip -9 $(BINARY)-darwin-x64.zip $(BINARY)-darwin-x64 darwin-arm64: @echo "Building macOS Apple Silicon..." @@ -38,6 +39,7 @@ darwin-arm64: -ldflags "-s -w" \ -o $(OUT_DIR)/$(BINARY)-darwin-arm64 \ $(MODULE) + cd $(OUT_DIR) && zip -9 $(BINARY)-darwin-arm64.zip $(BINARY)-darwin-arm64 clean: rm -rf $(OUT_DIR) diff --git a/bootstrapper/i18n/i18n.go b/bootstrapper/i18n/i18n.go new file mode 100644 index 00000000..e0cb85db --- /dev/null +++ b/bootstrapper/i18n/i18n.go @@ -0,0 +1,389 @@ +package i18n + +import ( + "os" + "strings" + "sync" +) + +type Catalog struct { + SetupTitle string + SetupCompleteTitle string + InstallerWindowTitle string + WelcomeMessage string + InstallingNodeDarwinMessage string + InstallingNodeWindowsMessage string + InstallingCLIMessage string + ConfiguringNativeHostMessage string + StepCheckingTitle string + StepCheckingDetail string + StepInstallDepsTitle string + StepInstallDepsDetail string + StepInstallCLITitle string + StepInstallCLIDetail string + StepConfigureTitle string + StepConfigureDetail string + NodeRequiredTitle string + NodeRequiredMessageFmt string + NodeInstallFailedTitle string + NodeInstallFailedMessage string + InstallFailedTitle string + InstallFailedMessageFmt string + ExtSetupIncompleteTitle string + ExtSetupIncompleteMessageFmt string + SuccessIntro string + SuccessVersionFmt string + SuccessNextSteps string + SuccessReloadExtensionBullet string + SuccessRunHelpBulletFmt string + SuccessClickOK string + SessionProgressFmt string + SessionPreparingTitle string + SessionPreparingDetail string + SessionStepLabelFmtPS string + SessionDoneTitle string + SessionDoneDetail string + NodeInstalledNotInPath string + HomebrewInstallFailed string + HomebrewInstalledNotInPath string + NodeInstallManual string + CalyXanoNotFound string + TerminalLabelDarwin string + TerminalLabelWindows string +} + +var ( + once sync.Once + current Catalog +) + +func Get() Catalog { + once.Do(func() { + current = selectCatalog(detectLocale()) + }) + return current +} + +func detectLocale() string { + for _, key := range []string{"CALYCODE_LANG", "LC_ALL", "LANG"} { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + v = strings.ToLower(v) + if i := strings.IndexByte(v, '.'); i > 0 { + v = v[:i] + } + if i := strings.IndexByte(v, '_'); i > 0 { + v = v[:i] + } + if i := strings.IndexByte(v, '-'); i > 0 { + v = v[:i] + } + return v + } + } + return "en" +} + +func selectCatalog(lang string) Catalog { + switch lang { + case "es": + return esCatalog() + case "de": + return deCatalog() + case "fr": + return frCatalog() + case "hu": + return huCatalog() + case "sr": + return srCatalog() + default: + return enCatalog() + } +} + +func enCatalog() Catalog { + return Catalog{ + SetupTitle: "@calycode/cli Setup", + SetupCompleteTitle: "@calycode/cli Setup - Complete", + InstallerWindowTitle: "@calycode/cli Installer", + WelcomeMessage: "This flow will install the @calycode/cli and configure browser extension integration.\n\nThe process takes 1-2 minutes.\n\nClick OK to continue.", + InstallingNodeDarwinMessage: "Node.js 18+ is required but not found.\n\nInstalling Node.js now via Homebrew...\nThis may take a few minutes.\n\nClick OK to proceed.", + InstallingNodeWindowsMessage: "Node.js 18+ is required but not found.\n\nInstalling Node.js now via Winget...\nThis may take a few minutes.", + InstallingCLIMessage: "Installing @calycode/cli...\nThis may take a moment.", + ConfiguringNativeHostMessage: "Configuring browser extension connection...", + StepCheckingTitle: "Checking prerequisites", + StepCheckingDetail: "Checking Node.js 18+...", + StepInstallDepsTitle: "Installing dependencies", + StepInstallDepsDetail: "Node.js not found. Installing Node.js 18+...", + StepInstallCLITitle: "Installing @calycode/cli", + StepInstallCLIDetail: "Installing @calycode/cli globally...", + StepConfigureTitle: "Configuring browser integration", + StepConfigureDetail: "Setting up native messaging host...", + NodeRequiredTitle: "Node.js Required", + NodeRequiredMessageFmt: "Node.js 18+ is required but could not be installed automatically.\n\n%s\n\nPlease install it manually from https://nodejs.org\nThen run this installer again.", + NodeInstallFailedTitle: "Node.js Installation Failed", + NodeInstallFailedMessage: "Node.js was installed but is not available in PATH.\n\nPlease restart your computer and run this installer again.", + InstallFailedTitle: "Installation Failed", + InstallFailedMessageFmt: "Could not install @calycode/cli.\n\n%s\n\nCheck your internet connection and try again.\nOr install manually: npm install -g @calycode/cli", + ExtSetupIncompleteTitle: "Extension Integration Setup Incomplete", + ExtSetupIncompleteMessageFmt: "@calycode/cli is installed but the browser extension integration could not be configured.\n\n%s\n\nRun this command in terminal to finish setup:\n caly-xano opencode init\n\nThen reload your browser.", + SuccessIntro: "@calycode/cli has been installed successfully!\n\n", + SuccessVersionFmt: "Version: %s\n\n", + SuccessNextSteps: "Next steps:\n", + SuccessReloadExtensionBullet: " - Reload your browser\n", + SuccessRunHelpBulletFmt: " - Open %s and run: caly-xano --help\n", + SuccessClickOK: "\nClick OK to finish.", + SessionProgressFmt: "[@calycode/cli Installer] Step %d/%d - %s: %s", + SessionPreparingTitle: "Preparing...", + SessionPreparingDetail: "Starting installer", + SessionStepLabelFmtPS: "Step {0} of {1}", + SessionDoneTitle: "Done", + SessionDoneDetail: "Finalizing...", + NodeInstalledNotInPath: "Node.js was installed but is not available in PATH. Restart Terminal and try again.", + HomebrewInstallFailed: "Failed to install Homebrew. Install it manually from https://brew.sh", + HomebrewInstalledNotInPath: "Homebrew was installed but is not available in PATH. Restart Terminal and try again.", + NodeInstallManual: "Could not install Node.js. Install it manually from https://nodejs.org", + CalyXanoNotFound: "caly-xano command not found", + TerminalLabelDarwin: "Terminal", + TerminalLabelWindows: "a terminal", + } +} + +func esCatalog() Catalog { + return Catalog{ + SetupTitle: "Configuracion de @calycode/cli", + SetupCompleteTitle: "Configuracion de @calycode/cli - Completa", + InstallerWindowTitle: "Instalador de @calycode/cli", + WelcomeMessage: "Este flujo instalara @calycode/cli y configurara la integracion de la extension del navegador.\n\nEl proceso tarda 1-2 minutos.\n\nHaz clic en OK para continuar.", + InstallingNodeDarwinMessage: "Se requiere Node.js 18+ pero no se encontro.\n\nInstalando Node.js con Homebrew...\nEsto puede tardar unos minutos.\n\nHaz clic en OK para continuar.", + InstallingNodeWindowsMessage: "Se requiere Node.js 18+ pero no se encontro.\n\nInstalando Node.js con Winget...\nEsto puede tardar unos minutos.", + InstallingCLIMessage: "Instalando @calycode/cli...\nEsto puede tardar un momento.", + ConfiguringNativeHostMessage: "Configurando la conexion de la extension del navegador...", + StepCheckingTitle: "Verificando requisitos", + StepCheckingDetail: "Verificando Node.js 18+...", + StepInstallDepsTitle: "Instalando dependencias", + StepInstallDepsDetail: "Node.js no encontrado. Instalando Node.js 18+...", + StepInstallCLITitle: "Instalando @calycode/cli", + StepInstallCLIDetail: "Instalando @calycode/cli globalmente...", + StepConfigureTitle: "Configurando integracion del navegador", + StepConfigureDetail: "Configurando native messaging host...", + NodeRequiredTitle: "Node.js requerido", + NodeRequiredMessageFmt: "Node.js 18+ es obligatorio, pero no se pudo instalar automaticamente.\n\n%s\n\nInstalalo manualmente desde https://nodejs.org\nLuego ejecuta este instalador otra vez.", + NodeInstallFailedTitle: "Fallo la instalacion de Node.js", + NodeInstallFailedMessage: "Node.js se instalo, pero no esta disponible en PATH.\n\nReinicia tu computadora y ejecuta este instalador otra vez.", + InstallFailedTitle: "Instalacion fallida", + InstallFailedMessageFmt: "No se pudo instalar @calycode/cli.\n\n%s\n\nRevisa tu conexion a internet e intentalo de nuevo.\nO instala manualmente: npm install -g @calycode/cli", + ExtSetupIncompleteTitle: "Configuracion de integracion de extension incompleta", + ExtSetupIncompleteMessageFmt: "@calycode/cli esta instalado, pero no se pudo configurar la integracion de la extension del navegador.\n\n%s\n\nEjecuta este comando en terminal para terminar la configuracion:\n caly-xano opencode init\n\nLuego recarga tu navegador.", + SuccessIntro: "@calycode/cli se instalo correctamente.\n\n", + SuccessVersionFmt: "Version: %s\n\n", + SuccessNextSteps: "Siguientes pasos:\n", + SuccessReloadExtensionBullet: " - Recarga tu navegador\n", + SuccessRunHelpBulletFmt: " - Abre %s y ejecuta: caly-xano --help\n", + SuccessClickOK: "\nHaz clic en OK para finalizar.", + SessionProgressFmt: "[Instalador @calycode/cli] Paso %d/%d - %s: %s", + SessionPreparingTitle: "Preparando...", + SessionPreparingDetail: "Iniciando instalador", + SessionStepLabelFmtPS: "Paso {0} de {1}", + SessionDoneTitle: "Listo", + SessionDoneDetail: "Finalizando...", + NodeInstalledNotInPath: "Node.js se instalo, pero no esta disponible en PATH. Reinicia Terminal e intentalo otra vez.", + HomebrewInstallFailed: "No se pudo instalar Homebrew. Instalalo manualmente desde https://brew.sh", + HomebrewInstalledNotInPath: "Homebrew se instalo, pero no esta disponible en PATH. Reinicia Terminal e intentalo otra vez.", + NodeInstallManual: "No se pudo instalar Node.js. Instalalo manualmente desde https://nodejs.org", + CalyXanoNotFound: "no se encontro el comando caly-xano", + TerminalLabelDarwin: "Terminal", + TerminalLabelWindows: "una terminal", + } +} + +func deCatalog() Catalog { + return Catalog{ + SetupTitle: "@calycode/cli Einrichtung", + SetupCompleteTitle: "@calycode/cli Einrichtung - Abgeschlossen", + InstallerWindowTitle: "@calycode/cli Installer", + WelcomeMessage: "Dieser Ablauf installiert @calycode/cli und richtet die Browser-Erweiterungsintegration ein.\n\nDer Vorgang dauert 1-2 Minuten.\n\nKlicke auf OK, um fortzufahren.", + InstallingNodeDarwinMessage: "Node.js 18+ ist erforderlich, wurde aber nicht gefunden.\n\nNode.js wird jetzt ueber Homebrew installiert...\nDies kann einige Minuten dauern.\n\nKlicke auf OK, um fortzufahren.", + InstallingNodeWindowsMessage: "Node.js 18+ ist erforderlich, wurde aber nicht gefunden.\n\nNode.js wird jetzt ueber Winget installiert...\nDies kann einige Minuten dauern.", + InstallingCLIMessage: "@calycode/cli wird installiert...\nDas kann einen Moment dauern.", + ConfiguringNativeHostMessage: "Browser-Erweiterungsverbindung wird konfiguriert...", + StepCheckingTitle: "Voraussetzungen werden geprueft", + StepCheckingDetail: "Node.js 18+ wird geprueft...", + StepInstallDepsTitle: "Abhaengigkeiten werden installiert", + StepInstallDepsDetail: "Node.js nicht gefunden. Node.js 18+ wird installiert...", + StepInstallCLITitle: "@calycode/cli wird installiert", + StepInstallCLIDetail: "@calycode/cli wird global installiert...", + StepConfigureTitle: "Browser-Integration wird konfiguriert", + StepConfigureDetail: "Native Messaging Host wird eingerichtet...", + NodeRequiredTitle: "Node.js erforderlich", + NodeRequiredMessageFmt: "Node.js 18+ ist erforderlich, konnte aber nicht automatisch installiert werden.\n\n%s\n\nBitte installiere es manuell von https://nodejs.org\nStarte danach diesen Installer erneut.", + NodeInstallFailedTitle: "Node.js Installation fehlgeschlagen", + NodeInstallFailedMessage: "Node.js wurde installiert, ist aber im PATH nicht verfuegbar.\n\nBitte starte deinen Computer neu und fuehre diesen Installer erneut aus.", + InstallFailedTitle: "Installation fehlgeschlagen", + InstallFailedMessageFmt: "@calycode/cli konnte nicht installiert werden.\n\n%s\n\nPruefe deine Internetverbindung und versuche es erneut.\nOder manuell installieren: npm install -g @calycode/cli", + ExtSetupIncompleteTitle: "Einrichtung der Erweiterungsintegration unvollstaendig", + ExtSetupIncompleteMessageFmt: "@calycode/cli ist installiert, aber die Browser-Erweiterungsintegration konnte nicht konfiguriert werden.\n\n%s\n\nFuehre diesen Befehl im Terminal aus, um die Einrichtung abzuschliessen:\n caly-xano opencode init\n\nLade danach deinen Browser neu.", + SuccessIntro: "@calycode/cli wurde erfolgreich installiert!\n\n", + SuccessVersionFmt: "Version: %s\n\n", + SuccessNextSteps: "Naechste Schritte:\n", + SuccessReloadExtensionBullet: " - Browser neu laden\n", + SuccessRunHelpBulletFmt: " - %s oeffnen und ausfuehren: caly-xano --help\n", + SuccessClickOK: "\nKlicke auf OK, um zu beenden.", + SessionProgressFmt: "[@calycode/cli Installer] Schritt %d/%d - %s: %s", + SessionPreparingTitle: "Wird vorbereitet...", + SessionPreparingDetail: "Installer wird gestartet", + SessionStepLabelFmtPS: "Schritt {0} von {1}", + SessionDoneTitle: "Fertig", + SessionDoneDetail: "Wird abgeschlossen...", + NodeInstalledNotInPath: "Node.js wurde installiert, ist aber im PATH nicht verfuegbar. Starte das Terminal neu und versuche es erneut.", + HomebrewInstallFailed: "Homebrew konnte nicht installiert werden. Bitte installiere es manuell von https://brew.sh", + HomebrewInstalledNotInPath: "Homebrew wurde installiert, ist aber im PATH nicht verfuegbar. Starte das Terminal neu und versuche es erneut.", + NodeInstallManual: "Node.js konnte nicht installiert werden. Bitte installiere es manuell von https://nodejs.org", + CalyXanoNotFound: "Befehl caly-xano nicht gefunden", + TerminalLabelDarwin: "Terminal", + TerminalLabelWindows: "ein Terminal", + } +} + +func frCatalog() Catalog { + return Catalog{ + SetupTitle: "Configuration de @calycode/cli", + SetupCompleteTitle: "Configuration de @calycode/cli - Terminee", + InstallerWindowTitle: "Installeur @calycode/cli", + WelcomeMessage: "Ce flux va installer @calycode/cli et configurer l'integration de l'extension navigateur.\n\nLe processus prend 1 a 2 minutes.\n\nCliquez sur OK pour continuer.", + InstallingNodeDarwinMessage: "Node.js 18+ est requis mais introuvable.\n\nInstallation de Node.js via Homebrew...\nCela peut prendre quelques minutes.\n\nCliquez sur OK pour continuer.", + InstallingNodeWindowsMessage: "Node.js 18+ est requis mais introuvable.\n\nInstallation de Node.js via Winget...\nCela peut prendre quelques minutes.", + InstallingCLIMessage: "Installation de @calycode/cli...\nCela peut prendre un instant.", + ConfiguringNativeHostMessage: "Configuration de la connexion de l'extension navigateur...", + StepCheckingTitle: "Verification des prerequis", + StepCheckingDetail: "Verification de Node.js 18+...", + StepInstallDepsTitle: "Installation des dependances", + StepInstallDepsDetail: "Node.js introuvable. Installation de Node.js 18+...", + StepInstallCLITitle: "Installation de @calycode/cli", + StepInstallCLIDetail: "Installation globale de @calycode/cli...", + StepConfigureTitle: "Configuration de l'integration navigateur", + StepConfigureDetail: "Configuration du native messaging host...", + NodeRequiredTitle: "Node.js requis", + NodeRequiredMessageFmt: "Node.js 18+ est requis mais n'a pas pu etre installe automatiquement.\n\n%s\n\nVeuillez l'installer manuellement depuis https://nodejs.org\nPuis relancez cet installeur.", + NodeInstallFailedTitle: "Echec de l'installation de Node.js", + NodeInstallFailedMessage: "Node.js a ete installe mais n'est pas disponible dans PATH.\n\nVeuillez redemarrer votre ordinateur puis relancer cet installeur.", + InstallFailedTitle: "Echec de l'installation", + InstallFailedMessageFmt: "Impossible d'installer @calycode/cli.\n\n%s\n\nVerifiez votre connexion internet et reessayez.\nOu installez manuellement: npm install -g @calycode/cli", + ExtSetupIncompleteTitle: "Configuration de l'integration de l'extension incomplete", + ExtSetupIncompleteMessageFmt: "@calycode/cli est installe mais l'integration de l'extension navigateur n'a pas pu etre configuree.\n\n%s\n\nExecutez cette commande dans le terminal pour terminer la configuration:\n caly-xano opencode init\n\nPuis rechargez votre navigateur.", + SuccessIntro: "@calycode/cli a ete installe avec succes !\n\n", + SuccessVersionFmt: "Version: %s\n\n", + SuccessNextSteps: "Etapes suivantes :\n", + SuccessReloadExtensionBullet: " - Rechargez votre navigateur\n", + SuccessRunHelpBulletFmt: " - Ouvrez %s et executez : caly-xano --help\n", + SuccessClickOK: "\nCliquez sur OK pour terminer.", + SessionProgressFmt: "[Installeur @calycode/cli] Etape %d/%d - %s : %s", + SessionPreparingTitle: "Preparation...", + SessionPreparingDetail: "Demarrage de l'installeur", + SessionStepLabelFmtPS: "Etape {0} sur {1}", + SessionDoneTitle: "Termine", + SessionDoneDetail: "Finalisation...", + NodeInstalledNotInPath: "Node.js a ete installe mais n'est pas disponible dans PATH. Redemarrez le Terminal puis reessayez.", + HomebrewInstallFailed: "Echec de l'installation de Homebrew. Installez-le manuellement depuis https://brew.sh", + HomebrewInstalledNotInPath: "Homebrew a ete installe mais n'est pas disponible dans PATH. Redemarrez le Terminal puis reessayez.", + NodeInstallManual: "Impossible d'installer Node.js. Installez-le manuellement depuis https://nodejs.org", + CalyXanoNotFound: "commande caly-xano introuvable", + TerminalLabelDarwin: "Terminal", + TerminalLabelWindows: "un terminal", + } +} + +func huCatalog() Catalog { + return Catalog{ + SetupTitle: "@calycode/cli beallitas", + SetupCompleteTitle: "@calycode/cli beallitas - Kesz", + InstallerWindowTitle: "@calycode/cli telepito", + WelcomeMessage: "Ez a folyamat telepiti a @calycode/cli-t, es beallitja a bongeszo kiegeszito integraciojat.\n\nA folyamat 1-2 percet vesz igenybe.\n\nKattints az OK gombra a folytatashoz.", + InstallingNodeDarwinMessage: "Node.js 18+ szukseges, de nem talalhato.\n\nNode.js telepitese Homebrew-vel...\nEz nehany percet igenybe vehet.\n\nKattints az OK gombra a folytatashoz.", + InstallingNodeWindowsMessage: "Node.js 18+ szukseges, de nem talalhato.\n\nNode.js telepitese Winget-tel...\nEz nehany percet igenybe vehet.", + InstallingCLIMessage: "@calycode/cli telepitese...\nEz eltarthat egy rovid ideig.", + ConfiguringNativeHostMessage: "Bongeszo kiegeszito kapcsolat beallitasa...", + StepCheckingTitle: "Elofeltetelek ellenorzese", + StepCheckingDetail: "Node.js 18+ ellenorzese...", + StepInstallDepsTitle: "Fuggosegek telepitese", + StepInstallDepsDetail: "Node.js nem talalhato. Node.js 18+ telepitese...", + StepInstallCLITitle: "@calycode/cli telepitese", + StepInstallCLIDetail: "@calycode/cli globalis telepitese...", + StepConfigureTitle: "Bongeszo integracio beallitasa", + StepConfigureDetail: "Native messaging host beallitasa...", + NodeRequiredTitle: "Node.js szukseges", + NodeRequiredMessageFmt: "Node.js 18+ szukseges, de automatikusan nem sikerult telepiteni.\n\n%s\n\nKerlek telepitsd kezzel innen: https://nodejs.org\nEzutan futtasd ujra ezt a telepitot.", + NodeInstallFailedTitle: "Node.js telepitese sikertelen", + NodeInstallFailedMessage: "A Node.js telepitve lett, de PATH-ban nem erheto el.\n\nInditsd ujra a gepet, majd futtasd ujra ezt a telepitot.", + InstallFailedTitle: "Telepites sikertelen", + InstallFailedMessageFmt: "Nem sikerult telepiteni a @calycode/cli-t.\n\n%s\n\nEllenorizd az internetkapcsolatot, majd probald ujra.\nVagy telepitsd kezzel: npm install -g @calycode/cli", + ExtSetupIncompleteTitle: "Kiegeszito integracio beallitasa nem teljes", + ExtSetupIncompleteMessageFmt: "A @calycode/cli telepitve van, de a bongeszo kiegeszito integraciojat nem sikerult beallitani.\n\n%s\n\nA beallitas befejezesehez futtasd ezt a parancsot terminalban:\n caly-xano opencode init\n\nEzutan toltsd ujra a bongeszot.", + SuccessIntro: "A @calycode/cli sikeresen telepitve lett!\n\n", + SuccessVersionFmt: "Verzio: %s\n\n", + SuccessNextSteps: "Kovetkezo lepesek:\n", + SuccessReloadExtensionBullet: " - Toltsd ujra a bongeszot\n", + SuccessRunHelpBulletFmt: " - Nyisd meg: %s, es futtasd: caly-xano --help\n", + SuccessClickOK: "\nKattints az OK gombra a befejezeshez.", + SessionProgressFmt: "[@calycode/cli telepito] Lepes %d/%d - %s: %s", + SessionPreparingTitle: "Elokeszites...", + SessionPreparingDetail: "Telepito inditasa", + SessionStepLabelFmtPS: "{0}. lepes / {1}", + SessionDoneTitle: "Kesz", + SessionDoneDetail: "Befejezes...", + NodeInstalledNotInPath: "A Node.js telepitve lett, de PATH-ban nem erheto el. Inditsd ujra a Terminalt, majd probald ujra.", + HomebrewInstallFailed: "A Homebrew telepitese sikertelen. Telepitsd kezzel innen: https://brew.sh", + HomebrewInstalledNotInPath: "A Homebrew telepitve lett, de PATH-ban nem erheto el. Inditsd ujra a Terminalt, majd probald ujra.", + NodeInstallManual: "Nem sikerult telepiteni a Node.js-t. Telepitsd kezzel innen: https://nodejs.org", + CalyXanoNotFound: "caly-xano parancs nem talalhato", + TerminalLabelDarwin: "Terminal", + TerminalLabelWindows: "egy terminal", + } +} + +func srCatalog() Catalog { + return Catalog{ + SetupTitle: "@calycode/cli podesavanje", + SetupCompleteTitle: "@calycode/cli podesavanje - Zavrseno", + InstallerWindowTitle: "@calycode/cli instalater", + WelcomeMessage: "Ovaj tok ce instalirati @calycode/cli i podesiti integraciju ekstenzije pregledaca.\n\nProces traje 1-2 minuta.\n\nKliknite OK za nastavak.", + InstallingNodeDarwinMessage: "Node.js 18+ je obavezan, ali nije pronadjen.\n\nInstalacija Node.js preko Homebrew...\nOvo moze potrajati nekoliko minuta.\n\nKliknite OK za nastavak.", + InstallingNodeWindowsMessage: "Node.js 18+ je obavezan, ali nije pronadjen.\n\nInstalacija Node.js preko Winget...\nOvo moze potrajati nekoliko minuta.", + InstallingCLIMessage: "Instalacija @calycode/cli...\nOvo moze potrajati trenutak.", + ConfiguringNativeHostMessage: "Podesavanje veze sa ekstenzijom pregledaca...", + StepCheckingTitle: "Provera preduslova", + StepCheckingDetail: "Provera Node.js 18+...", + StepInstallDepsTitle: "Instalacija zavisnosti", + StepInstallDepsDetail: "Node.js nije pronadjen. Instalacija Node.js 18+...", + StepInstallCLITitle: "Instalacija @calycode/cli", + StepInstallCLIDetail: "Globalna instalacija @calycode/cli...", + StepConfigureTitle: "Podesavanje integracije pregledaca", + StepConfigureDetail: "Podesavanje native messaging host-a...", + NodeRequiredTitle: "Node.js je obavezan", + NodeRequiredMessageFmt: "Node.js 18+ je obavezan, ali nije mogao automatski da se instalira.\n\n%s\n\nInstalirajte ga rucno sa https://nodejs.org\nZatim ponovo pokrenite ovaj instalater.", + NodeInstallFailedTitle: "Instalacija Node.js nije uspela", + NodeInstallFailedMessage: "Node.js je instaliran, ali nije dostupan u PATH-u.\n\nRestartujte racunar i ponovo pokrenite ovaj instalater.", + InstallFailedTitle: "Instalacija nije uspela", + InstallFailedMessageFmt: "Nije moguce instalirati @calycode/cli.\n\n%s\n\nProverite internet vezu i pokusajte ponovo.\nIli instalirajte rucno: npm install -g @calycode/cli", + ExtSetupIncompleteTitle: "Podesavanje integracije ekstenzije nije kompletno", + ExtSetupIncompleteMessageFmt: "@calycode/cli je instaliran, ali integracija ekstenzije pregledaca nije mogla da se podesi.\n\n%s\n\nPokrenite ovu komandu u terminalu da zavrsite podesavanje:\n caly-xano opencode init\n\nZatim osvezite pregledac.", + SuccessIntro: "@calycode/cli je uspesno instaliran!\n\n", + SuccessVersionFmt: "Verzija: %s\n\n", + SuccessNextSteps: "Sledeci koraci:\n", + SuccessReloadExtensionBullet: " - Osvezite pregledac\n", + SuccessRunHelpBulletFmt: " - Otvorite %s i pokrenite: caly-xano --help\n", + SuccessClickOK: "\nKliknite OK za kraj.", + SessionProgressFmt: "[@calycode/cli instalater] Korak %d/%d - %s: %s", + SessionPreparingTitle: "Priprema...", + SessionPreparingDetail: "Pokretanje instalatera", + SessionStepLabelFmtPS: "Korak {0} od {1}", + SessionDoneTitle: "Gotovo", + SessionDoneDetail: "Zavrsavanje...", + NodeInstalledNotInPath: "Node.js je instaliran, ali nije dostupan u PATH-u. Restartujte Terminal i pokusajte ponovo.", + HomebrewInstallFailed: "Instalacija Homebrew nije uspela. Instalirajte ga rucno sa https://brew.sh", + HomebrewInstalledNotInPath: "Homebrew je instaliran, ali nije dostupan u PATH-u. Restartujte Terminal i pokusajte ponovo.", + NodeInstallManual: "Nije moguce instalirati Node.js. Instalirajte ga rucno sa https://nodejs.org", + CalyXanoNotFound: "komanda caly-xano nije pronadjena", + TerminalLabelDarwin: "Terminal", + TerminalLabelWindows: "terminal", + } +} diff --git a/bootstrapper/install/cli.go b/bootstrapper/install/cli.go index ee0ce3a6..382eff25 100644 --- a/bootstrapper/install/cli.go +++ b/bootstrapper/install/cli.go @@ -38,7 +38,7 @@ func InstallCLI(version string) (output string, errOut string) { // Returns stderr on failure. func InitNativeHost() (errOut string) { if !CommandExists("caly-xano") { - return "caly-xano command not found" + return msg.CalyXanoNotFound } var stderr bytes.Buffer diff --git a/bootstrapper/install/messages.go b/bootstrapper/install/messages.go new file mode 100644 index 00000000..4913ff23 --- /dev/null +++ b/bootstrapper/install/messages.go @@ -0,0 +1,9 @@ +package install + +import "github.com/calycode/xano-tools/bootstrapper/i18n" + +var msg = i18n.Get() + +func SetMessages(c i18n.Catalog) { + msg = c +} diff --git a/bootstrapper/install/node_darwin.go b/bootstrapper/install/node_darwin.go index 4021c954..b10e7174 100644 --- a/bootstrapper/install/node_darwin.go +++ b/bootstrapper/install/node_darwin.go @@ -30,7 +30,7 @@ func InstallNode() (errOut string) { } if !NodeOK() { - return "Node.js was installed but is not available in PATH. Restart Terminal and try again." + return msg.NodeInstalledNotInPath } return "" } @@ -46,7 +46,7 @@ func installHomebrew() string { if stderr.Len() > 0 { return stderr.String() } - return "Failed to install Homebrew. Install it manually from https://brew.sh" + return msg.HomebrewInstallFailed } // Add Homebrew to PATH for Apple Silicon @@ -58,7 +58,7 @@ func installHomebrew() string { } if !CommandExists("brew") { - return "Homebrew was installed but is not available in PATH. Restart Terminal and try again." + return msg.HomebrewInstalledNotInPath } return "" } diff --git a/bootstrapper/install/node_windows.go b/bootstrapper/install/node_windows.go index e02ad117..d0766dc0 100644 --- a/bootstrapper/install/node_windows.go +++ b/bootstrapper/install/node_windows.go @@ -57,7 +57,7 @@ func InstallNode() (errOut string) { } } - return "Could not install Node.js. Install it manually from https://nodejs.org" + return msg.NodeInstallManual } func refreshPathWindows() { diff --git a/bootstrapper/main.go b/bootstrapper/main.go index b5909cf6..f64645cb 100644 --- a/bootstrapper/main.go +++ b/bootstrapper/main.go @@ -1,13 +1,19 @@ package main import ( + "fmt" "os" + "github.com/calycode/xano-tools/bootstrapper/i18n" "github.com/calycode/xano-tools/bootstrapper/install" "github.com/calycode/xano-tools/bootstrapper/ui" ) func main() { + t := i18n.Get() + install.SetMessages(t) + ui.SetMessages(t) + ui.ShowWelcome() session := ui.StartInstallerSession() @@ -18,51 +24,40 @@ func main() { } // --- STEP 1: Ensure Node.js >= 18 --- - session.Update(1, 3, "Checking prerequisites", "Checking Node.js 18+...") + session.Update(1, 3, t.StepCheckingTitle, t.StepCheckingDetail) if !install.NodeOK() { - session.Update(1, 3, "Installing dependencies", "Node.js not found. Installing Node.js 18+...") + session.Update(1, 3, t.StepInstallDepsTitle, t.StepInstallDepsDetail) if errOut := install.InstallNode(); errOut != "" { fail( - "Node.js Required", - "Node.js 18+ is required but could not be installed automatically.\n\n"+ - errOut+"\n\n"+ - "Please install it manually from https://nodejs.org\n"+ - "Then run this installer again.", + t.NodeRequiredTitle, + fmt.Sprintf(t.NodeRequiredMessageFmt, errOut), ) } if !install.NodeOK() { fail( - "Node.js Installation Failed", - "Node.js was installed but is not available in PATH.\n\n"+ - "Please restart your computer and run this installer again.", + t.NodeInstallFailedTitle, + t.NodeInstallFailedMessage, ) } } // --- STEP 2: Install @calycode/cli --- - session.Update(2, 3, "Installing CalyCode CLI", "Installing @calycode/cli globally...") + session.Update(2, 3, t.StepInstallCLITitle, t.StepInstallCLIDetail) cliVersion, errOut := install.InstallCLI("latest") if errOut != "" { fail( - "Installation Failed", - "Could not install @calycode/cli.\n\n"+ - errOut+"\n\n"+ - "Check your internet connection and try again.\n"+ - "Or install manually: npm install -g @calycode/cli", + t.InstallFailedTitle, + fmt.Sprintf(t.InstallFailedMessageFmt, errOut), ) } // --- STEP 3: Configure native messaging host --- - session.Update(3, 3, "Configuring browser integration", "Setting up native messaging host...") + session.Update(3, 3, t.StepConfigureTitle, t.StepConfigureDetail) if errOut := install.InitNativeHost(); errOut != "" { session.Close() ui.ShowWarning( - "Extension Setup Incomplete", - "CLI is installed but the browser extension native host could not be configured.\n\n"+ - errOut+"\n\n"+ - "Run this command in terminal to complete setup:\n"+ - " caly-xano opencode init\n\n"+ - "Then reload your Chrome extension.", + t.ExtSetupIncompleteTitle, + fmt.Sprintf(t.ExtSetupIncompleteMessageFmt, errOut), ) } diff --git a/bootstrapper/ui/dialog_darwin.go b/bootstrapper/ui/dialog_darwin.go index 42bd37f4..c83cd793 100644 --- a/bootstrapper/ui/dialog_darwin.go +++ b/bootstrapper/ui/dialog_darwin.go @@ -9,6 +9,8 @@ import ( "strings" ) +const autoCloseTerminalEnv = "CALYCODE_INSTALLER_AUTO_CLOSE_TERMINAL" + func osascript(script string) { cmd := exec.Command("osascript", "-e", script) cmd.Stdout = os.Stdout @@ -16,6 +18,11 @@ func osascript(script string) { cmd.Run() } +func osascriptDetached(script string) { + cmd := exec.Command("osascript", "-e", script) + _ = cmd.Start() +} + func escapeAppleScript(s string) string { s = strings.ReplaceAll(s, "\\", "\\\\") s = strings.ReplaceAll(s, "\"", "\\\"") @@ -38,8 +45,8 @@ func displayDialog(title, message string, buttons string, defaultButton string, // ShowWelcome displays the welcome dialog on macOS. func ShowWelcome() { displayDialog( - "CalyCode Setup", - "This will install the CalyCode CLI and configure your browser extension.\n\nThe process takes 1-2 minutes.\n\nClick OK to continue.", + msg.SetupTitle, + msg.WelcomeMessage, `"OK", "Cancel"`, "OK", "note", @@ -49,8 +56,8 @@ func ShowWelcome() { // ShowInstallingNode displays progress: installing Node.js. func ShowInstallingNode() { displayDialog( - "CalyCode Setup", - "Node.js 18+ is required but not found.\n\nInstalling Node.js now via Homebrew...\nThis may take a few minutes.\n\nClick OK to proceed.", + msg.SetupTitle, + msg.InstallingNodeDarwinMessage, `"OK"`, "OK", "note", @@ -60,8 +67,8 @@ func ShowInstallingNode() { // ShowInstallingCLI displays progress: installing CLI. func ShowInstallingCLI() { displayDialog( - "CalyCode Setup", - "Installing CalyCode CLI...\nThis may take a moment.", + msg.SetupTitle, + msg.InstallingCLIMessage, `"OK"`, "OK", "note", @@ -71,8 +78,8 @@ func ShowInstallingCLI() { // ShowConfiguringNativeHost displays progress: configuring browser extension. func ShowConfiguringNativeHost() { displayDialog( - "CalyCode Setup", - "Configuring browser extension connection...", + msg.SetupTitle, + msg.ConfiguringNativeHostMessage, `"OK"`, "OK", "note", @@ -103,20 +110,31 @@ func ShowWarning(title, message string) { // ShowSuccess displays the completion dialog with next steps. func ShowSuccess(cliVersion string) { - msg := "CalyCode CLI has been installed successfully!\n\n" + success := msg.SuccessIntro if cliVersion != "" { - msg += "Version: " + cliVersion + "\n\n" + success += fmt.Sprintf(msg.SuccessVersionFmt, cliVersion) } - msg += "Next steps:\n" - msg += " - Reload your Chrome extension\n" - msg += " - Open Terminal and run: caly-xano --help\n" - msg += "\nClick OK to finish." + success += msg.SuccessNextSteps + success += msg.SuccessReloadExtensionBullet + success += fmt.Sprintf(msg.SuccessRunHelpBulletFmt, msg.TerminalLabelDarwin) + success += msg.SuccessClickOK displayDialog( - "CalyCode Setup - Complete", - msg, + msg.SetupCompleteTitle, + success, `"OK"`, "OK", "note", ) + + if os.Getenv(autoCloseTerminalEnv) == "1" { + osascriptDetached(`delay 0.4 +tell application "Terminal" + if (count of windows) > 0 then + try + close front window + end try + end if +end tell`) + } } diff --git a/bootstrapper/ui/dialog_windows.go b/bootstrapper/ui/dialog_windows.go index e0f5764b..eab20b42 100644 --- a/bootstrapper/ui/dialog_windows.go +++ b/bootstrapper/ui/dialog_windows.go @@ -3,24 +3,25 @@ package ui import ( + "fmt" "syscall" "unsafe" ) var ( - user32 = syscall.NewLazyDLL("user32.dll") - messageBoxW = user32.NewProc("MessageBoxW") + user32 = syscall.NewLazyDLL("user32.dll") + messageBoxW = user32.NewProc("MessageBoxW") ) const ( - MB_OK = 0x00000000 - MB_OKCANCEL = 0x00000001 - MB_ICONINFORMATION = 0x00000040 - MB_ICONWARNING = 0x00000030 - MB_ICONERROR = 0x00000010 - MB_ICONQUESTION = 0x00000020 - MB_SETFOREGROUND = 0x00010000 - MB_TOPMOST = 0x00040000 + MB_OK = 0x00000000 + MB_OKCANCEL = 0x00000001 + MB_ICONINFORMATION = 0x00000040 + MB_ICONWARNING = 0x00000030 + MB_ICONERROR = 0x00000010 + MB_ICONQUESTION = 0x00000020 + MB_SETFOREGROUND = 0x00010000 + MB_TOPMOST = 0x00040000 IDOK = 1 IDCANCEL = 2 @@ -41,8 +42,8 @@ func messageBox(title, message string, flags uintptr) int { // ShowWelcome displays the welcome/setup-start dialog. func ShowWelcome() { messageBox( - "CalyCode Setup", - "This will install the CalyCode CLI and configure your browser extension.\n\nThe process takes 1-2 minutes.\n\nClick OK to continue.", + msg.SetupTitle, + msg.WelcomeMessage, MB_OK|MB_ICONINFORMATION|MB_SETFOREGROUND|MB_TOPMOST, ) } @@ -50,8 +51,8 @@ func ShowWelcome() { // ShowInstallingNode displays progress: installing Node.js. func ShowInstallingNode() { messageBox( - "CalyCode Setup", - "Node.js 18+ is required but not found.\n\nInstalling Node.js now via Winget...\nThis may take a few minutes.", + msg.SetupTitle, + msg.InstallingNodeWindowsMessage, MB_OK|MB_ICONINFORMATION|MB_SETFOREGROUND|MB_TOPMOST, ) } @@ -59,8 +60,8 @@ func ShowInstallingNode() { // ShowInstallingCLI displays progress: installing CLI. func ShowInstallingCLI() { messageBox( - "CalyCode Setup", - "Installing CalyCode CLI...\nThis may take a moment.", + msg.SetupTitle, + msg.InstallingCLIMessage, MB_OK|MB_ICONINFORMATION|MB_SETFOREGROUND|MB_TOPMOST, ) } @@ -68,8 +69,8 @@ func ShowInstallingCLI() { // ShowConfiguringNativeHost displays progress: configuring browser extension. func ShowConfiguringNativeHost() { messageBox( - "CalyCode Setup", - "Configuring browser extension connection...", + msg.SetupTitle, + msg.ConfiguringNativeHostMessage, MB_OK|MB_ICONINFORMATION|MB_SETFOREGROUND|MB_TOPMOST, ) } @@ -94,17 +95,17 @@ func ShowWarning(title, message string) { // ShowSuccess displays the completion dialog with next steps. func ShowSuccess(cliVersion string) { - msg := "CalyCode CLI has been installed successfully!\n\n" + success := msg.SuccessIntro if cliVersion != "" { - msg += "Version: " + cliVersion + "\n\n" + success += fmt.Sprintf(msg.SuccessVersionFmt, cliVersion) } - msg += "Next steps:\n" - msg += " - Reload your Chrome extension\n" - msg += " - Open a terminal and run: caly-xano --help\n\n" - msg += "Click OK to finish." + success += msg.SuccessNextSteps + success += msg.SuccessReloadExtensionBullet + success += fmt.Sprintf(msg.SuccessRunHelpBulletFmt, msg.TerminalLabelWindows) + success += msg.SuccessClickOK messageBox( - "CalyCode Setup - Complete", - msg, + msg.SetupCompleteTitle, + success, MB_OK|MB_ICONINFORMATION|MB_SETFOREGROUND|MB_TOPMOST, ) } diff --git a/bootstrapper/ui/messages.go b/bootstrapper/ui/messages.go new file mode 100644 index 00000000..5dabbeb9 --- /dev/null +++ b/bootstrapper/ui/messages.go @@ -0,0 +1,9 @@ +package ui + +import "github.com/calycode/xano-tools/bootstrapper/i18n" + +var msg = i18n.Get() + +func SetMessages(c i18n.Catalog) { + msg = c +} diff --git a/bootstrapper/ui/session_darwin.go b/bootstrapper/ui/session_darwin.go index be832fc6..7bc65888 100644 --- a/bootstrapper/ui/session_darwin.go +++ b/bootstrapper/ui/session_darwin.go @@ -19,7 +19,7 @@ func newInstallerSession() *InstallerSession { if step > total { step = total } - fmt.Printf("\r[CalyCode Installer] Step %d/%d - %s: %s", step, total, title, detail) + fmt.Printf("\r"+msg.SessionProgressFmt, step, total, title, detail) }, closeFn: func() { fmt.Print("\n") diff --git a/bootstrapper/ui/session_windows.go b/bootstrapper/ui/session_windows.go index 63e5972f..824c98de 100644 --- a/bootstrapper/ui/session_windows.go +++ b/bootstrapper/ui/session_windows.go @@ -36,7 +36,7 @@ func newInstallerSession() *InstallerSession { _ = os.Rename(tmp, statusPath) } - writeStatusFile(installerStatus{Title: "Preparing...", Detail: "Starting installer", Step: 0, Total: 3, Done: false}) + writeStatusFile(installerStatus{Title: msg.SessionPreparingTitle, Detail: msg.SessionPreparingDetail, Step: 0, Total: 3, Done: false}) statusEscaped := strings.ReplaceAll(statusPath, "'", "''") viewerScript := `Add-Type -AssemblyName System.Windows.Forms @@ -45,7 +45,7 @@ Add-Type -AssemblyName System.Drawing $statusPath = '` + statusEscaped + `' $form = New-Object System.Windows.Forms.Form -$form.Text = 'CalyCode Installer' +$form.Text = '` + strings.ReplaceAll(msg.InstallerWindowTitle, "'", "''") + `' $form.StartPosition = 'CenterScreen' $form.Size = New-Object System.Drawing.Size(560, 220) $form.FormBorderStyle = 'FixedDialog' @@ -57,14 +57,14 @@ $titleLabel = New-Object System.Windows.Forms.Label $titleLabel.Location = New-Object System.Drawing.Point(24, 24) $titleLabel.Size = New-Object System.Drawing.Size(510, 32) $titleLabel.Font = New-Object System.Drawing.Font('Segoe UI', 11, [System.Drawing.FontStyle]::Bold) -$titleLabel.Text = 'Preparing installer...' +$titleLabel.Text = '` + strings.ReplaceAll(msg.SessionPreparingTitle, "'", "''") + `' $form.Controls.Add($titleLabel) $detailLabel = New-Object System.Windows.Forms.Label $detailLabel.Location = New-Object System.Drawing.Point(24, 64) $detailLabel.Size = New-Object System.Drawing.Size(510, 44) $detailLabel.Font = New-Object System.Drawing.Font('Segoe UI', 9) -$detailLabel.Text = 'Please wait while setup starts.' +$detailLabel.Text = '` + strings.ReplaceAll(msg.SessionPreparingDetail, "'", "''") + `' $form.Controls.Add($detailLabel) $progress = New-Object System.Windows.Forms.ProgressBar @@ -80,7 +80,7 @@ $stepLabel = New-Object System.Windows.Forms.Label $stepLabel.Location = New-Object System.Drawing.Point(24, 152) $stepLabel.Size = New-Object System.Drawing.Size(510, 24) $stepLabel.Font = New-Object System.Drawing.Font('Segoe UI', 8) -$stepLabel.Text = 'Step 0 of 3' +$stepLabel.Text = [string]::Format('` + strings.ReplaceAll(msg.SessionStepLabelFmtPS, "'", "''") + `', 0, 3) $form.Controls.Add($stepLabel) $timer = New-Object System.Windows.Forms.Timer @@ -109,7 +109,7 @@ $timer.Add_Tick({ if ($pct -lt 1) { $pct = 1 } if ($pct -gt 100) { $pct = 100 } $progress.Value = $pct - $stepLabel.Text = 'Step ' + $step + ' of ' + $total + $stepLabel.Text = [string]::Format('` + strings.ReplaceAll(msg.SessionStepLabelFmtPS, "'", "''") + `', $step, $total) if ($status.done -eq $true) { $timer.Stop() @@ -157,7 +157,7 @@ $timer.Start() return } closed = true - writeStatusFile(installerStatus{Title: "Done", Detail: "Finalizing...", Step: 3, Total: 3, Done: true}) + writeStatusFile(installerStatus{Title: msg.SessionDoneTitle, Detail: msg.SessionDoneDetail, Step: 3, Total: 3, Done: true}) go func() { time.Sleep(2 * time.Second) _ = os.RemoveAll(tmpDir) diff --git a/docs/extension-installer-flow.md b/docs/extension-installer-flow.md index 7e4b9b48..1ecfe8dd 100644 --- a/docs/extension-installer-flow.md +++ b/docs/extension-installer-flow.md @@ -19,9 +19,9 @@ Use these release assets: - Windows x64: - `https://github.com/calycode/xano-tools/releases/latest/download/calycode-installer-windows-x64.exe` - macOS Intel (x64): - - `https://github.com/calycode/xano-tools/releases/latest/download/calycode-installer-darwin-x64` + - `https://github.com/calycode/xano-tools/releases/latest/download/calycode-installer-darwin-x64.zip` - macOS Apple Silicon (arm64): - - `https://github.com/calycode/xano-tools/releases/latest/download/calycode-installer-darwin-arm64` + - `https://github.com/calycode/xano-tools/releases/latest/download/calycode-installer-darwin-arm64.zip` Optional checksum: @@ -61,7 +61,7 @@ Notes: macOS note copy: -"If macOS blocks opening the file, right-click it and choose **Open**." +"Unzip the download, then if macOS blocks opening the installer, right-click it and choose **Open**." ## What the Installer Does diff --git a/packages/cli/scripts/README.md b/packages/cli/scripts/README.md index 248389ab..8e87c2dc 100644 --- a/packages/cli/scripts/README.md +++ b/packages/cli/scripts/README.md @@ -8,10 +8,10 @@ No terminal commands required. | Platform | Download | |---|---| | **Windows** | `https://github.com/calycode/xano-tools/releases/latest/download/calycode-installer-windows-x64.exe` | -| **macOS Intel** | `https://github.com/calycode/xano-tools/releases/latest/download/calycode-installer-darwin-x64` | -| **macOS Apple Silicon** | `https://github.com/calycode/xano-tools/releases/latest/download/calycode-installer-darwin-arm64` | +| **macOS Intel** | `https://github.com/calycode/xano-tools/releases/latest/download/calycode-installer-darwin-x64.zip` | +| **macOS Apple Silicon** | `https://github.com/calycode/xano-tools/releases/latest/download/calycode-installer-darwin-arm64.zip` | -> **macOS note:** After downloading, right-click the file and select **Open** to bypass Gatekeeper. +> **macOS note:** Unzip the download first, then right-click the extracted installer and select **Open** to bypass Gatekeeper. > Apple requires notarization for seamless opening — this will be added in a future release. > **Checksums** are available alongside each release in the `SHA256SUMS` file. From b6415c6fd03527cfa495ba73a1cf8a120eac9bb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mih=C3=A1ly=20T=C3=B3th?= Date: Thu, 21 May 2026 19:26:51 +0200 Subject: [PATCH 6/9] feat: default opencode channel to latest --- .../cli/src/commands/opencode/implementation.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/commands/opencode/implementation.ts b/packages/cli/src/commands/opencode/implementation.ts index 877897cc..32080e97 100644 --- a/packages/cli/src/commands/opencode/implementation.ts +++ b/packages/cli/src/commands/opencode/implementation.ts @@ -12,7 +12,7 @@ import { showNativeHostStatus as showNativeHostStatusImpl, } from './native-host/setup'; -const DEFAULT_OPENCODE_VERSION = '1.14.41'; +const DEFAULT_OPENCODE_VERSION = 'latest'; const OC_VERSION_REGEX = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/; const MAX_NATIVE_MESSAGE_SIZE = 1 * 1024 * 1024; const NATIVE_HOST_PORT_RANGE_START = 4096; @@ -77,9 +77,9 @@ function parseOcVersionFromArgv(argv: string[]): string | undefined { function resolveOcVersion(explicitVersion?: string): string { const explicit = normalizeOcVersion(explicitVersion); if (explicit) { - if (!OC_VERSION_REGEX.test(explicit)) { + if (explicit !== 'latest' && !OC_VERSION_REGEX.test(explicit)) { throw new Error( - `Invalid OpenCode version "${explicit}". Use semantic version format like "1.14.41".`, + `Invalid OpenCode version "${explicit}". Use "latest" or semantic version format like "1.14.41".`, ); } return explicit; @@ -87,9 +87,9 @@ function resolveOcVersion(explicitVersion?: string): string { const fromEnv = normalizeOcVersion(process.env.CALY_OC_OPENCODE_VERSION); if (fromEnv) { - if (!OC_VERSION_REGEX.test(fromEnv)) { + if (fromEnv !== 'latest' && !OC_VERSION_REGEX.test(fromEnv)) { throw new Error( - `Invalid CALY_OC_OPENCODE_VERSION "${fromEnv}". Use semantic version format like "1.14.41".`, + `Invalid CALY_OC_OPENCODE_VERSION "${fromEnv}". Use "latest" or semantic version format like "1.14.41".`, ); } return fromEnv; @@ -340,7 +340,8 @@ function buildOpencodeSpawnPlan( const globalOpencode = findGlobalOpencodeBinary(); if (globalOpencode) { const globalVersion = getOpencodeBinaryVersion(globalOpencode); - if (globalVersion === version) { + const isPinnedVersion = version !== 'latest'; + if (!isPinnedVersion || globalVersion === version) { return { command: globalOpencode, args: opencodeArgs, @@ -376,7 +377,7 @@ function buildOpencodeSpawnPlan( function warnIfUsingNonDefaultOcVersion(version: string): void { if (version !== DEFAULT_OPENCODE_VERSION) { log.warn( - `Using OpenCode ${version} (override). Our currently validated default is ${DEFAULT_OPENCODE_VERSION}.`, + `Using OpenCode ${version} (override). Default channel is ${DEFAULT_OPENCODE_VERSION}.`, ); } } @@ -1022,7 +1023,7 @@ async function startNativeHost() { logger.log(`Using OpenCode working directory: ${getOpencodeWorkingDir('server')}`); if (resolvedVersion !== DEFAULT_OPENCODE_VERSION) { logger.log( - `Using overridden OpenCode ${resolvedVersion}. Current validated default is ${DEFAULT_OPENCODE_VERSION}.`, + `Using overridden OpenCode ${resolvedVersion}. Default channel is ${DEFAULT_OPENCODE_VERSION}.`, ); } From c49af1f150651ab52016c309f87f7a45457b135b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mih=C3=A1ly=20T=C3=B3th?= Date: Thu, 21 May 2026 19:37:56 +0200 Subject: [PATCH 7/9] chore: add changeset --- .changeset/cool-oranges-cut.md | 5 +++++ .changeset/pretty-dodos-occur.md | 5 +++++ .changeset/solid-carpets-decide.md | 5 +++++ 3 files changed, 15 insertions(+) create mode 100644 .changeset/cool-oranges-cut.md create mode 100644 .changeset/pretty-dodos-occur.md create mode 100644 .changeset/solid-carpets-decide.md diff --git a/.changeset/cool-oranges-cut.md b/.changeset/cool-oranges-cut.md new file mode 100644 index 00000000..1a17cbed --- /dev/null +++ b/.changeset/cool-oranges-cut.md @@ -0,0 +1,5 @@ +--- +"@calycode/cli": minor +--- + +refactor: fixed several inconsistencies with the native host implementation diff --git a/.changeset/pretty-dodos-occur.md b/.changeset/pretty-dodos-occur.md new file mode 100644 index 00000000..8e6c20ba --- /dev/null +++ b/.changeset/pretty-dodos-occur.md @@ -0,0 +1,5 @@ +--- +"@calycode/cli": patch +--- + +refactor: adjusted extension id discovery diff --git a/.changeset/solid-carpets-decide.md b/.changeset/solid-carpets-decide.md new file mode 100644 index 00000000..b4115c43 --- /dev/null +++ b/.changeset/solid-carpets-decide.md @@ -0,0 +1,5 @@ +--- +"@calycode/cli": minor +--- + +feat: downloadable bootstrapper for the cli for windows and mac os From c9ba73eb4b162986c2115952230a5d8ff6f1d676 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mih=C3=A1ly=20T=C3=B3th?= Date: Thu, 21 May 2026 19:38:31 +0200 Subject: [PATCH 8/9] chore: stop tracking native host hardening plan --- .gitignore | 5 +- plans/native-host-security-hardening.md | 812 ------------------------ 2 files changed, 4 insertions(+), 813 deletions(-) delete mode 100644 plans/native-host-security-hardening.md diff --git a/.gitignore b/.gitignore index 9b6294e7..43c8deee 100644 --- a/.gitignore +++ b/.gitignore @@ -42,5 +42,8 @@ coverage/ # Native binary assets **/*/.sea-cache/ +# Local planning docs +plans/native-host-security-hardening.md + # Xano's ai supporting assets: -util-resources/xano-agentic-setups/ \ No newline at end of file +util-resources/xano-agentic-setups/ diff --git a/plans/native-host-security-hardening.md b/plans/native-host-security-hardening.md deleted file mode 100644 index 85738e2b..00000000 --- a/plans/native-host-security-hardening.md +++ /dev/null @@ -1,812 +0,0 @@ -# Native Host Security Hardening — Implementation Plan - -> **Date:** 2026-05-13 -> **Status:** Approved / Ready for Implementation -> **Scope:** `packages/cli/src/commands/opencode/`, `packages/cli/src/utils/host-constants.ts`, `packages/cli/scripts/` -> **Based on:** Security review & revalidation of native host feature - ---- - -## Summary of Decisions - -| # | Finding | Severity | Decision | -| --- | --------------------------------------- | -------- | ------------------------------------------------------------------------ | -| 1 | No max native message size → memory DoS | HIGH | Cap at **1MB**, reject oversized frames before buffering | -| 2 | Unvalidated CORS origins from extension | HIGH | Filter `*`, accept validated origins, cap at **10 origins** | -| 3 | Arbitrary process kill via stop/restart | HIGH | Restrict to **port range 32 slots**, add **PID tracking** | -| 4 | Windows `shell: true` on all spawns | MEDIUM | Scope `shell: true` to **npx only** | -| 5 | Extension discovery too permissive | MEDIUM | Bake **public key at build time**, remove **dev extension ID** from prod | -| 6 | Logger raw payload + no rotation | MEDIUM | **Disable by default**, gate behind `CALY_OC_NATIVE_HOST_DEBUG=1` | -| 7 | Uninstall only removes Chrome paths | LOW | Add cleanup for Brave, Edge, Chromium manifests + registry keys | -| 8 | Supply chain (mutable sources) | LOW | Keep runtime install for OpenCode; templates/skills in your control | -| 9 | Installer remote script chains | LOW | Accepted risk; pinning approach documented for future | - ---- - -## 1. Native Message Size Cap (HIGH) - -**File:** `packages/cli/src/commands/opencode/implementation.ts` -**Region:** `startNativeHost()` — stdin message parser (lines ~975–1008) - -### Current State - -```typescript -// Line 985: reads UInt32LE with no upper bound check -expectedLength = inputBuffer.readUInt32LE(0); -// ... accumulates data until inputBuffer.length >= expectedLength -inputBuffer = Buffer.concat([inputBuffer, chunk]); -``` - -A 32-bit unsigned integer can encode up to ~4GB. An attacker writing to stdin can exhaust memory. - -### Changes - -**a) Add constant:** - -```typescript -// Near other constants (after line 16) -const MAX_NATIVE_MESSAGE_SIZE = 1 * 1024 * 1024; // 1 MB -``` - -**b) Insert size check immediately after reading the length prefix (after line 985):** - -```typescript -expectedLength = inputBuffer.readUInt32LE(0); -inputBuffer = inputBuffer.subarray(4); - -// NEW: reject oversized messages before any buffering -if (expectedLength > MAX_NATIVE_MESSAGE_SIZE) { - logger.error('Message exceeds max size', { - size: expectedLength, - max: MAX_NATIVE_MESSAGE_SIZE, - }); - // Drain the oversized message from the buffer to recover protocol sync - // Reset parser state - expectedLength = null; - inputBuffer = Buffer.alloc(0); - break; // Stop processing this chunk, wait for next valid frame -} -``` - -**c) Guard `Buffer.concat` accumulation (line 980):** -Before accumulating, check that the new total won't exceed the max: - -```typescript -process.stdin.on('data', (chunk) => { - // Prevent unbounded accumulation even before length prefix is read - if (inputBuffer.length + chunk.length > MAX_NATIVE_MESSAGE_SIZE + 4) { - logger.error('Input buffer exceeds max size, resetting'); - inputBuffer = Buffer.alloc(0); - expectedLength = null; - return; - } - inputBuffer = Buffer.concat([inputBuffer, chunk]); - // ... rest of parser -}); -``` - -### Verification - -- Send a message with length prefix `0xFFFFFFFF` → host rejects, logs error, stays alive -- Send a valid message after an oversized one → host recovers correctly - ---- - -## 2. CORS Origin Validation (HIGH) - -**File:** `packages/cli/src/commands/opencode/implementation.ts` -**Region:** `handleMessage()` (lines ~895–937) and `getCorsArgs()` (lines ~537–539) - -### Current State - -```typescript -// Line 903: only checks Array.isArray(), no per-origin validation -const origins = Array.isArray(msg.origins) ? msg.origins : []; -// ... -// Line 539: origins passed directly to --cors args -return Array.from(origins).flatMap((origin) => ['--cors', origin]); -``` - -The extension can inject `"*"` or arbitrary origins. - -### Changes - -**a) Add origin validation function (new, near line 537):** - -```typescript -const MAX_CORS_ORIGINS = 10; - -// Regex for chrome-extension://<32-char-id> -const CHROME_EXTENSION_ORIGIN_REGEX = /^chrome-extension:\/\/[a-p]{32}$/; -// Reject wildcard patterns -const DANGEROUS_ORIGIN_PATTERNS = [ - /^\*$/, - /^https?:\/\/\*/, // https://* - /^chrome-extension:\/\/\*/, // chrome-extension://* -]; - -function isValidCorsOrigin(origin: string, knownExtensionIds: string[]): boolean { - // Reject dangerous wildcard patterns - for (const pattern of DANGEROUS_ORIGIN_PATTERNS) { - if (pattern.test(origin)) { - return false; - } - } - - // Allow chrome-extension:// origins only for known extension IDs - if (origin.startsWith('chrome-extension://')) { - return ( - CHROME_EXTENSION_ORIGIN_REGEX.test(origin) && - knownExtensionIds.some((id) => origin === `chrome-extension://${id}`) - ); - } - - // Allow https:// origins (arbitrary host, but not wildcards) - if (origin.startsWith('https://')) { - // Must have a non-empty host after https:// - const hostPart = origin.slice('https://'.length); - if (hostPart.length === 0 || hostPart === '*') { - return false; - } - return true; - } - - // Reject everything else (http://, file://, custom schemes, etc.) - return false; -} - -function filterAndValidateOrigins(rawOrigins: unknown, knownExtensionIds: string[]): string[] { - if (!Array.isArray(rawOrigins)) { - return []; - } - - const valid: string[] = []; - for (const origin of rawOrigins) { - if (typeof origin !== 'string') continue; - const trimmed = origin.trim(); - if (!trimmed) continue; - - if (!isValidCorsOrigin(trimmed, knownExtensionIds)) { - // Log rejection but don't fail — silently drop invalid origins - continue; - } - - // Deduplicate - if (!valid.includes(trimmed)) { - valid.push(trimmed); - } - - // Cap at MAX_CORS_ORIGINS - if (valid.length >= MAX_CORS_ORIGINS) break; - } - - return valid; -} -``` - -**b) Use validated origins in `handleMessage()` (around line 903):** - -```typescript -// Before (line 903): -const origins = Array.isArray(msg.origins) ? msg.origins : []; - -// After: -const knownIds = resolveAllowedExtensionIds().ids; -const origins = filterAndValidateOrigins(msg.origins, knownIds); -``` - -**c) Also validate `extraOrigins` in `getCorsArgs()` (line 537–539):** -The `getCorsArgs` function receives `extraOrigins` from two callers: - -- `launchOpencodeServer` (static server start) — these come from the static `getAllowedCorsOrigins()`, already safe -- Native host `startServer` (line 803) — these are the extension-provided origins - -Since validation now happens in `handleMessage()` before calling `startServer`, the `getCorsArgs` function itself doesn't need changes. But for defense-in-depth, consider adding validation there too. - -### Verification - -- `msg.origins = ["*"]` → rejected, not passed to `--cors` -- `msg.origins = ["https://*.evil.com"]` → rejected -- `msg.origins = ["https://my-custom-xano.example.com"]` → accepted -- `msg.origins = ["chrome-extension://hadkkdmpcmllbkfopioopcmeapjchpbm"]` → accepted (production ID) -- 15 valid origins → only first 10 accepted - ---- - -## 3. Port Kill Scope — Range + PID Tracking (HIGH) - -**Files:** - -- `packages/cli/src/commands/opencode/implementation.ts` — `startNativeHost()`, `killProcessOnPort()` -- New: `packages/cli/src/commands/opencode/native-host/port-manager.ts` (optional, or inline) - -### Current State - -- Extension can request `start`/`stop`/`restart` on ANY port 1–65535 -- `killProcessOnPort()` kills whatever PID it finds on that port — no ownership check -- No tracking of which PIDs the host spawned - -### Changes - -**a) Add constants:** - -```typescript -const NATIVE_HOST_PORT_RANGE_START = 4096; -const NATIVE_HOST_PORT_RANGE_SIZE = 32; // 4096–4127 -const NATIVE_HOST_PORT_RANGE_END = NATIVE_HOST_PORT_RANGE_START + NATIVE_HOST_PORT_RANGE_SIZE - 1; -``` - -**b) Add managed state to `startNativeHost()` scope (near line 724):** - -```typescript -let serverProc: ReturnType | null = null; - -// NEW: managed session tracking -interface ManagedSession { - port: number; - proc: ReturnType; - pid: number; - startedAt: number; -} -const managedSessions = new Map(); // port → session -const managedPids = new Set(); // for quick PID ownership check -``` - -**c) Add port validation function:** - -```typescript -function validateNativeHostPort(port: number): void { - validatePort(port); // existing 1–65535 check - if (port < NATIVE_HOST_PORT_RANGE_START || port > NATIVE_HOST_PORT_RANGE_END) { - throw new Error( - `Port ${port} is outside the allowed native host range ` + - `(${NATIVE_HOST_PORT_RANGE_START}–${NATIVE_HOST_PORT_RANGE_END})`, - ); - } -} -``` - -**d) Use `validateNativeHostPort` in message handlers (lines 902, 908, 913):** - -```typescript -// Before: -const port = msg.port ? parseInt(msg.port, 10) : 4096; - -// After: -const rawPort = msg.port ? parseInt(msg.port, 10) : 4096; -try { - validateNativeHostPort(rawPort); -} catch (e) { - logger.error('Invalid port in message', { port: rawPort, error: e }); - sendMessage({ status: 'error', message: `Invalid port: ${rawPort}` }); - return; -} -const port = rawPort; -``` - -**e) Track spawned sessions in `startServer()` (around line 808):** -When a server process is spawned: - -```typescript -serverProc = launched.proc; - -// NEW: track managed session -if (launched.proc.pid) { - const session: ManagedSession = { - port, - proc: launched.proc, - pid: launched.proc.pid, - startedAt: Date.now(), - }; - managedSessions.set(port, session); - managedPids.add(launched.proc.pid); -} - -serverProc.on('exit', (code) => { - logger.log(`Server process exited with code ${code}`); - sendMessage({ status: 'stopped', code }); - serverProc = null; - // NEW: clean up tracking - const session = managedSessions.get(port); - if (session && session.pid) { - managedPids.delete(session.pid); - } - managedSessions.delete(port); -}); -``` - -**f) Constrain `killProcessOnPort()` to only kill managed PIDs:** - -Option A — modify `killProcessOnPort` to accept a `Set` of allowed PIDs: - -```typescript -function killProcessOnPort( - port: number, - allowedPids: Set, - logger?: { log: ...; error: ... }, -): boolean { - // ... validatePort ... - // ... find PIDs on port ... - // NEW: only kill PIDs in allowedPids set - for (const pid of pidsToKill) { - if (!allowedPids.has(parseInt(pid, 10))) { - logInfo(`Skipping non-managed PID ${pid} on port ${port}`); - continue; - } - // kill it - } -} -``` - -Option B — simplify: don't scan ports at all for `stop`. Just kill the tracked session: - -```typescript -// In stop handler (line 912-929): -} else if (msg.type === 'stop') { - // NEW: only kill managed sessions - const session = managedSessions.get(port); - if (session) { - logger.log('Stopping managed session', { port, pid: session.pid }); - session.proc.kill(); - managedPids.delete(session.pid); - managedSessions.delete(port); - sendMessage({ status: 'stopped', message: `Server on port ${port} stopped` }); - } else { - sendMessage({ status: 'error', message: `No managed server on port ${port}` }); - } -} -``` - -Option B is **strongly preferred** — it eliminates the `killProcessOnPort` scanning entirely for stop operations. The `killProcessOnPort` function then only serves as an orphan cleanup during `restart` (where we lost the PID reference). For restart cleanup, pass `managedPids` as the allowed set. - -**g) Update `cleanup()` (line 940) to kill all managed sessions:** - -```typescript -const cleanup = (reason: string, port?: number) => { - logger.log(`Cleanup triggered: ${reason}`); - for (const [sessionPort, session] of managedSessions) { - logger.log(`Killing managed session on port ${sessionPort}`); - session.proc.kill(); - if (session.pid) managedPids.delete(session.pid); - } - managedSessions.clear(); - process.exit(0); -}; -``` - -### Verification - -- Extension sends `{ type: "stop", port: 3306 }` → rejected (outside range 4096–4127) -- Extension sends `{ type: "stop", port: 4100 }` but no session on 4100 → error, nothing killed -- Extension sends `{ type: "stop", port: 4100 }` with active session → kills only that PID -- MySQL on port 3306 unaffected regardless of extension messages - ---- - -## 4. Windows `shell: true` — Scope to npx Only (MEDIUM) - -**File:** `packages/cli/src/commands/opencode/implementation.ts` -**Region:** `getSpawnOptions()` (lines 274–288) - -### Current State - -```typescript -// Line 281-284: -const isWindows = process.platform === 'win32'; -return { - stdio, - shell: isWindows, // Applied to ALL spawns on Windows - cwd, - env: extraEnv ? { ...process.env, ...extraEnv } : process.env, -}; -``` - -### Why It's There - -The comment at line 279: "On Windows, npx is a batch file and requires shell: true." This is correct for `npx.cmd`. But it's applied unconditionally to all spawn sources (managed binary, global binary, npx). - -### Changes - -**a) Make `getSpawnOptions` take a `needsShell` parameter:** - -```typescript -function getSpawnOptions( - stdio: 'inherit' | 'pipe' | 'ignore' = 'inherit', - extraEnv?: Record, - cwd?: string, - needsShell: boolean = false, // NEW parameter -) { - return { - stdio, - shell: needsShell, // Only when explicitly requested - cwd, - env: extraEnv ? { ...process.env, ...extraEnv } : process.env, - }; -} -``` - -**b) All callers must pass `needsShell` based on the spawn plan source:** - -```typescript -// In proxyOpencode (line 571): -const needsShell = process.platform === 'win32' && launchPlan.source === 'npx'; -const proc = spawn(launchPlan.command, launchPlan.args, { - ...getSpawnOptions('inherit', { OPENCODE_CONFIG_DIR: configDir }, workingDir, needsShell), -}); - -// In launchOpencodeServer (line 258): -const needsShell = process.platform === 'win32' && plan.source === 'npx'; -const proc = spawn(plan.command, plan.args, { - ...getSpawnOptions(stdio, { OPENCODE_CONFIG_DIR: configDir }, workingDir, needsShell), - detached: detach, -}); - -// In native host startServer (line 802): -// Already indirect via launchOpencodeServer, so covered above. -``` - -**c) Alternative — compute `needsShell` once, tie it to the spawn plan:** -Add a `needsShell: boolean` field to `OpencodeSpawnPlan`: - -```typescript -interface OpencodeSpawnPlan { - command: string; - args: string[]; - source: 'env' | 'managed' | 'global' | 'npx'; - displayCommand: string; - needsShell: boolean; // NEW -} -``` - -Set it in `buildOpencodeSpawnPlan()`: - -```typescript -return { - command: explicitBin, // or managedBin, globalOpencode - args: opencodeArgs, - source: 'env', // or 'managed', 'global' - displayCommand: ..., - needsShell: false, // Direct binary, no shell needed -}; - -// For npx fallback: -return { - command: 'npx', - args: npxArgs, - source: 'npx', - displayCommand: ..., - needsShell: process.platform === 'win32', // Only npx needs shell on Windows -}; -``` - -Then callers simply use `plan.needsShell`. This is cleaner. - -### Verification - -- Windows: OpenCode started via managed binary → no `cmd.exe` wrapping, direct process -- Windows: OpenCode started via npx fallback → `cmd.exe` wrapping (required for `.cmd` batch file) -- Unix: All paths unchanged (were already `shell: false`) - ---- - -## 5. Extension Discovery — Public Key + Remove Dev ID (MEDIUM) - -**Files:** - -- `packages/cli/src/utils/host-constants.ts` -- `packages/cli/src/commands/opencode/native-host/discovery.ts` - -### Changes - -**a) Remove dev extension ID from `allowedExtensionIds` (host-constants.ts, lines 30–33):** - -```typescript -// Before: -allowedExtensionIds: [ - 'hadkkdmpcmllbkfopioopcmeapjchpbm', // Production (Chrome Web Store) - 'lnhipaeaeiegnlokhokfokndgadkohfe', // Development (unpacked) -], - -// After: -allowedExtensionIds: [ - 'hadkkdmpcmllbkfopioopcmeapjchpbm', // Production (Chrome Web Store) -], -``` - -**b) Add production public key to build-time defaults (discovery.ts, lines 9–20):** - -```typescript -const BUILD_OC_DEFAULTS = { - // ... existing ... - extPublicKeyB64: process.env.CALY_BUILD_OC_EXT_PUBLIC_KEY_B64, - // ... -}; -``` - -The actual public key value should be set via the build pipeline environment variable `CALY_BUILD_OC_EXT_PUBLIC_KEY_B64`. To extract it: the public key is embedded in the Chrome Web Store extension package (`.crx`) and can be extracted from `manifest.json`'s `key` field after publishing. - -**c) Set default discovery mode to `strict` (host-constants.ts, line 42):** - -```typescript -// Before: -mode: 'balanced', - -// After: -mode: 'strict', -``` - -With `strict` mode AND a baked-in public key, the discovery will: - -1. Find extensions matching the name -2. Require ≥ 2 trust signals (author, homepage, update_url, key match) -3. **Reject any extension whose ID doesn't match the ID derived from the baked-in public key** (line 463–465) - -This means: only the extension signed with your private key will be accepted, regardless of what other extensions claim about their name or author. - -**d) Option: conditional dev ID include via build flag:** -For local development, add a guard so the dev ID is only included in non-production builds: - -```typescript -// In host-constants.ts, replace the hardcoded array with a function: -function getAllowedExtensionIds(): string[] { - const prodIds = ['hadkkdmpcmllbkfopioopcmeapjchpbm']; - const includeDev = process.env.CALY_OC_INCLUDE_DEV_EXT === '1'; - if (includeDev) { - return [...prodIds, 'lnhipaeaeiegnlokhokfokndgadkohfe']; - } - return prodIds; -} - -export const HOST_APP_INFO: HostAppInfo = { - // ... - allowedExtensionIds: getAllowedExtensionIds(), - // ... -}; -``` - -### Verification - -- Run `caly-xano oc init` with public key set → only production extension ID accepted -- Load unpacked dev extension → not accepted (unless `CALY_OC_INCLUDE_DEV_EXT=1`) -- Malicious extension with same name but different key → rejected by strict mode + public key check - ---- - -## 6. Logger — Disable by Default (MEDIUM) - -**File:** `packages/cli/src/commands/opencode/implementation.ts` -**Region:** `NativeHostLogger` class (lines 639–708) - -### Changes - -**a) Add debug gate to `NativeHostLogger`:** - -```typescript -class NativeHostLogger { - private logPath: string; - private logDir: string; - private initialized: boolean = false; - private enabled: boolean; // NEW - - constructor() { - // NEW: only enable if debug env var is set - this.enabled = process.env.CALY_OC_NATIVE_HOST_DEBUG === '1'; - const homeDir = os.homedir(); - this.logDir = path.join(homeDir, '.calycode', 'logs'); - this.logPath = path.join(this.logDir, 'native-host.log'); - if (this.enabled) { - this.ensureLogDir(); - } - } -``` - -**b) Add early return in `log()` and `error()` when disabled:** - -```typescript -log(msg: string, data?: any) { - if (!this.enabled) return; // NEW - // ... existing logging logic -} - -error(msg: string, err?: any) { - if (!this.enabled) return; // NEW - // ... existing logging logic -} -``` - -**c) Add log rotation (when enabled):** -Check file size on each write; if exceeds a threshold (e.g., 5MB), rotate: - -```typescript -private readonly MAX_LOG_SIZE = 5 * 1024 * 1024; // 5 MB -private readonly MAX_LOG_FILES = 3; - -private rotateIfNeeded() { - try { - if (!fs.existsSync(this.logPath)) return; - const stat = fs.statSync(this.logPath); - if (stat.size < this.MAX_LOG_SIZE) return; - - for (let i = this.MAX_LOG_FILES - 1; i >= 1; i--) { - const oldPath = `${this.logPath}.${i}`; - const newPath = `${this.logPath}.${i + 1}`; - if (fs.existsSync(oldPath)) { - if (i === this.MAX_LOG_FILES - 1) { - fs.unlinkSync(newPath); // Remove oldest - } else { - fs.renameSync(oldPath, newPath); - } - } - } - fs.renameSync(this.logPath, `${this.logPath}.1`); - } catch { - // Rotation failure is non-critical - } -} -``` - -**d) Redact sensitive fields from logged messages:** - -```typescript -// In handleMessage, before logging: -const sanitizedMsg = { ...msg }; -// Redact potentially sensitive fields -for (const key of ['token', 'apiKey', 'secret', 'password', 'authorization']) { - if (sanitizedMsg[key]) sanitizedMsg[key] = '[REDACTED]'; -} -logger.log('Received message', sanitizedMsg); -``` - -### Verification - -- Normal operation: no log file created (`CALY_OC_NATIVE_HOST_DEBUG` not set) -- Debug mode: `CALY_OC_NATIVE_HOST_DEBUG=1 caly-xano oc native-host` → log file created -- Log file exceeds 5MB → rotated to `.1`, `.2`, `.3` - ---- - -## 7. Uninstall — Complete Browser Coverage (LOW) - -**Files:** - -- `packages/cli/scripts/installer/install.sh` -- `packages/cli/scripts/installer/install.ps1` - -### Current State - -Uninstall only removes Chrome's manifest/registry key. Brave, Edge, and Chromium leftovers remain. - -### Changes - -**a) install.sh — add removal for all browser manifests (lines 114–130):** - -```bash -# Remove all browser native messaging manifests -local browsers=( - "Google/Chrome" - "BraveSoftware/Brave-Browser" - "Microsoft Edge" - "Chromium" -) - -case "$(uname -s)" in - Darwin*) - for browser in "${browsers[@]}"; do - local manifest="$home_dir/Library/Application Support/$browser/NativeMessagingHosts/com.calycode.cli.json" - if [ -f "$manifest" ]; then - log "Removing $browser native messaging manifest..." - rm -f "$manifest" - fi - done - ;; - Linux*) - local linux_browsers=("google-chrome" "BraveSoftware/Brave-Browser" "microsoft-edge" "chromium") - for browser in "${linux_browsers[@]}"; do - local manifest="$home_dir/.config/$browser/NativeMessagingHosts/com.calycode.cli.json" - if [ -f "$manifest" ]; then - log "Removing $browser native messaging manifest..." - rm -f "$manifest" - fi - done - ;; -esac -``` - -**b) install.ps1 — add removal for all browser registry keys (lines 306–311):** - -```powershell -# Remove all browser registry keys -$browserKeys = @( - "HKCU:\Software\Google\Chrome\NativeMessagingHosts\$NativeHostId", - "HKCU:\Software\BraveSoftware\Brave-Browser\NativeMessagingHosts\$NativeHostId", - "HKCU:\Software\Microsoft\Edge\NativeMessagingHosts\$NativeHostId", - "HKCU:\Software\Chromium\NativeMessagingHosts\$NativeHostId" -) - -foreach ($regKey in $browserKeys) { - if (Test-Path $regKey) { - Write-Log "Removing registry key: $regKey" "INFO" - Remove-Item $regKey -Force - } -} -``` - -**c) Also remove wrapper directory (currently missing from uninstall):** - -```bash -# install.sh — also clean up wrapper dir and config -if [ -f "$home_dir/.calycode/bin/calycode-host.sh" ]; then - rm -f "$home_dir/.calycode/bin/calycode-host.sh" -fi -# Remove bin dir if empty -rmdir "$home_dir/.calycode/bin" 2>/dev/null || true -``` - -```powershell -# install.ps1 — also remove bin directory -$binDir = Join-Path $calyDir "bin" -if (Test-Path $binDir) { - Write-Log "Removing bin directory..." "INFO" - Remove-Item $binDir -Recurse -Force -} -``` - -### Verification - -- Install on macOS with Chrome + Brave → uninstall → both manifests removed -- Install on Windows with Chrome + Edge → uninstall → both registry keys removed -- Wrapper script and bin directory cleaned up - ---- - -## Implementation Order (Recommended) - -| Phase | Tasks | Dependencies | Risk Profile | -| ----------------------- | -------------------------------------------------------------------------- | ------------------ | ------------------ | -| **Phase 1 (Critical)** | 1. Message size cap, 2. CORS validation, 3. Port kill scope + PID tracking | None (independent) | Eliminates HIGHs | -| **Phase 2 (Hardening)** | 4. Shell:true scope, 5. Extension discovery | None (independent) | Eliminates MEDIUMs | -| **Phase 3 (Polish)** | 6. Logger gating, 7. Uninstall completeness | None (independent) | Eliminates LOWs | - -Each phase can be implemented and PR'd independently. No phase depends on another. - ---- - -## Files Changed Summary - -| File | Phase | Changes | -| ------------------------------------------------------------- | ------------- | --------------------------------------------------------------------------------------- | -| `packages/cli/src/commands/opencode/implementation.ts` | 1, 2, 3, 4, 6 | Message cap, CORS validation, port range + PID tracking, spawn shell scope, logger gate | -| `packages/cli/src/commands/opencode/native-host/discovery.ts` | 5 | Public key validation hardening (minor — already supports it) | -| `packages/cli/src/utils/host-constants.ts` | 5 | Remove dev ID, set default mode to strict, add conditional dev ID function | -| `packages/cli/scripts/installer/install.sh` | 7 | Complete browser manifest uninstall + wrapper dir cleanup | -| `packages/cli/scripts/installer/install.ps1` | 7 | Complete browser registry key uninstall + bin dir cleanup | - ---- - -## Testing Strategy - -### Unit Tests (new `*.test.ts` files in `packages/cli/src/commands/opencode/`) - -1. **Message size cap:** Send frames with length 0, 1, 1MB, 1MB+1, 4GB → verify rejection at boundary -2. **CORS validation:** Test `filterAndValidateOrigins` with `["*"]`, `["https://*.evil.com"]`, `["https://good.com"]`, `["chrome-extension://valid-id"]`, mixed arrays, 15+ origins -3. **Port validation:** Test `validateNativeHostPort` at boundaries 4095, 4096, 4127, 4128 -4. **Origin regex:** Test `isValidCorsOrigin` against all dangerous patterns - -### Integration Tests - -5. **PID tracking:** Start server on port 4100, send stop message for port 4100 → server killed. Send stop for 4101 → error (not managed). -6. **Logger gate:** Start native host without/with `CALY_OC_NATIVE_HOST_DEBUG=1` → verify log file absence/presence -7. **Shell scope:** On Windows, verify managed binary spawn uses `shell: false`, npx fallback uses `shell: true` - ---- - -## Rollback / Rollout Plan - -All changes are additive with backward-compatible defaults: - -- Port range default is 4096 — same as current default port -- `CALY_OC_NATIVE_HOST_DEBUG` not set → logger disabled (new behavior, safe default) -- Strict mode + public key → only effective if `CALY_BUILD_OC_EXT_PUBLIC_KEY_B64` is set at build time -- Dev ID removal → only affects `allowedExtensionIds` in production builds; dev builds set `CALY_OC_INCLUDE_DEV_EXT=1` - -Rollback: revert to previous version. No data migration, no config file format changes, no database changes. From ee96589444879faaed415bd715065f2df60d203a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mih=C3=A1ly=20T=C3=B3th?= Date: Thu, 21 May 2026 19:48:26 +0200 Subject: [PATCH 9/9] fix: address bootstrapper and native host review issues --- .github/workflows/build-bootstrapper.yml | 19 +- bootstrapper/Makefile | 9 +- bootstrapper/install/cli.go | 5 +- bootstrapper/install/node_darwin.go | 2 +- bootstrapper/install/node_unsupported.go | 2 +- bootstrapper/ui/dialog_darwin.go | 2 +- .../src/commands/opencode/implementation.ts | 166 ++++++++++-------- 7 files changed, 118 insertions(+), 87 deletions(-) diff --git a/.github/workflows/build-bootstrapper.yml b/.github/workflows/build-bootstrapper.yml index e00eee62..4c9fed52 100644 --- a/.github/workflows/build-bootstrapper.yml +++ b/.github/workflows/build-bootstrapper.yml @@ -21,10 +21,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff with: go-version: "1.21" @@ -68,19 +68,20 @@ jobs: if: github.event_name == 'release' env: GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.event.release.tag_name }} run: | - gh release upload "${{ github.event.release.tag_name }}" \ - bootstrapper/dist/calycode-installer-windows-x64.exe \ - bootstrapper/dist/calycode-installer-darwin-x64.zip \ - bootstrapper/dist/calycode-installer-darwin-arm64.zip \ - bootstrapper/dist/SHA256SUMS \ - --clobber + gh release upload "$RELEASE_TAG" \ + bootstrapper/dist/calycode-installer-windows-x64.exe \ + bootstrapper/dist/calycode-installer-darwin-x64.zip \ + bootstrapper/dist/calycode-installer-darwin-arm64.zip \ + bootstrapper/dist/SHA256SUMS \ + --clobber # --- Floating tag --- # Keeps `bootstrapper-latest` pointing at HEAD so # dev builds are always downloadable at a stable URL. - name: Update bootstrapper-latest tag - if: github.event_name != 'workflow_dispatch' + if: github.event_name == 'push' run: | git tag -f bootstrapper-latest git push -f origin bootstrapper-latest diff --git a/bootstrapper/Makefile b/bootstrapper/Makefile index 26d1d55b..231b11a7 100644 --- a/bootstrapper/Makefile +++ b/bootstrapper/Makefile @@ -18,14 +18,17 @@ MODULE := github.com/calycode/xano-tools/bootstrapper all: windows darwin-amd64 darwin-arm64 -windows: +$(OUT_DIR): + mkdir -p $(OUT_DIR) + +windows: $(OUT_DIR) @echo "Building Windows x64..." GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build \ -ldflags "-s -w -H=windowsgui" \ -o $(OUT_DIR)/$(BINARY)-windows-x64.exe \ $(MODULE) -darwin-amd64: +darwin-amd64: $(OUT_DIR) @echo "Building macOS Intel x64..." GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build \ -ldflags "-s -w" \ @@ -33,7 +36,7 @@ darwin-amd64: $(MODULE) cd $(OUT_DIR) && zip -9 $(BINARY)-darwin-x64.zip $(BINARY)-darwin-x64 -darwin-arm64: +darwin-arm64: $(OUT_DIR) @echo "Building macOS Apple Silicon..." GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build \ -ldflags "-s -w" \ diff --git a/bootstrapper/install/cli.go b/bootstrapper/install/cli.go index 382eff25..07b68c61 100644 --- a/bootstrapper/install/cli.go +++ b/bootstrapper/install/cli.go @@ -21,7 +21,10 @@ func InstallCLI(version string) (output string, errOut string) { setupSilent(cmd) cmd.Stderr = &stderr if err := cmd.Run(); err != nil { - return "", stderr.String() + if stderr.Len() > 0 { + return "", stderr.String() + } + return "", err.Error() } // Verify installation diff --git a/bootstrapper/install/node_darwin.go b/bootstrapper/install/node_darwin.go index b10e7174..1c1ff195 100644 --- a/bootstrapper/install/node_darwin.go +++ b/bootstrapper/install/node_darwin.go @@ -38,7 +38,7 @@ func InstallNode() (errOut string) { func installHomebrew() string { var stderr bytes.Buffer cmd := exec.Command("/bin/bash", "-c", - `"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"`, + `$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)`, ) setupSilent(cmd) cmd.Stderr = &stderr diff --git a/bootstrapper/install/node_unsupported.go b/bootstrapper/install/node_unsupported.go index 32f72058..95eedd44 100644 --- a/bootstrapper/install/node_unsupported.go +++ b/bootstrapper/install/node_unsupported.go @@ -4,5 +4,5 @@ package install // InstallNode is a no-op on unsupported platforms. func InstallNode() string { - return "Unsupported platform. Please install Node.js 18+ manually from https://nodejs.org" + return msg.NodeInstallManual } diff --git a/bootstrapper/ui/dialog_darwin.go b/bootstrapper/ui/dialog_darwin.go index c83cd793..16680325 100644 --- a/bootstrapper/ui/dialog_darwin.go +++ b/bootstrapper/ui/dialog_darwin.go @@ -47,7 +47,7 @@ func ShowWelcome() { displayDialog( msg.SetupTitle, msg.WelcomeMessage, - `"OK", "Cancel"`, + `"OK"`, "OK", "note", ) diff --git a/packages/cli/src/commands/opencode/implementation.ts b/packages/cli/src/commands/opencode/implementation.ts index 32080e97..431309de 100644 --- a/packages/cli/src/commands/opencode/implementation.ts +++ b/packages/cli/src/commands/opencode/implementation.ts @@ -479,58 +479,74 @@ function validateNativeHostPort(port: number): void { function killProcessOnPort(port: number, allowedPids: Set, logger?: { log: (msg: string, data?: any) => void; error: (msg: string, err?: any) => void }): boolean { const logInfo = logger?.log ?? ((msg: string) => { /* silent */ }); const logError = logger?.error ?? ((msg: string) => { /* silent */ }); - + + const toAllowedPidList = (rawPids: Array): number[] => { + const parsed = rawPids + .map((pid) => (typeof pid === 'number' ? pid : Number.parseInt(String(pid).trim(), 10))) + .filter((pid) => Number.isInteger(pid) && pid > 0); + return Array.from(new Set(parsed.filter((pid) => allowedPids.has(pid)))); + }; + + const parseWindowsNetstatPids = (output: string): string[] => { + const lines = output.split('\n'); + const pids: string[] = []; + for (const line of lines) { + if (!line.includes('LISTENING') || !line.includes(`:${port}`)) { + continue; + } + const parts = line.trim().split(/\s+/); + const pid = parts[parts.length - 1]; + if (pid && /^\d+$/.test(pid) && pid !== '0') { + pids.push(pid); + } + } + return pids; + }; + + const parsePidLines = (output: string): string[] => + output + .split('\n') + .map((line) => line.trim()) + .filter((line) => /^\d+$/.test(line)); + try { validatePort(port); - + if (os.platform() === 'win32') { - // Windows: Use netstat to find the PID and taskkill to terminate try { - const netstatOutput = execSync(`netstat -ano | findstr :${port}`, { + const netstatOutput = execSync(`netstat -ano | findstr :${port}`, { encoding: 'utf8', timeout: 5000, windowsHide: true, }); - - // Parse output to find LISTENING processes - const lines = netstatOutput.split('\n'); - const pidsToKill = new Set(); - - for (const line of lines) { - // Look for lines with LISTENING state on our port - // Format: TCP 0.0.0.0:4096 0.0.0.0:0 LISTENING 12345 - if (line.includes('LISTENING') && line.includes(`:${port}`)) { - const parts = line.trim().split(/\s+/); - const pid = parts[parts.length - 1]; - if (pid && /^\d+$/.test(pid) && pid !== '0') { - pidsToKill.add(pid); - } - } - } - - if (pidsToKill.size === 0) { - logInfo(`No listening process found on port ${port}`); + + const allowed = toAllowedPidList(parseWindowsNetstatPids(netstatOutput)); + if (allowed.length === 0) { + logInfo(`No managed process found on port ${port}`); return false; } - - // Kill each process found - for (const pid of pidsToKill) { + + let killedAny = false; + for (const pid of allowed) { try { - logInfo(`Killing process ${pid} on port ${port}`); - execSync(`taskkill /F /PID ${pid}`, { + execSync(`taskkill /F /PID ${pid}`, { timeout: 5000, windowsHide: true, }); - logInfo(`Successfully killed process ${pid}`); + logInfo(`Killed managed process ${pid} on port ${port}`); + killedAny = true; } catch (killErr) { - // Process might have already exited - logError(`Failed to kill process ${pid}`, killErr); + logError(`Failed to kill managed process ${pid}`, killErr); } } - + + if (!killedAny) { + logInfo(`No listening process found on port ${port}`); + return false; + } + return true; - } catch (e: any) { - // netstat might return non-zero if no process found + } catch (e: any) { if (e.status === 1 || e.message?.includes('not found')) { logInfo(`No process found on port ${port}`); return false; @@ -538,49 +554,57 @@ function killProcessOnPort(port: number, allowedPids: Set, logger?: { lo throw e; } } else { - // Unix-like systems: Use fuser or lsof + const candidates = new Set(); + try { - // Try fuser first (more reliable for killing) - execSync(`fuser -k ${port}/tcp 2>/dev/null || true`, { + const fuserOutput = execSync(`fuser ${port}/tcp 2>/dev/null || true`, { + encoding: 'utf8', timeout: 5000, }); - logInfo(`Killed process on port ${port} using fuser`); - return true; - } catch (fuserErr) { - // fuser not available, try lsof + kill - try { - const lsofOutput = execSync(`lsof -ti tcp:${port}`, { - encoding: 'utf8', - timeout: 5000, - }); - - const pids = lsofOutput.trim().split('\n').filter(Boolean); - if (pids.length === 0) { - logInfo(`No process found on port ${port}`); - return false; - } - - for (const pid of pids) { - if (pid && /^\d+$/.test(pid)) { - try { - execSync(`kill -9 ${pid}`, { timeout: 5000 }); - logInfo(`Killed process ${pid} on port ${port}`); - } catch (killErr) { - logError(`Failed to kill process ${pid}`, killErr); - } - } - } - - return true; - } catch (lsofErr: any) { - // lsof returns non-zero if no process found - if (lsofErr.status === 1) { - logInfo(`No process found on port ${port}`); - return false; - } + for (const pid of parsePidLines(fuserOutput)) { + candidates.add(pid); + } + } catch { + // Best effort; lsof fallback below. + } + + try { + const lsofOutput = execSync(`lsof -ti tcp:${port}`, { + encoding: 'utf8', + timeout: 5000, + }); + for (const pid of parsePidLines(lsofOutput)) { + candidates.add(pid); + } + } catch (lsofErr: any) { + if (lsofErr.status !== 1) { throw lsofErr; } } + + const allowed = toAllowedPidList(Array.from(candidates)); + if (allowed.length === 0) { + logInfo(`No managed process found on port ${port}`); + return false; + } + + let killedAny = false; + for (const pid of allowed) { + try { + execSync(`kill -9 ${pid}`, { timeout: 5000 }); + logInfo(`Killed managed process ${pid} on port ${port}`); + killedAny = true; + } catch (killErr) { + logError(`Failed to kill managed process ${pid}`, killErr); + } + } + + if (!killedAny) { + logInfo(`No managed process killed on port ${port}`); + return false; + } + + return true; } } catch (error) { logError(`Error killing process on port ${port}`, error);