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 diff --git a/.github/workflows/build-bootstrapper.yml b/.github/workflows/build-bootstrapper.yml new file mode 100644 index 00000000..4c9fed52 --- /dev/null +++ b/.github/workflows/build-bootstrapper.yml @@ -0,0 +1,87 @@ +# .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@34e114876b0b11c390a56381ad16ebd13914f8d5 + + - name: Setup Go + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff + 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 \ + . + cd dist + zip -9 calycode-installer-darwin-x64.zip 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 \ + . + cd dist + zip -9 calycode-installer-darwin-arm64.zip 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 }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + 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 == 'push' + run: | + git tag -f bootstrapper-latest + git push -f origin bootstrapper-latest 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 }} 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/bootstrapper/Makefile b/bootstrapper/Makefile new file mode 100644 index 00000000..231b11a7 --- /dev/null +++ b/bootstrapper/Makefile @@ -0,0 +1,48 @@ +# 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 + +$(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: $(OUT_DIR) + @echo "Building macOS Intel x64..." + GOOS=darwin GOARCH=amd64 CGO_ENABLED=0 go build \ + -ldflags "-s -w" \ + -o $(OUT_DIR)/$(BINARY)-darwin-x64 \ + $(MODULE) + cd $(OUT_DIR) && zip -9 $(BINARY)-darwin-x64.zip $(BINARY)-darwin-x64 + +darwin-arm64: $(OUT_DIR) + @echo "Building macOS Apple Silicon..." + GOOS=darwin GOARCH=arm64 CGO_ENABLED=0 go build \ + -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/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/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 new file mode 100644 index 00000000..07b68c61 --- /dev/null +++ b/bootstrapper/install/cli.go @@ -0,0 +1,58 @@ +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 { + if stderr.Len() > 0 { + return "", stderr.String() + } + return "", err.Error() + } + + // 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 msg.CalyXanoNotFound + } + + 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/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.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..1c1ff195 --- /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 msg.NodeInstalledNotInPath + } + 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 msg.HomebrewInstallFailed + } + + // 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 msg.HomebrewInstalledNotInPath + } + return "" +} diff --git a/bootstrapper/install/node_unsupported.go b/bootstrapper/install/node_unsupported.go new file mode 100644 index 00000000..95eedd44 --- /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 msg.NodeInstallManual +} diff --git a/bootstrapper/install/node_windows.go b/bootstrapper/install/node_windows.go new file mode 100644 index 00000000..d0766dc0 --- /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 msg.NodeInstallManual +} + +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..f64645cb --- /dev/null +++ b/bootstrapper/main.go @@ -0,0 +1,67 @@ +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() + + fail := func(title, message string) { + session.Close() + ui.ShowError(title, message) + os.Exit(1) + } + + // --- STEP 1: Ensure Node.js >= 18 --- + session.Update(1, 3, t.StepCheckingTitle, t.StepCheckingDetail) + if !install.NodeOK() { + session.Update(1, 3, t.StepInstallDepsTitle, t.StepInstallDepsDetail) + if errOut := install.InstallNode(); errOut != "" { + fail( + t.NodeRequiredTitle, + fmt.Sprintf(t.NodeRequiredMessageFmt, errOut), + ) + } + if !install.NodeOK() { + fail( + t.NodeInstallFailedTitle, + t.NodeInstallFailedMessage, + ) + } + } + + // --- STEP 2: Install @calycode/cli --- + session.Update(2, 3, t.StepInstallCLITitle, t.StepInstallCLIDetail) + cliVersion, errOut := install.InstallCLI("latest") + if errOut != "" { + fail( + t.InstallFailedTitle, + fmt.Sprintf(t.InstallFailedMessageFmt, errOut), + ) + } + + // --- STEP 3: Configure native messaging host --- + session.Update(3, 3, t.StepConfigureTitle, t.StepConfigureDetail) + if errOut := install.InitNativeHost(); errOut != "" { + session.Close() + ui.ShowWarning( + t.ExtSetupIncompleteTitle, + fmt.Sprintf(t.ExtSetupIncompleteMessageFmt, errOut), + ) + } + + // --- 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..16680325 --- /dev/null +++ b/bootstrapper/ui/dialog_darwin.go @@ -0,0 +1,140 @@ +//go:build darwin + +package ui + +import ( + "fmt" + "os" + "os/exec" + "strings" +) + +const autoCloseTerminalEnv = "CALYCODE_INSTALLER_AUTO_CLOSE_TERMINAL" + +func osascript(script string) { + cmd := exec.Command("osascript", "-e", script) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + 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, "\"", "\\\"") + 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( + msg.SetupTitle, + msg.WelcomeMessage, + `"OK"`, + "OK", + "note", + ) +} + +// ShowInstallingNode displays progress: installing Node.js. +func ShowInstallingNode() { + displayDialog( + msg.SetupTitle, + msg.InstallingNodeDarwinMessage, + `"OK"`, + "OK", + "note", + ) +} + +// ShowInstallingCLI displays progress: installing CLI. +func ShowInstallingCLI() { + displayDialog( + msg.SetupTitle, + msg.InstallingCLIMessage, + `"OK"`, + "OK", + "note", + ) +} + +// ShowConfiguringNativeHost displays progress: configuring browser extension. +func ShowConfiguringNativeHost() { + displayDialog( + msg.SetupTitle, + msg.ConfiguringNativeHostMessage, + `"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) { + success := msg.SuccessIntro + if cliVersion != "" { + success += fmt.Sprintf(msg.SuccessVersionFmt, cliVersion) + } + success += msg.SuccessNextSteps + success += msg.SuccessReloadExtensionBullet + success += fmt.Sprintf(msg.SuccessRunHelpBulletFmt, msg.TerminalLabelDarwin) + success += msg.SuccessClickOK + + displayDialog( + 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_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..eab20b42 --- /dev/null +++ b/bootstrapper/ui/dialog_windows.go @@ -0,0 +1,111 @@ +//go:build windows + +package ui + +import ( + "fmt" + "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( + msg.SetupTitle, + msg.WelcomeMessage, + MB_OK|MB_ICONINFORMATION|MB_SETFOREGROUND|MB_TOPMOST, + ) +} + +// ShowInstallingNode displays progress: installing Node.js. +func ShowInstallingNode() { + messageBox( + msg.SetupTitle, + msg.InstallingNodeWindowsMessage, + MB_OK|MB_ICONINFORMATION|MB_SETFOREGROUND|MB_TOPMOST, + ) +} + +// ShowInstallingCLI displays progress: installing CLI. +func ShowInstallingCLI() { + messageBox( + msg.SetupTitle, + msg.InstallingCLIMessage, + MB_OK|MB_ICONINFORMATION|MB_SETFOREGROUND|MB_TOPMOST, + ) +} + +// ShowConfiguringNativeHost displays progress: configuring browser extension. +func ShowConfiguringNativeHost() { + messageBox( + msg.SetupTitle, + msg.ConfiguringNativeHostMessage, + 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) { + success := msg.SuccessIntro + if cliVersion != "" { + success += fmt.Sprintf(msg.SuccessVersionFmt, cliVersion) + } + success += msg.SuccessNextSteps + success += msg.SuccessReloadExtensionBullet + success += fmt.Sprintf(msg.SuccessRunHelpBulletFmt, msg.TerminalLabelWindows) + success += msg.SuccessClickOK + messageBox( + 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.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..7bc65888 --- /dev/null +++ b/bootstrapper/ui/session_darwin.go @@ -0,0 +1,28 @@ +//go:build darwin + +package ui + +import "fmt" + +func newInstallerSession() *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"+msg.SessionProgressFmt, step, total, title, detail) + }, + closeFn: func() { + fmt.Print("\n") + }, + } +} 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..824c98de --- /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: msg.SessionPreparingTitle, Detail: msg.SessionPreparingDetail, 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 = '` + strings.ReplaceAll(msg.InstallerWindowTitle, "'", "''") + `' +$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 = '` + 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 = '` + strings.ReplaceAll(msg.SessionPreparingDetail, "'", "''") + `' +$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 = [string]::Format('` + strings.ReplaceAll(msg.SessionStepLabelFmtPS, "'", "''") + `', 0, 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 = [string]::Format('` + strings.ReplaceAll(msg.SessionStepLabelFmtPS, "'", "''") + `', $step, $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: 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 new file mode 100644 index 00000000..1ecfe8dd --- /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.zip` +- macOS Apple Silicon (arm64): + - `https://github.com/calycode/xano-tools/releases/latest/download/calycode-installer-darwin-arm64.zip` + +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: + +"Unzip the download, then if macOS blocks opening the installer, 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 dcbebea7..8e87c2dc 100644 --- a/packages/cli/scripts/README.md +++ b/packages/cli/scripts/README.md @@ -1,193 +1,117 @@ -# 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.zip` | +| **macOS Apple Silicon** | `https://github.com/calycode/xano-tools/releases/latest/download/calycode-installer-darwin-arm64.zip` | -### One-line Installation Commands (Recommended for Extension) +> **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. -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 ``` - -### Environment Variables (Unix) - -`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` - -Example: - -```bash -curl -fsSL https://links.calycode.com/install-cli-unix | CALYCODE_VERSION=1.2.3 CALYCODE_SKIP_NATIVE_HOST=1 bash -s -- +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 ``` -## 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 +Architecture detection on macOS: check `navigator.userAgentData` or serve a universal page. -The production installers are hosted at: +For a full extension integration guide, see `docs/extension-installer-flow.md`. -- 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. - -## 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 +## For Developers -- **Windows:** Ensure Winget or Chocolatey is available -- **macOS:** Ensure Homebrew is installed or can be installed -- **Linux:** Supported distributions: Debian/Ubuntu, RHEL/Fedora, Arch +The bootstrapper source is at `bootstrapper/`. It's a Go application that cross-compiles +to Windows and macOS from any platform. -### Native host not connecting +### Build locally +```bash +cd bootstrapper +make all # Build all targets +make windows # Windows only +make darwin-arm64 # macOS ARM only +``` -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` +### 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 -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 9c662a1f..431309de 100644 --- a/packages/cli/src/commands/opencode/implementation.ts +++ b/packages/cli/src/commands/opencode/implementation.ts @@ -12,8 +12,14 @@ 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; +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; @@ -21,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 { @@ -28,6 +41,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 { @@ -56,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; @@ -66,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; @@ -94,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); @@ -131,9 +224,27 @@ 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)) { + pruneManagedOpencodeVersions(5); return managedBinPath; } @@ -162,13 +273,24 @@ function ensureManagedOpencodeInstalled(version: string): string { } } + pruneManagedOpencodeVersions(5); + return managedBinPath; } 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) { @@ -180,6 +302,7 @@ function buildOpencodeSpawnPlan( args: opencodeArgs, source: 'env', displayCommand: `${explicitBin} ${opencodeArgs.join(' ')}`.trim(), + needsShell: false, }; } @@ -191,6 +314,7 @@ function buildOpencodeSpawnPlan( args: opencodeArgs, source: 'managed', displayCommand: `${managedBin} ${opencodeArgs.join(' ')}`.trim(), + needsShell: false, }; } @@ -202,20 +326,42 @@ function buildOpencodeSpawnPlan( args: opencodeArgs, source: 'managed', 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(), - }; + const allowGlobalFallback = options?.allowGlobalFallback !== false; + if (allowGlobalFallback) { + const globalOpencode = findGlobalOpencodeBinary(); + if (globalOpencode) { + const globalVersion = getOpencodeBinaryVersion(globalOpencode); + const isPinnedVersion = version !== 'latest'; + if (!isPinnedVersion || 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]; @@ -224,13 +370,14 @@ function buildOpencodeSpawnPlan( args: npxArgs, source: 'npx', displayCommand: `npx ${npxArgs.join(' ')}`, + needsShell: process.platform === 'win32', }; } 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}.`, ); } } @@ -241,6 +388,9 @@ function launchOpencodeServer({ stdio = 'inherit', detach = false, ocVersion, + allowGlobalFallback, + onManagedFail, + onGlobalVersionMismatch, }: LaunchOpencodeServerOptions) { validatePort(port); @@ -251,12 +401,16 @@ 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'); 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 +422,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,61 +459,94 @@ 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 */ }); - + + 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; @@ -367,49 +554,57 @@ function killProcessOnPort(port: number, logger?: { log: (msg: string, data?: an 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); @@ -534,6 +729,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 +805,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 +876,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 +895,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 +910,7 @@ class NativeHostLogger { } log(msg: string, data?: any) { + if (!this.enabled) return; try { const timestamp = new Date().toISOString(); let content = `[${timestamp}] ${msg}`; @@ -682,12 +920,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 +935,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 +959,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 +1002,6 @@ async function startNativeHost() { extraOrigins: string[] = [], requestedOcVersion?: string, ) => { - // Validate port to prevent injection via invalid values try { validatePort(port); } catch (e) { @@ -763,12 +1013,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)); } @@ -786,16 +1042,12 @@ 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')}`); 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}.`, ); } @@ -804,8 +1056,22 @@ 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); serverProc.on('error', (err) => { logger.error('Failed to spawn server process', err); @@ -815,6 +1081,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 +1132,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 +1166,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 +1222,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 +1259,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 +1273,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', }, };