diff --git a/.github/actions/release-tool/npm/action.yml b/.github/actions/release-tool/npm/action.yml index 1b6b56f07e..bc2c2d06c2 100644 --- a/.github/actions/release-tool/npm/action.yml +++ b/.github/actions/release-tool/npm/action.yml @@ -11,7 +11,7 @@ inputs: required: false default: 'latest' tool: - description: 'Tool directory name under tools/ (e.g. npx-thunderid)' + description: 'Tool directory name under tools/ (e.g. npx)' required: true bump-type: description: 'Semver bump type: major, minor, or patch' @@ -64,7 +64,7 @@ runs: - name: ๐Ÿ”จ Build shell: bash - run: pnpm build:tools + run: pnpm --filter "./tools/${{ inputs.tool }}" build - name: ๐Ÿ“ค Commit and Tag Release shell: bash diff --git a/.github/workflows/release-tools.yml b/.github/workflows/release-tools.yml index 10b06ae129..d596ff622a 100644 --- a/.github/workflows/release-tools.yml +++ b/.github/workflows/release-tools.yml @@ -15,8 +15,8 @@ on: - major default: patch - tool_npx_thunderid: - description: 'thunderid (npx-thunderid)' + npx: + description: 'Release npx tool' required: false type: boolean default: false @@ -35,7 +35,7 @@ env: jobs: release-npx-thunderid: name: โšก Release thunderid (npx-thunderid) - if: ${{ github.event.inputs.tool_npx_thunderid == 'true' }} + if: ${{ github.event.inputs.npx == 'true' }} runs-on: ubuntu-latest steps: - name: ๐Ÿ“ฅ Checkout Code @@ -46,7 +46,7 @@ jobs: - name: ๐Ÿš€ Release uses: ./.github/actions/release-tool/npm with: - tool: npx-thunderid + tool: npx bump-type: ${{ github.event.inputs.bump_type }} git-user-name: ${{ env.RELEASE_GIT_USER_NAME }} git-user-email: ${{ env.RELEASE_GIT_USER_EMAIL }} diff --git a/Makefile b/Makefile index 80918dd985..be0e6ee257 100644 --- a/Makefile +++ b/Makefile @@ -132,6 +132,15 @@ test_sdks: lint_sdks: ./build.sh lint_sdks +build_tools: + ./build.sh build_tools + +test_tools: + ./build.sh test_tools + +lint_tools: + ./build.sh lint_tools + lint_docs: @command -v vale >/dev/null 2>&1 || (echo "vale is not installed. See https://vale.sh/docs/vale-cli/installation/ for installation instructions." && exit 1) vale docs/ @@ -214,6 +223,9 @@ help: @echo " build_sdks - Build all SDK packages." @echo " test_sdks - Run tests for all SDK packages." @echo " lint_sdks - Run linting on all SDK packages." + @echo " build_tools - Build all tool binaries (CLI + i18n-extractor + npm tools)." + @echo " test_tools - Run tests for all tools." + @echo " lint_tools - Run linting on all tools." @echo " lint - Run linting on backend, frontend, and SDK code." @echo " lint_backend - Run golangci-lint on the backend code." @echo " lint_frontend - Run ESLint on the frontend code." diff --git a/build.ps1 b/build.ps1 index 38e4356f8c..fbbe4796ac 100644 --- a/build.ps1 +++ b/build.ps1 @@ -530,6 +530,105 @@ function Lint-SDKs { Write-Host "================================================================" } +function Build-CLI { + Write-Host "Building CLI tool..." + & bash "$PSScriptRoot/tools/cli/scripts/build.sh" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +} + +function Test-CLI { + Write-Host "Running CLI tool tests..." + Push-Location "$PSScriptRoot/tools/cli" + try { + & go test -v -race -count=1 ./... + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } finally { + Pop-Location + } +} + +function Build-I18n-Extractor { + $toolBin = Join-Path $PSScriptRoot "backend/bin/tools" + New-Item -ItemType Directory -Force -Path $toolBin | Out-Null + Write-Host "Building i18n-extractor..." + Push-Location "$PSScriptRoot/tools/i18n-extractor" + try { + & go build -o "$toolBin/i18n-extractor.exe" . + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } finally { + Pop-Location + } +} + +function Test-I18n-Extractor { + Write-Host "Running i18n-extractor tests..." + Push-Location "$PSScriptRoot/tools/i18n-extractor" + try { + & go test -v . + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } finally { + Pop-Location + } +} + +function Lint-CLI { + $golangciLint = Join-Path $PSScriptRoot "backend/bin/tools/golangci-lint.exe" + Write-Host "Linting CLI tool..." + Push-Location "$PSScriptRoot/tools/cli" + try { + & $golangciLint run ./... + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } finally { + Pop-Location + } +} + +function Lint-I18n-Extractor { + $golangciLint = Join-Path $PSScriptRoot "backend/bin/tools/golangci-lint.exe" + Write-Host "Linting i18n-extractor..." + Push-Location "$PSScriptRoot/tools/i18n-extractor" + try { + & $golangciLint run ./... + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } finally { + Pop-Location + } +} + +function Lint-Tools { + Write-Host "================================================================" + Write-Host "Linting tools..." + Lint-CLI + Lint-I18n-Extractor + Write-Host "================================================================" +} + +function Build-Npm-Tools { + Ensure-Pnpm + Write-Host "Installing tools dependencies..." + & pnpm install --frozen-lockfile + Write-Host "Building npm-based tools..." + & pnpm --filter './tools/**' build + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +} + +function Build-Tools { + Write-Host "================================================================" + Write-Host "Building tools..." + Build-CLI + Build-I18n-Extractor + Build-Npm-Tools + Write-Host "================================================================" +} + +function Test-Tools { + Write-Host "================================================================" + Write-Host "Running tool tests..." + Test-CLI + Test-I18n-Extractor + Write-Host "================================================================" +} + function Initialize-Databases { param( [bool]$override = $false @@ -1793,6 +1892,15 @@ switch ($Command) { 'lint_sdks' { Lint-SDKs } + 'build_tools' { + Build-Tools + } + 'test_tools' { + Test-Tools + } + 'lint_tools' { + Lint-Tools + } 'package_samples' { Package-Sample-App } @@ -1822,7 +1930,7 @@ switch ($Command) { Test-Integration } default { - Write-Host "Usage: $($MyInvocation.MyCommand.Name) {clean|build|build_backend|build_frontend|build_docs|package_samples|test_unit|test_integration|merge_coverage|run|run_backend|run_frontend|run_docs|test}" + Write-Host "Usage: $($MyInvocation.MyCommand.Name) {clean|build|build_backend|build_frontend|build_docs|build_sdks|test_sdks|lint_sdks|build_tools|test_tools|lint_tools|package_samples|test_unit|test_integration|merge_coverage|run|run_backend|run_frontend|run_docs|test}" exit 1 } } diff --git a/build.sh b/build.sh index e764c18f88..fbaec4458b 100755 --- a/build.sh +++ b/build.sh @@ -437,6 +437,79 @@ function lint_sdks() { echo "================================================================" } +function build_cli() { + echo "Building CLI tool..." + bash "$SCRIPT_DIR/tools/cli/scripts/build.sh" +} + +function test_cli() { + echo "Running CLI tool tests..." + cd "$SCRIPT_DIR/tools/cli" && go test -v -race -count=1 ./... + cd "$SCRIPT_DIR" || exit 1 +} + +function build_i18n_extractor() { + local tool_bin="$SCRIPT_DIR/backend/bin/tools" + mkdir -p "$tool_bin" + echo "Building i18n-extractor..." + cd "$SCRIPT_DIR/tools/i18n-extractor" && go build -o "$tool_bin/i18n-extractor" . + cd "$SCRIPT_DIR" || exit 1 +} + +function test_i18n_extractor() { + echo "Running i18n-extractor tests..." + cd "$SCRIPT_DIR/tools/i18n-extractor" && go test -v . + cd "$SCRIPT_DIR" || exit 1 +} + +function lint_cli() { + local golangci_lint="$SCRIPT_DIR/backend/bin/tools/golangci-lint" + echo "Linting CLI tool..." + cd "$SCRIPT_DIR/tools/cli" && "$golangci_lint" run ./... + cd "$SCRIPT_DIR" || exit 1 +} + +function lint_i18n_extractor() { + local golangci_lint="$SCRIPT_DIR/backend/bin/tools/golangci-lint" + echo "Linting i18n-extractor..." + cd "$SCRIPT_DIR/tools/i18n-extractor" && "$golangci_lint" run ./... + cd "$SCRIPT_DIR" || exit 1 +} + +function lint_tools() { + echo "================================================================" + echo "Linting tools..." + lint_cli + lint_i18n_extractor + echo "================================================================" +} + +function build_npm_tools() { + ensure_pnpm + echo "Installing tools dependencies..." + pnpm install --frozen-lockfile + echo "Building npm-based tools..." + pnpm --filter './tools/**' build + cd "$SCRIPT_DIR" || exit 1 +} + +function build_tools() { + echo "================================================================" + echo "Building tools..." + build_cli + build_i18n_extractor + build_npm_tools + echo "================================================================" +} + +function test_tools() { + echo "================================================================" + echo "Running tool tests..." + test_cli + test_i18n_extractor + echo "================================================================" +} + function build_docs() { echo "================================================================" echo "Building documentation..." @@ -1242,6 +1315,15 @@ case "$1" in lint_sdks) lint_sdks ;; + build_tools) + build_tools + ;; + test_tools) + test_tools + ;; + lint_tools) + lint_tools + ;; package_samples) package_sample_app ;; @@ -1291,6 +1373,9 @@ case "$1" in echo " build_sdks - Build all SDK packages" echo " test_sdks - Run tests for all SDK packages" echo " lint_sdks - Run linting for all SDK packages" + echo " build_tools - Build all tool binaries (CLI + i18n-extractor + npm tools)" + echo " test_tools - Run tests for all tools (CLI + i18n-extractor)" + echo " lint_tools - Run linting for all tools (CLI + i18n-extractor)" echo " package_samples - Package the sample applications (samples are distributed as source)" echo " test_unit - Run unit tests with coverage" echo " test_integration - Run integration tests. Use -run and -package for filtering" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 774e0223d0..b472c10a57 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3025,42 +3025,7 @@ importers: specifier: 5.7.3 version: 5.7.3 - tools/npx-thunderid: - dependencies: - '@clack/prompts': - specifier: ^0.9.1 - version: 0.9.1 - picocolors: - specifier: ^1.1.1 - version: 1.1.1 - devDependencies: - '@thunderid/eslint-plugin': - specifier: workspace:^ - version: link:../../frontend/packages/eslint-plugin - '@thunderid/prettier-config': - specifier: workspace:^ - version: link:../../frontend/packages/prettier-config - '@types/node': - specifier: 'catalog:' - version: 24.7.2 - eslint: - specifier: 'catalog:' - version: 9.39.4(jiti@2.7.0) - prettier: - specifier: 'catalog:' - version: 3.6.2 - rimraf: - specifier: 'catalog:' - version: 6.1.3 - rolldown: - specifier: 'catalog:' - version: 1.0.0-beta.45(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) - typescript: - specifier: 'catalog:' - version: 5.9.3 - vitest: - specifier: 'catalog:' - version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.7.2)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(jsdom@27.0.1)(vite@8.0.16(@types/node@24.7.2)(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.47.1)(tsx@4.22.4)(yaml@2.8.3)) + tools/npx: {} packages: @@ -3892,9 +3857,6 @@ packages: '@clack/core@0.3.5': resolution: {integrity: sha512-5cfhQNH+1VQ2xLQlmzXMqUoiaH0lRBq9/CLW9lTyMbuKLC3+xEK01tHVvyut++mLOn5urSHmkm6I0Lg9MaJSTQ==} - '@clack/core@0.4.1': - resolution: {integrity: sha512-Pxhij4UXg8KSr7rPek6Zowm+5M22rbd2g1nfojHJkxp5YkFqiZ2+YLEM/XGVIzvGOcM0nqjIFxrpDwWRZYWYjA==} - '@clack/core@1.3.1': resolution: {integrity: sha512-fT1qHVGAag4IEkrupZ6lRRbNCs1vS9P01KB/sG8zKgvUztbYtFBtQpjSITNwooDZ83tpsPzP0mRNs1/KVszCRA==} engines: {node: '>= 20.12.0'} @@ -3904,9 +3866,6 @@ packages: bundledDependencies: - is-unicode-supported - '@clack/prompts@0.9.1': - resolution: {integrity: sha512-JIpyaboYZeWYlyP0H+OoPPxd6nqueG/CmN6ixBiNFsIDHREevjIf0n0Ohh5gr5C8pEDknzgvz+pIJ8dMhzWIeg==} - '@clack/prompts@1.4.0': resolution: {integrity: sha512-S0My7XPGIgpRWMDG8uRqalbgT+a6FmCUdOW+HaIOVVpUPHOb7RrpvjTjiODadKp06fsrVDJZlIzc6yCTp4AnxA==} engines: {node: '>= 20.12.0'} @@ -17105,11 +17064,6 @@ snapshots: picocolors: 1.1.1 sisteransi: 1.0.5 - '@clack/core@0.4.1': - dependencies: - picocolors: 1.1.1 - sisteransi: 1.0.5 - '@clack/core@1.3.1': dependencies: fast-wrap-ansi: 0.2.0 @@ -17121,12 +17075,6 @@ snapshots: picocolors: 1.1.1 sisteransi: 1.0.5 - '@clack/prompts@0.9.1': - dependencies: - '@clack/core': 0.4.1 - picocolors: 1.1.1 - sisteransi: 1.0.5 - '@clack/prompts@1.4.0': dependencies: '@clack/core': 1.3.1 diff --git a/samples/apps/wayfinder-sample/frontend/.env.example b/samples/apps/wayfinder-sample/frontend/.env.example index 3686f298ce..86705e11c2 100644 --- a/samples/apps/wayfinder-sample/frontend/.env.example +++ b/samples/apps/wayfinder-sample/frontend/.env.example @@ -2,3 +2,9 @@ # This is the WAYFINDER application โ€” separate from the chat agent's client. VITE_THUNDER_CLIENT_ID=WAYFINDER VITE_THUNDER_BASE_URL=https://localhost:8090 + +# Set to "true" to enable the AI concierge chat widget and agent-related routes +# (/agent-callback, /signin-as-agent). When false (default), all AI features are +# hidden and the agent:access OAuth scope is not requested. +# The `thunderid wayfinder --ai` flag sets this automatically. +VITE_AI_FEATURES_ENABLED=true diff --git a/samples/apps/wayfinder-sample/frontend/src/App.jsx b/samples/apps/wayfinder-sample/frontend/src/App.jsx index a638acffed..766101b74b 100644 --- a/samples/apps/wayfinder-sample/frontend/src/App.jsx +++ b/samples/apps/wayfinder-sample/frontend/src/App.jsx @@ -43,6 +43,7 @@ import { buildResultsPath, readCriteria } from "./utils/routes"; const AGENT_CHAT_URL = import.meta.env.VITE_AGENT_CHAT_URL || "http://localhost:8790/chat"; const THUNDER_BASE_URL = import.meta.env.VITE_THUNDER_BASE_URL || ""; +const AI_FEATURES_ENABLED = import.meta.env.VITE_AI_FEATURES_ENABLED === "true"; function createChatMessage(role, content) { return { @@ -819,11 +820,13 @@ function AppRoutes({ authReady, criteria, locations, onSearch }) { path="/profile" element={authReady ? : } /> - } /> - : } - /> + {AI_FEATURES_ENABLED && } />} + {AI_FEATURES_ENABLED && ( + : } + /> + )} } /> ); @@ -921,7 +924,7 @@ function App({ authReady }) { locations={locations} onSearch={handleSearch} /> - + {AI_FEATURES_ENABLED && } ); diff --git a/samples/apps/wayfinder-sample/frontend/src/main.jsx b/samples/apps/wayfinder-sample/frontend/src/main.jsx index a9b86eaf55..da4c1ce268 100644 --- a/samples/apps/wayfinder-sample/frontend/src/main.jsx +++ b/samples/apps/wayfinder-sample/frontend/src/main.jsx @@ -27,7 +27,9 @@ const clientId = import.meta.env.VITE_THUNDER_CLIENT_ID; const baseUrl = import.meta.env.VITE_THUNDER_BASE_URL; const thunderidReady = Boolean(clientId && baseUrl); -const SCOPES = ["openid", "profile", "email", "ou", "agent:access", "booking:read", "booking:create", "booking:cancel"]; +const AI_FEATURES_ENABLED = import.meta.env.VITE_AI_FEATURES_ENABLED === "true"; +const SCOPES = ["openid", "profile", "email", "ou", "booking:read", "booking:create", "booking:cancel", + ...(AI_FEATURES_ENABLED ? ["agent:access"] : [])]; createRoot(document.getElementById("root")).render( diff --git a/samples/apps/wayfinder-sample/package.json b/samples/apps/wayfinder-sample/package.json index a6cdde8ce3..f910c914b3 100644 --- a/samples/apps/wayfinder-sample/package.json +++ b/samples/apps/wayfinder-sample/package.json @@ -10,7 +10,8 @@ "frontend" ], "scripts": { - "dev": "concurrently \"npm --workspace backend run dev\" \"npm --workspace smtp-server run dev\" \"npm --workspace ai-agent run dev\" \"npm --workspace frontend run dev\"" + "dev": "concurrently \"npm --workspace backend run dev\" \"npm --workspace smtp-server run dev\" \"npm --workspace ai-agent run dev\" \"npm --workspace frontend run dev\"", + "dev:b2c": "concurrently \"npm --workspace backend run dev\" \"npm --workspace smtp-server run dev\" \"npm --workspace frontend run dev\"" }, "devDependencies": { "concurrently": "10.0.3" diff --git a/tools/cli/.golangci.yml b/tools/cli/.golangci.yml new file mode 100644 index 0000000000..606fa6b393 --- /dev/null +++ b/tools/cli/.golangci.yml @@ -0,0 +1,87 @@ +# ------------------------------------------------------------------------------ +# Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +# +# WSO2 LLC. licenses this file to you under the Apache License, +# Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ------------------------------------------------------------------------------ + +run: + timeout: 5m + allow-parallel-runners: true + +linters: + disable-all: true + enable: + - errcheck + - gocritic + - gofmt + - goimports + - gosimple + - govet + - ineffassign + - lll + - misspell + - revive + - staticcheck + - typecheck + - unused + - whitespace + +linters-settings: + errcheck: + check-blank: false + check-type-assertions: false + gci: + sections: + - standard + - default + - prefix(github.com/thunder-id/thunderid/tools/cli) + govet: + enable-all: true + disable: + - fieldalignment + - shadow + gocritic: + disabled-checks: + - exitAfterDefer + - ifElseChain + - elseif + enabled-tags: + - diagnostic + goimports: + local-prefixes: github.com/thunder-id/thunderid/tools/cli + misspell: + locale: US + ignore-words: + - cancelled + lll: + line-length: 140 + tab-width: 4 + revive: + rules: + - name: var-naming + - name: redundant-import-alias + - name: comment-spacings + - name: exported + arguments: + - disableStutteringCheck + - name: package-comments + +issues: + max-same-issues: 0 + max-issues-per-linter: 0 + exclude-use-default: false + +output: + show-stats: true diff --git a/tools/cli/cmd/thunderid/main.go b/tools/cli/cmd/thunderid/main.go new file mode 100644 index 0000000000..b66249d4dd --- /dev/null +++ b/tools/cli/cmd/thunderid/main.go @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Package main is the entry point for the ThunderID CLI. +package main + +import ( + "fmt" + "os" + + "github.com/thunder-id/thunderid/tools/cli/internal/cli" + "github.com/thunder-id/thunderid/tools/cli/internal/commands/sample" + "github.com/thunder-id/thunderid/tools/cli/internal/commands/upgrade" + "github.com/thunder-id/thunderid/tools/cli/internal/product" + "github.com/thunder-id/thunderid/tools/cli/internal/services/config" + "github.com/thunder-id/thunderid/tools/cli/internal/ui" +) + +func main() { + args := os.Args[1:] + + // upgrade [--direct] โ€” explicit upgrade with optional blue/green staging. + if len(args) > 0 && args[0] == "upgrade" { + verbose, direct := parseUpgradeFlags(args[1:]) + if err := upgrade.Run(cli.BaseDir(), upgrade.Opts{Direct: direct, Verbose: verbose}); err != nil { + os.Exit(1) + } + return + } + + // try โ€” download and launch a use-case sample app. + if len(args) >= 2 && args[0] == "try" { + usecase := args[1] + verbose, _ := parseFlags(args[2:]) + activeVersion := config.ReadActiveVersion() + if activeVersion == "" { + ui.Fatal(fmt.Sprintf("No active %s install found. Run `npx %s` first.", product.Name, product.Slug)) + os.Exit(1) + } + path := cli.VersionedInstallPath(activeVersion) + if err := sample.Run(usecase, path, verbose, sample.Options{}); err != nil { + ui.Fatal(err.Error()) + os.Exit(1) + } + return + } + + // integrate โ€” configure a technology integration (future). + if len(args) >= 2 && args[0] == "integrate" { + ui.Fatal(fmt.Sprintf("`integrate %s` is not yet implemented.", args[1])) + os.Exit(1) + } + + if len(args) > 0 && (args[0] == "--help" || args[0] == "-h") { + printUsage() + return + } + + verbose, forceSetup := parseFlags(args) + cli.Run(verbose, forceSetup) +} + +func printUsage() { + fmt.Printf(`Usage: %s [command] [flags] + +Commands: + (none) Install and start %s + upgrade Upgrade to the latest release (side-by-side by default) + try Download and launch a use-case sample app + integrate Configure a technology integration (coming soon) + +Flags: + --verbose, -v Show detailed output + --setup Force re-run setup + --help, -h Show this help message + +Upgrade flags: + --direct Upgrade in-place (stop current, upgrade, restart) +`, product.Slug, product.Name) +} + +func parseFlags(args []string) (verbose, forceSetup bool) { + for _, a := range args { + switch a { + case "--verbose", "-v": + verbose = true + case "--setup": + forceSetup = true + } + } + return +} + +func parseUpgradeFlags(args []string) (verbose, direct bool) { + for _, a := range args { + switch a { + case "--verbose", "-v": + verbose = true + case "--direct": + direct = true + } + } + return +} diff --git a/tools/cli/go.mod b/tools/cli/go.mod new file mode 100644 index 0000000000..fd185efd82 --- /dev/null +++ b/tools/cli/go.mod @@ -0,0 +1,45 @@ +module github.com/thunder-id/thunderid/tools/cli + +go 1.26.1 + +require ( + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/huh v1.0.0 + github.com/charmbracelet/huh/spinner v0.0.0-20260223110133-9dc45e34a40b + github.com/charmbracelet/lipgloss v1.1.0 + github.com/stretchr/testify v1.11.1 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/catppuccin/go v0.3.0 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/harmonica v0.2.0 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/sahilm/fuzzy v0.1.1 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.23.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/tools/cli/go.sum b/tools/cli/go.sum new file mode 100644 index 0000000000..a98875787d --- /dev/null +++ b/tools/cli/go.sum @@ -0,0 +1,96 @@ +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= +github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= +github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= +github.com/charmbracelet/huh/spinner v0.0.0-20260223110133-9dc45e34a40b h1:deQbW7eR/gYwkXonGX6a1now6H6f8v4kfv0OIKECu0I= +github.com/charmbracelet/huh/spinner v0.0.0-20260223110133-9dc45e34a40b/go.mod h1:Y68nuKJuC/Q2lmiq18EkHWkVWi2VGLrwaOfOyPKLkkE= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= +github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/tools/cli/internal/cli/root.go b/tools/cli/internal/cli/root.go new file mode 100644 index 0000000000..7846b1cec7 --- /dev/null +++ b/tools/cli/internal/cli/root.go @@ -0,0 +1,276 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Package cli contains the default command that installs, sets up, and starts ThunderID. +package cli + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + huhspinner "github.com/charmbracelet/huh/spinner" + "github.com/charmbracelet/lipgloss" + + "github.com/thunder-id/thunderid/tools/cli/internal/commands/upgrade" + "github.com/thunder-id/thunderid/tools/cli/internal/product" + "github.com/thunder-id/thunderid/tools/cli/internal/services/config" + "github.com/thunder-id/thunderid/tools/cli/internal/services/health" + "github.com/thunder-id/thunderid/tools/cli/internal/services/release" + "github.com/thunder-id/thunderid/tools/cli/internal/services/setup" + "github.com/thunder-id/thunderid/tools/cli/internal/ui" + "github.com/thunder-id/thunderid/tools/cli/internal/ui/spinner" +) + +// BaseDir is the parent directory that holds all versioned installs and samples. +func BaseDir() string { + return filepath.Join(".", product.Slug) +} + +// VersionedInstallPath returns the extracted artifact directory for the given version. +func VersionedInstallPath(version string) string { + return filepath.Join(BaseDir(), "v"+version) +} + +// Run executes the default (no-args) CLI command: fetch version, install if needed, +// run setup, start background, launch interactive REPL. +func Run(verbose, forceSetup bool) { + if !verbose && runtime.GOOS != "windows" { + fmt.Print("\033[H\033[2J") + } + + ui.PrintBanner() + + fmt.Print(ui.Dim(" Fetching latest " + product.Name + " release...")) + latestVersion, err := release.FetchLatestVersion() + if err != nil { + fmt.Println() + ui.Fatal("Could not fetch latest " + product.Name + " release: " + err.Error()) + os.Exit(1) + } + fmt.Printf("\r\033[2K %s Latest %s release: v%s\n\n", ui.Green("โœ“"), product.Name, latestVersion) + + activeVersion := config.ReadActiveVersion() + + // Always start the active version; only download when there's no installed version yet. + runVersion := latestVersion + if activeVersion != "" { + runVersion = activeVersion + } + + // Show a new-version banner inside the REPL (not a blocking prompt). + var newVersion string + if activeVersion != "" && activeVersion != latestVersion && !config.IsVersionSkipped(latestVersion) { + newVersion = latestVersion + } + + path := VersionedInstallPath(runVersion) + installOnDisk := false + if stored := config.ReadInstallPath(runVersion); stored != "" { + if _, err := os.Stat(stored); err == nil { + path = stored + installOnDisk = true + } + } + alreadyInstalled := activeVersion == runVersion && config.IsSetupComplete(runVersion) && installOnDisk + isFirstRun := !config.IsOnboardingDone(runVersion) + + // If the product is already responding on port 8090 and we have a valid local install, + // skip setup and attach the REPL to the running instance without starting a new process. + // If the install is missing or state is gone, fall through to reinstall even if something + // is already listening on the port. + if !forceSetup && alreadyInstalled && isRunning() { + ui.Note("Already running", + fmt.Sprintf("%s is already running on port %d.\nAttaching to the existing instance.", + product.Name, health.DefaultPort)) + upgradeRequested, err := ui.RunREPL(runVersion, nil, path, verbose, isFirstRun, newVersion) + if err != nil { + fmt.Fprintf(os.Stderr, "\nREPL error: %v\n", err) + os.Exit(1) + } + if upgradeRequested { + runUpgrade(verbose) + } + return + } + + if alreadyInstalled && !forceSetup { + ui.Note("Starting "+product.Name, fmt.Sprintf("%s v%s is ready\n%s", product.Name, runVersion, path)) + } else if activeVersion == runVersion && installOnDisk { + absPath, err := filepath.Abs(path) + if err != nil { + absPath = path + } + if err := config.WriteInstallPath(runVersion, absPath); err != nil { + ui.Fatal("Failed to record install path: " + err.Error()) + os.Exit(1) + } + path = absPath + if forceSetup { + ui.Note("Setup requested", fmt.Sprintf("Re-running setup for %s v%s\n%s", product.Name, runVersion, path)) + } else { + ui.Note("First-time setup", fmt.Sprintf("Setting up %s v%s\n%s", product.Name, runVersion, path)) + } + runSetupPhase(runVersion, path, verbose) + } else { + // If the previously-active version is no longer in the manifest we'd need + // to download it, so fall back to the latest available version. + if runVersion != latestVersion { + runVersion = latestVersion + path = VersionedInstallPath(runVersion) + newVersion = "" // already on latest after this download + } + downloadAndInstall(runVersion, path, verbose) + absPath, err := filepath.Abs(path) + if err != nil { + absPath = path + } + if err := config.WriteInstallPath(runVersion, absPath); err != nil { + ui.Fatal("Failed to record install path: " + err.Error()) + os.Exit(1) + } + path = absPath + runSetupPhase(runVersion, path, verbose) + if err := config.WriteActiveVersion(runVersion); err != nil { + ui.Fatal("Failed to record active version: " + err.Error()) + os.Exit(1) + } + } + + if !setup.WaitForPortFree(health.DefaultPort, 10*time.Second) { + setup.KillPort(health.DefaultPort) + setup.WaitForPortFree(health.DefaultPort, 5*time.Second) + } + + fmt.Print(ui.Dim("\n Starting " + product.Name + " in the background...")) + proc, err := setup.StartBackground(path, verbose) + if err != nil { + fmt.Println() + ui.Fatal("Failed to start " + product.Name + ": " + err.Error()) + os.Exit(1) + } + fmt.Printf("\r\033[2K %s %s started %s\n", ui.Green("โœ“"), product.Name, ui.Dim("logs: "+setup.LogDir(path))) + + upgradeRequested, err := ui.RunREPL(runVersion, proc, path, verbose, isFirstRun, newVersion) + if err != nil { + fmt.Fprintf(os.Stderr, "\nREPL error: %v\n", err) + os.Exit(1) + } + if upgradeRequested { + runUpgrade(verbose) + } +} + +func runUpgrade(verbose bool) { + if err := upgrade.Run(BaseDir(), upgrade.Opts{Verbose: verbose}); err != nil { + os.Exit(1) + } +} + +// isRunning returns true if the product is already responding on the default port. +func isRunning() bool { + for _, scheme := range []string{"https", "http"} { + if health.CheckReady(fmt.Sprintf("%s://localhost:%d", scheme, health.DefaultPort)) { + return true + } + } + return false +} + +// downloadAndInstall downloads and extracts the product into path. +func downloadAndInstall(version, path string, verbose bool) { + fmt.Println() + + if verbose { + if err := release.Download(version, path, func(pct int, msg string) { + if pct < 0 { + fmt.Println(" " + msg) + } else { + fmt.Printf(" %s %d%%\n", msg, pct) + } + }); err != nil { + ui.Fatal("Download failed: " + err.Error()) + os.Exit(1) + } + } else { + if err := release.Download(version, path, func(pct int, msg string) { + if pct < 0 { + fmt.Printf("\r\033[2K %s", msg) + } else { + fmt.Printf("\r\033[2K %s %s %3d%%", spinner.Render(pct), msg, pct) + } + }); err != nil { + fmt.Println() + ui.Fatal("Download failed: " + err.Error()) + os.Exit(1) + } + fmt.Println() + } + + fmt.Printf(" %s %s v%s installed to %s\n", ui.Green("โœ“"), product.Name, version, path) +} + +// runSetupPhase runs setup.sh with a spinner (non-verbose) or raw output (verbose). +// On failure in non-verbose mode, the captured stderr is printed before the error box. +func runSetupPhase(version, installPath string, verbose bool) { + if verbose { + fmt.Printf("\n Running %s setup (v%s)...\n", product.Name, version) + if err := setup.RunSetup(installPath, true); err != nil { + ui.Fatal("Setup failed: " + err.Error()) + os.Exit(1) + } + } else { + fmt.Println() + var setupErr error + if err := huhspinner.New(). + Style(lipgloss.NewStyle().Foreground(lipgloss.Color(product.ColorElectricBlue)).PaddingLeft(2)). + TitleStyle(lipgloss.NewStyle()). + Title("Setting up " + product.Name + "..."). + Action(func() { + setupErr = setup.RunSetup(installPath, false) + }). + Run(); err != nil { + ui.Fatal("Setup interrupted: " + err.Error()) + os.Exit(1) + } + if setupErr != nil { + msg := setupErr.Error() + if idx := strings.Index(msg, "\n\n"); idx != -1 { + detail := strings.TrimSpace(msg[idx+2:]) + if detail != "" { + fmt.Println() + for _, line := range strings.Split(detail, "\n") { + fmt.Println(" " + line) + } + fmt.Println() + } + msg = strings.TrimSpace(msg[:idx]) + } + ui.Fatal(msg) + os.Exit(1) + } + } + + if err := config.MarkSetupComplete(version); err != nil { + ui.Fatal("Failed to mark setup complete: " + err.Error()) + os.Exit(1) + } + fmt.Printf(" %s Setup complete\n", ui.Green("โœ“")) +} diff --git a/tools/cli/internal/commands/sample/sample.go b/tools/cli/internal/commands/sample/sample.go new file mode 100644 index 0000000000..d02ec3f6f2 --- /dev/null +++ b/tools/cli/internal/commands/sample/sample.go @@ -0,0 +1,589 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Package sample downloads and launches use-case sample applications. +package sample + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strings" + "time" + + "github.com/thunder-id/thunderid/tools/cli/internal/product" + "github.com/thunder-id/thunderid/tools/cli/internal/services/health" + "github.com/thunder-id/thunderid/tools/cli/internal/services/release" + "github.com/thunder-id/thunderid/tools/cli/internal/services/setup" + "github.com/thunder-id/thunderid/tools/cli/internal/ui/spinner" +) + +// Options carries use-case-specific configuration collected by the CLI before the sample starts. +type Options struct { + Config map[string]string // key/value pairs to write into the target service's .env + EnvTarget string // sample sub-dir to write the .env into (e.g. "ai-agent") + Features []string // feature tags, e.g. ["ai"] โ€” drive optional services and frontend flags +} + +// hasFeature reports whether tag is present in opts.Features. +func hasFeature(opts Options, tag string) bool { + for _, f := range opts.Features { + if f == tag { + return true + } + } + return false +} + +// knownSamples lists available use-case samples. +var knownSamples = map[string]struct { + description string + sampleURL string +}{ + "wayfinder": { + description: "B2C consumer app: Login, Sign-Up, Profile, Account Recovery, Internal User Onboarding", + sampleURL: "http://localhost:5173", + }, +} + +// typeToDir mirrors the awk mapping in start.sh's setup_declarative_resources. +var typeToDir = map[string]string{ + "application": "applications", + "flow": "flows", + "group": "groups", + "identity_provider": "identity_providers", + "layout": "layouts", + "notification_sender": "notification_senders", + "organization_unit": "organization_units", + "resource_server": "resource_servers", + "role": "roles", + "theme": "themes", + "translation": "translations", + "user": "users", + "user_schema": "user_schemas", +} + +// ProgressEvent is a single update from RunAsync's progress channel. +// When Overwrite is true the receiver should replace its previous progress line +// in-place (mimicking \r behavior) rather than appending a new one. +type ProgressEvent struct { + Line string + Overwrite bool +} + +// Result is returned by RunAsync when the operation completes. +type Result struct { + Proc *exec.Cmd + SampleURL string + ServerURL string // base URL confirmed by ResolveBaseURL; empty on error + Features []string // mirrors Options.Features so callers can display mode-aware output + Err error +} + +// Run downloads the named sample, writes its resources into the product repository, +// restarts the product, and starts the sample's services. Progress is written to stdout. +// In verbose mode every step is printed on its own line; otherwise a progress bar +// overwrites in-place (matching the product download experience). +func Run(sampleName, installPath string, verbose bool, opts Options) error { + _, sampleURL, serverURL, err := runWithResult(sampleName, installPath, opts, + func(msg string) { fmt.Println(" " + msg) }, + func(pct int, msg string) { + if verbose { + if pct >= 0 { + fmt.Printf(" %s %d%%\n", msg, pct) + } else { + fmt.Println(" " + msg) + } + } else { + if pct < 0 { + fmt.Printf("\r\033[2K %s", msg) + } else { + fmt.Printf("\r\033[2K %s %s %3d%%", spinner.Render(pct), msg, pct) + } + } + }, + ) + if err == nil { + printSummary(sampleName, serverURL, sampleURL, opts.Features) + } + return err +} + +// RunAsync runs the workflow in a goroutine, streaming ProgressEvents on the first +// channel. In non-verbose mode, events with Overwrite=true replace the previous +// bottom-status line (progress bar and inline status); non-overwrite events are +// appended to the message list. In verbose mode all events are non-overwrite. +// The second channel receives exactly one Result. +func RunAsync(sampleName, installPath string, verbose bool, opts Options) (<-chan ProgressEvent, <-chan Result) { + progress := make(chan ProgressEvent, 64) + result := make(chan Result, 1) + + send := func(line string) { + select { + case progress <- ProgressEvent{Line: line}: + default: + } + } + sendBar := func(line string) { + if verbose { + send(line) + return + } + select { + case progress <- ProgressEvent{Line: line, Overwrite: true}: + default: + } + } + + go func() { + defer close(progress) + defer close(result) + proc, sampleURL, serverURL, err := runWithResult(sampleName, installPath, opts, + send, + func(pct int, msg string) { + if pct < 0 { + send(msg) + } else { + sendBar(fmt.Sprintf("%s %s %3d%%", spinner.Render(pct), msg, pct)) + } + }, + ) + result <- Result{Proc: proc, SampleURL: sampleURL, ServerURL: serverURL, Features: opts.Features, Err: err} + }() + return progress, result +} + +func runWithResult( + sampleName, installPath string, + opts Options, + progress func(string), + onDownload release.ProgressFunc, +) (*exec.Cmd, string, string, error) { + meta, ok := knownSamples[sampleName] + if !ok { + return nil, "", "", fmt.Errorf("unknown sample %q โ€” available: %s", sampleName, availableList()) + } + + // Fetch latest version. + version, err := release.FetchLatestVersion() + if err != nil { + return nil, "", "", fmt.Errorf("could not fetch latest version: %w", err) + } + + // Download sample into the shared samples directory inside the product base dir. + // Invalidate the cache when the fetched release version differs from what was previously downloaded. + cacheDir := filepath.Join(filepath.Dir(installPath), "samples", sampleName) + cachedVersion := readCachedSampleVersion(cacheDir) + if cachedVersion != version { + if cachedVersion != "" { + _ = os.RemoveAll(cacheDir) + } + if err := release.DownloadSample(sampleName, version, cacheDir, onDownload); err != nil { + return nil, "", "", fmt.Errorf("download failed: %w", err) + } + _ = writeCachedSampleVersion(cacheDir, version) + progress(fmt.Sprintf("โœ“ Downloaded %s sample v%s", sampleName, version)) + } else { + progress(fmt.Sprintf("Using existing %s sample at %s/samples/%s", sampleName, product.Slug, sampleName)) + } + + // Find config files inside the extracted sample (may be in a subdirectory). + configYAML, configEnv, sampleDir, err := findSampleConfig(cacheDir) + if err != nil { + return nil, "", "", err + } + + // Parse env variables. + vars, err := parseEnvFile(configEnv) + if err != nil { + return nil, "", "", fmt.Errorf("could not read env file: %w", err) + } + + // Stop the product and the consent server (port 9090). + progress("Stopping " + product.Name + "...") + setup.KillPort(health.DefaultPort) + setup.KillPort(consentServerPort) + setup.WaitForPortFree(health.DefaultPort, 15*time.Second) + setup.WaitForPortFree(consentServerPort, 15*time.Second) + + // Find ThunderID root and write resource files. + thunderRoot, err := setup.FindThunderRoot(installPath) + if err != nil { + return nil, "", "", fmt.Errorf("could not find %s root: %w", product.Name, err) + } + progress("Writing wayfinder resources...") + if err := writeResources(configYAML, vars, thunderRoot); err != nil { + return nil, "", "", fmt.Errorf("could not write resources: %w", err) + } + + // Start the product. + progress("Starting " + product.Name + "...") + proc, err := setup.StartBackground(installPath, false) + if err != nil { + return nil, "", "", fmt.Errorf("could not start %s: %w", product.Name, err) + } + + // Wait for the product to be ready. + serverURL, ready := health.ResolveBaseURL(health.DefaultPort, 60*time.Second) + if !ready { + return proc, meta.sampleURL, "", + fmt.Errorf("%s did not become ready within 60 seconds โ€” check logs at %s", + product.Name, setup.LogDir(installPath)) + } + progress(fmt.Sprintf("%s ready at %s", product.Name, serverURL)) + + // Install workspace dependencies if not already present. + if _, err := os.Stat(filepath.Join(sampleDir, "node_modules")); os.IsNotExist(err) { + progress("Installing dependencies...") + installCmd := exec.Command("npm", "install", "--silent") + installCmd.Dir = sampleDir + if out, installErr := installCmd.CombinedOutput(); installErr != nil { + return proc, meta.sampleURL, serverURL, + fmt.Errorf("npm install failed: %w\n%s", installErr, out) + } + } + + // Seed database on first run. + if _, err := os.Stat(filepath.Join(sampleDir, "backend", "wayfinder.sqlite")); os.IsNotExist(err) { + progress("Seeding database...") + seedCmd := exec.Command("npm", "run", "seed") + seedCmd.Dir = filepath.Join(sampleDir, "backend") + if out, seedErr := seedCmd.CombinedOutput(); seedErr != nil { + return proc, meta.sampleURL, serverURL, + fmt.Errorf("seed failed: %w\n%s", seedErr, out) + } + } + + // Write service .env files so each process starts with the right credentials. + aiEnabled := hasFeature(opts, "ai") + if err := writeFrontendEnv(sampleDir, serverURL, aiEnabled); err != nil { + return proc, meta.sampleURL, serverURL, fmt.Errorf("could not write frontend env: %w", err) + } + if aiEnabled && opts.EnvTarget != "" { + if err := writeServiceEnv(sampleDir, serverURL, vars, opts); err != nil { + return proc, meta.sampleURL, serverURL, fmt.Errorf("could not write %s env: %w", opts.EnvTarget, err) + } + } + + // Start sample services. + progress("Starting " + sampleName + " services...") + if err := startSampleServices(sampleDir, aiEnabled); err != nil { + return proc, meta.sampleURL, serverURL, fmt.Errorf("could not start sample: %w", err) + } + + return proc, meta.sampleURL, serverURL, nil +} + +// findSampleConfig locates the config YAML and env file within dir. +// The ZIP may extract to a nested subdirectory, so we search one level deep. +func findSampleConfig(dir string) (configYAML, configEnv, sampleDir string, err error) { + candidates := []string{dir} + entries, _ := os.ReadDir(dir) + for _, e := range entries { + if e.IsDir() { + candidates = append(candidates, filepath.Join(dir, e.Name())) + } + } + for _, base := range candidates { + configDir := product.Slug + "-config" + yaml := filepath.Join(base, configDir, configDir+".yaml") + env := filepath.Join(base, configDir, product.Slug+".env") + if _, err := os.Stat(yaml); err == nil { + return yaml, env, base, nil + } + } + return "", "", "", fmt.Errorf("%s-config/%s-config.yaml not found in %s", product.Slug, product.Slug, dir) +} + +// parseEnvFile reads KEY=VALUE lines into a map. +func parseEnvFile(path string) (map[string]string, error) { + vars := make(map[string]string) + f, err := os.Open(path) + if err != nil { + // Env file is optional. + return vars, nil + } + defer func() { _ = f.Close() }() + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + k, v, _ := strings.Cut(line, "=") + if k = strings.TrimSpace(k); k != "" { + vars[k] = strings.TrimSpace(v) + } + } + return vars, scanner.Err() +} + +// SampleDir returns the cache directory for the named sample given the versioned install path. +func SampleDir(installPath, sampleName string) string { + return filepath.Join(filepath.Dir(installPath), "samples", sampleName) +} + +// ReadServiceEnv reads key/value pairs from //.env. +// Returns an empty map if the file does not exist. +// Provider-specific API keys (ANTHROPIC_API_KEY, GOOGLE_API_KEY) are reverse-mapped +// to the generic LLM_API_KEY so the REPL can pre-populate the prompt on subsequent runs. +func ReadServiceEnv(sampleDir, envTarget string) map[string]string { + vals, _ := parseEnvFile(filepath.Join(sampleDir, envTarget, ".env")) + if _, ok := vals["LLM_API_KEY"]; !ok { + provider := strings.ToLower(vals["LLM_PROVIDER"]) + if provider == "gemini" || provider == "google" { + if v := vals["GOOGLE_API_KEY"]; v != "" { + vals["LLM_API_KEY"] = v + } + } else if v := vals["ANTHROPIC_API_KEY"]; v != "" { + vals["LLM_API_KEY"] = v + } + } + return vals +} + +// writeResources splits the multi-document YAML, substitutes template variables, +// and writes each document to thunderRoot/repository/resources//.yaml. +func writeResources(yamlPath string, vars map[string]string, thunderRoot string) error { + raw, err := os.ReadFile(yamlPath) + if err != nil { + return err + } + + content := substituteVars(string(raw), vars) + docs := splitYAML(content) + + reResourceType := regexp.MustCompile(`(?m)^#\s*resource_type:\s*(\S+)`) + reID := regexp.MustCompile(`(?m)^(?:id|handle):\s*(\S+)`) + + for i, doc := range docs { + doc = strings.TrimSpace(doc) + if doc == "" { + continue + } + + m := reResourceType.FindStringSubmatch(doc) + if m == nil { + continue + } + resourceType := m[1] + + dir, ok := typeToDir[resourceType] + if !ok { + dir = resourceType + "s" + } + + var filename string + if idM := reID.FindStringSubmatch(doc); idM != nil { + filename = idM[1] + ".yaml" + } else { + filename = fmt.Sprintf("%s_%04d.yaml", resourceType, i) + } + + target := filepath.Join(thunderRoot, "repository", "resources", dir) + if err := os.MkdirAll(target, 0o755); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(target, filename), []byte(doc+"\n"), 0o644); err != nil { + return err + } + } + return nil +} + +// substituteVars replaces {{.KEY}} template placeholders with values from vars. +func substituteVars(content string, vars map[string]string) string { + re := regexp.MustCompile(`\{\{\.(\w+)\}\}`) + return re.ReplaceAllStringFunc(content, func(m string) string { + key := re.FindStringSubmatch(m)[1] + if v, ok := vars[key]; ok { + return v + } + return m + }) +} + +// splitYAML splits a multi-document YAML string on "---" separators. +func splitYAML(content string) []string { + var docs []string + var cur strings.Builder + for _, line := range strings.Split(content, "\n") { + if strings.TrimSpace(line) == "---" { + if s := strings.TrimSpace(cur.String()); s != "" { + docs = append(docs, s) + } + cur.Reset() + } else { + cur.WriteString(line) + cur.WriteByte('\n') + } + } + if s := strings.TrimSpace(cur.String()); s != "" { + docs = append(docs, s) + } + return docs +} + +const consentServerPort = 9090 + +// writeFrontendEnv writes frontend/.env with Thunder client config and the +// VITE_AI_FEATURES_ENABLED flag so the React dev server picks up the right mode. +func writeFrontendEnv(sampleDir, thunderURL string, aiEnabled bool) error { + enabled := "false" + if aiEnabled { + enabled = "true" + } + content := "VITE_THUNDER_CLIENT_ID=WAYFINDER\n" + + "VITE_THUNDER_BASE_URL=" + thunderURL + "\n" + + "VITE_AI_FEATURES_ENABLED=" + enabled + "\n" + return os.WriteFile(filepath.Join(sampleDir, "frontend", ".env"), []byte(content), 0o644) +} + +// writeServiceEnv writes /.env combining standard Thunder +// credentials (from the thunderid.env vars map) with every key/value in opts.Config. +func writeServiceEnv(sampleDir, thunderURL string, vars map[string]string, opts Options) error { + var b strings.Builder + b.WriteString("THUNDER_BASE_URL=" + thunderURL + "\n") + if v := vars["AGENT_CLIENT_ID"]; v != "" { + b.WriteString("AGENT_ID=" + v + "\n") + } + if v := vars["AGENT_CLIENT_SECRET"]; v != "" { + b.WriteString("AGENT_SECRET=" + v + "\n") + } + b.WriteString("AGENT_REDIRECT_URI=http://localhost:5173/agent-callback\n") + b.WriteString("AGENT_ACCESS_SCOPE=agent:access\n") + for k, v := range opts.Config { + if k == "LLM_API_KEY" { + provider := strings.ToLower(opts.Config["LLM_PROVIDER"]) + if provider == "gemini" || provider == "google" { + k = "GOOGLE_API_KEY" + } else { + k = "ANTHROPIC_API_KEY" + } + } + b.WriteString(k + "=" + v + "\n") + } + return os.WriteFile(filepath.Join(sampleDir, opts.EnvTarget, ".env"), []byte(b.String()), 0o644) +} + +// startSampleServices launches the sample services in the background via npm. +// For AgentID mode (aiEnabled=true) it runs `npm run dev` (all three services); +// otherwise it runs `npm run dev:b2c` (backend + frontend only). +func startSampleServices(sampleDir string, aiEnabled bool) error { + logsDir := filepath.Join(sampleDir, "logs") + if err := os.MkdirAll(logsDir, 0o755); err != nil { + return err + } + logFile, err := os.OpenFile(filepath.Join(logsDir, "sample.log"), + os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + logFile, _ = os.OpenFile(os.DevNull, os.O_WRONLY, 0) + } + + script := "dev:b2c" + if aiEnabled { + script = "dev" + } + + npmExe := "npm" + if runtime.GOOS == "windows" { + npmExe = "npm.cmd" + } + logPath := filepath.Join(logsDir, "sample.log") + cmd := exec.Command(npmExe, "run", script) + cmd.Dir = sampleDir + cmd.Stdout = logFile + cmd.Stderr = logFile // never write to os.Stderr โ€” it corrupts the Bubble Tea display + cmd.Stdin = nil + if err := cmd.Start(); err != nil { + return err + } + + // Detect immediate failures (e.g. missing npm script) before returning. + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + select { + case err := <-done: + tail := tailLog(logPath, 10) + if err != nil { + return fmt.Errorf("sample services failed to start:\n%s", tail) + } + case <-time.After(2 * time.Second): + // Still running โ€” startup succeeded. + } + return nil +} + +// tailLog returns the last n lines of the file at path, or a fallback message. +func tailLog(path string, n int) string { + data, err := os.ReadFile(path) + if err != nil { + return "(no log available)" + } + lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n") + if len(lines) > n { + lines = lines[len(lines)-n:] + } + return strings.Join(lines, "\n") +} + +func printSummary(sampleName, thunderURL, sampleURL string, features []string) { + fmt.Println() + fmt.Printf(" โœ“ %s is ready at %s\n", product.Name, thunderURL) + fmt.Printf(" โœ“ Wayfinder is running at %s\n", sampleURL) + fmt.Println() + + if sampleName == "wayfinder" { + fmt.Println(" Try these walkthroughs:") + fmt.Println() + if hasFeature(Options{Features: features}, "ai") { + fmt.Println(" AI Concierge โ†’ click the chat bubble and ask about flights") + fmt.Println(" Book via Agent โ†’ ask the concierge to book a flight โ€” approve the consent prompt") + fmt.Println(" Agent Identity โ†’ open " + sampleURL + "/signin-as-agent") + } else { + fmt.Println(" Login โ†’ sign in as john.doe / john.doe") + fmt.Println(" Self Sign-Up โ†’ create a new account at the frontend") + fmt.Println(" View Profile โ†’ sign in, open the Profile tab") + fmt.Println(" Account Recovery โ†’ click \"Forgot password?\" (requires SMTP in deployment.yaml)") + fmt.Println(" Onboard Users โ†’ sign in as alex.carter / alex.carter (Admin)") + } + } + fmt.Println() + fmt.Println(" Press Ctrl+C to stop.") + fmt.Println() +} + +func readCachedSampleVersion(dir string) string { + data, _ := os.ReadFile(filepath.Join(dir, ".version")) + return strings.TrimSpace(string(data)) +} + +func writeCachedSampleVersion(dir, version string) error { + return os.WriteFile(filepath.Join(dir, ".version"), []byte(version+"\n"), 0o644) +} + +func availableList() string { + names := make([]string, 0, len(knownSamples)) + for k := range knownSamples { + names = append(names, k) + } + return strings.Join(names, ", ") +} diff --git a/tools/cli/internal/commands/upgrade/upgrade.go b/tools/cli/internal/commands/upgrade/upgrade.go new file mode 100644 index 0000000000..9b9514e091 --- /dev/null +++ b/tools/cli/internal/commands/upgrade/upgrade.go @@ -0,0 +1,299 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Package upgrade orchestrates in-place and side-by-side version upgrades. +package upgrade + +import ( + "fmt" + "os/exec" + "path/filepath" + "runtime" + "strings" + "syscall" + "time" + + "github.com/charmbracelet/huh" + huhspinner "github.com/charmbracelet/huh/spinner" + "github.com/charmbracelet/lipgloss" + + "github.com/thunder-id/thunderid/tools/cli/internal/product" + "github.com/thunder-id/thunderid/tools/cli/internal/services/config" + "github.com/thunder-id/thunderid/tools/cli/internal/services/health" + "github.com/thunder-id/thunderid/tools/cli/internal/services/release" + "github.com/thunder-id/thunderid/tools/cli/internal/services/setup" + "github.com/thunder-id/thunderid/tools/cli/internal/ui" + "github.com/thunder-id/thunderid/tools/cli/internal/ui/spinner" +) + +const stagingPort = 8091 + +// Opts controls how the upgrade runs. +type Opts struct { + Direct bool // skip side-by-side and upgrade in-place + Verbose bool +} + +// Run executes the upgrade workflow. baseDir is the parent thunderid directory (e.g. "./thunderid"). +func Run(baseDir string, opts Opts) error { + fmt.Print(ui.Dim(" Fetching latest " + product.Name + " release...")) + latestVersion, err := release.FetchLatestVersion() + if err != nil { + fmt.Println() + ui.Fatal("Could not fetch latest " + product.Name + " release: " + err.Error()) + return err + } + fmt.Printf("\r\033[2K %s Latest %s release: v%s\n\n", ui.Green("โœ“"), product.Name, latestVersion) + + activeVersion := config.ReadActiveVersion() + if activeVersion == latestVersion { + ui.Success(product.Name + " v" + latestVersion + " is already the latest version.") + return nil + } + + if activeVersion != "" { + fmt.Printf(" Upgrading %s: %s โ†’ %s\n\n", + product.Name, + ui.Dim("v"+activeVersion), + ui.Green("v"+latestVersion), + ) + } + + if opts.Direct || activeVersion == "" { + return runDirect(baseDir, activeVersion, latestVersion, opts.Verbose) + } + + var mode string + if err := huh.NewSelect[string](). + Title("How would you like to upgrade?"). + Options( + huh.NewOption( + fmt.Sprintf("Side-by-side โ€” run v%s on port %d while v%s keeps serving on %d (recommended)", + latestVersion, stagingPort, activeVersion, health.DefaultPort), + "side-by-side", + ), + huh.NewOption( + fmt.Sprintf("Direct โ€” stop v%s, upgrade, restart on port %d", activeVersion, health.DefaultPort), + "direct", + ), + ). + Value(&mode). + Run(); err != nil { + return nil // cancelled + } + + if mode == "direct" { + return runDirect(baseDir, activeVersion, latestVersion, opts.Verbose) + } + return runSideBySide(baseDir, activeVersion, latestVersion, opts.Verbose) +} + +func runDirect(baseDir, activeVersion, newVersion string, verbose bool) error { + label := product.Name + if activeVersion != "" { + label = product.Name + " v" + activeVersion + } + fmt.Print(ui.Dim(" Stopping " + label + "...")) + setup.KillPort(health.DefaultPort) + setup.KillPort(stagingPort) + setup.WaitForPortFree(health.DefaultPort, 15*time.Second) + if activeVersion != "" { + fmt.Printf("\r\033[2K %s Stopped v%s\n", ui.Green("โœ“"), activeVersion) + } else { + fmt.Printf("\r\033[2K\n") + } + + newPath := versionedPath(baseDir, newVersion) + if err := downloadVersion(newVersion, newPath, verbose); err != nil { + return err + } + if err := runSetupWithPort(newVersion, newPath, verbose, 0); err != nil { + return err + } + + fmt.Print(ui.Dim("\n Starting " + product.Name + " v" + newVersion + "...")) + proc, err := setup.StartBackground(newPath, verbose) + if err != nil { + fmt.Println() + ui.Fatal("Failed to start " + product.Name + ": " + err.Error()) + return err + } + + // Persist both the install path and the new active version only after the + // process has successfully started, so a failed launch doesn't corrupt state. + if err := config.WriteInstallPath(newVersion, newPath); err != nil { + ui.Fatal("Failed to persist install path: " + err.Error()) + return err + } + if err := config.WriteActiveVersion(newVersion); err != nil { + ui.Fatal("Failed to update active version: " + err.Error()) + return err + } + fmt.Printf("\r\033[2K %s %s v%s started %s\n", ui.Green("โœ“"), product.Name, newVersion, ui.Dim("logs: "+setup.LogDir(newPath))) + + _, err = ui.RunREPL(newVersion, proc, newPath, verbose, false, "") + return err +} + +func runSideBySide(baseDir, activeVersion, newVersion string, verbose bool) error { + newPath := versionedPath(baseDir, newVersion) + if err := downloadVersion(newVersion, newPath, verbose); err != nil { + return err + } + if err := runSetupWithPort(newVersion, newPath, verbose, stagingPort); err != nil { + return err + } + + fmt.Print(ui.Dim(fmt.Sprintf("\n Starting %s v%s on port %d (staging)...", product.Name, newVersion, stagingPort))) + proc, err := setup.StartBackgroundOnPort(newPath, verbose, stagingPort) + if err != nil { + fmt.Println() + ui.Fatal("Failed to start staging instance: " + err.Error()) + return err + } + fmt.Printf("\r\033[2K %s %s v%s staging on port %d\n", ui.Green("โœ“"), product.Name, newVersion, stagingPort) + fmt.Printf(" %s v%s still serving on port %d\n\n", ui.Dim("โ†’ Current"), activeVersion, health.DefaultPort) + fmt.Printf(" Type %s in the REPL to cut over to v%s and restart on port %d.\n\n", + ui.Cyan("/cutover"), newVersion, health.DefaultPort) + + cutoverRequested, err := ui.RunStagingREPL(newVersion, proc, newPath, verbose, stagingPort) + if err != nil { + return err + } + if !cutoverRequested { + return nil // user exited without cutting over + } + return performCutover(baseDir, activeVersion, newVersion, newPath, proc, verbose) +} + +func performCutover(baseDir, activeVersion, newVersion, newPath string, stagingProc *exec.Cmd, verbose bool) error { + fmt.Printf("\n %s Cutting over from v%s to v%s...\n\n", ui.Cyan("โ†’"), activeVersion, newVersion) + + if stagingProc != nil && stagingProc.Process != nil { + if runtime.GOOS == "windows" { + stagingProc.Process.Kill() //nolint:errcheck + } else { + stagingProc.Process.Signal(syscall.SIGTERM) //nolint:errcheck + } + time.Sleep(time.Second) + } + + fmt.Print(ui.Dim(" Stopping v" + activeVersion + "...")) + setup.KillPort(health.DefaultPort) + setup.WaitForPortFree(health.DefaultPort, 15*time.Second) + fmt.Printf("\r\033[2K %s v%s stopped\n", ui.Green("โœ“"), activeVersion) + + fmt.Print(ui.Dim(fmt.Sprintf(" Starting %s v%s on port %d...", product.Name, newVersion, health.DefaultPort))) + proc, err := setup.StartBackground(newPath, verbose) + if err != nil { + fmt.Println() + return fmt.Errorf("failed to start %s: %w", product.Name, err) + } + + // Persist state only after the new instance is confirmed running. + if err := config.WriteInstallPath(newVersion, newPath); err != nil { + return fmt.Errorf("failed to persist install path: %w", err) + } + if err := config.WriteActiveVersion(newVersion); err != nil { + return fmt.Errorf("failed to update active version: %w", err) + } + fmt.Printf("\r\033[2K %s %s v%s is now live on port %d\n", ui.Green("โœ“"), product.Name, newVersion, health.DefaultPort) + + _, err = ui.RunREPL(newVersion, proc, newPath, verbose, false, "") + return err +} + +func downloadVersion(version, destDir string, verbose bool) error { + fmt.Println() + if verbose { + if err := release.Download(version, destDir, func(pct int, msg string) { + if pct < 0 { + fmt.Println(" " + msg) + } else { + fmt.Printf(" %s %d%%\n", msg, pct) + } + }); err != nil { + ui.Fatal("Download failed: " + err.Error()) + return err + } + } else { + if err := release.Download(version, destDir, func(pct int, msg string) { + if pct < 0 { + fmt.Printf("\r\033[2K %s", msg) + } else { + fmt.Printf("\r\033[2K %s %s %3d%%", spinner.Render(pct), msg, pct) + } + }); err != nil { + fmt.Println() + ui.Fatal("Download failed: " + err.Error()) + return err + } + fmt.Println() + } + fmt.Printf(" %s %s v%s installed to %s\n", ui.Green("โœ“"), product.Name, version, destDir) + return nil +} + +func runSetupWithPort(version, installPath string, verbose bool, port int) error { + if verbose { + fmt.Printf("\n Running %s setup (v%s)...\n", product.Name, version) + if err := setup.RunSetupOnPort(installPath, true, port); err != nil { + ui.Fatal("Setup failed: " + err.Error()) + return err + } + } else { + fmt.Println() + var setupErr error + if err := huhspinner.New(). + Style(lipgloss.NewStyle().Foreground(lipgloss.Color(product.ColorElectricBlue)).PaddingLeft(2)). + TitleStyle(lipgloss.NewStyle()). + Title("Setting up " + product.Name + " v" + version + "..."). + Action(func() { + setupErr = setup.RunSetupOnPort(installPath, false, port) + }). + Run(); err != nil { + ui.Fatal("Setup interrupted: " + err.Error()) + return err + } + if setupErr != nil { + msg := setupErr.Error() + if idx := strings.Index(msg, "\n\n"); idx != -1 { + detail := strings.TrimSpace(msg[idx+2:]) + if detail != "" { + fmt.Println() + for _, line := range strings.Split(detail, "\n") { + fmt.Println(" " + line) + } + fmt.Println() + } + msg = strings.TrimSpace(msg[:idx]) + } + ui.Fatal(msg) + return setupErr + } + } + if err := config.MarkSetupComplete(version); err != nil { + ui.Fatal("Failed to mark setup complete: " + err.Error()) + return err + } + fmt.Printf(" %s Setup complete\n", ui.Green("โœ“")) + return nil +} + +func versionedPath(baseDir, version string) string { + return filepath.Join(baseDir, "v"+version) +} diff --git a/tools/cli/internal/product/product.go b/tools/cli/internal/product/product.go new file mode 100644 index 0000000000..1826b9fea0 --- /dev/null +++ b/tools/cli/internal/product/product.go @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Package product contains product-level constants shared across the CLI. +package product + +// Product identity constants. +const ( + Name = "ThunderID" + Slug = "thunderid" +) + +// Distribution URLs. +const ( + ReleasesURL = "https://brionmario.github.io/thunderid/data/releases.json" + GitHubAPI = "https://api.github.com/repos/thunder-id/thunderid/releases/latest" +) + +// Brand colors. +const ( + ColorDeepNavy = "#05213F" // primary brand โ€” logo text and dark backgrounds + ColorElectricBlue = "#3688FF" // accent โ€” icon highlight, links, call-to-action + ColorWhite = "#FFFFFF" // light backgrounds and inverted text +) diff --git a/tools/cli/internal/product/product_test.go b/tools/cli/internal/product/product_test.go new file mode 100644 index 0000000000..8ecb39fa65 --- /dev/null +++ b/tools/cli/internal/product/product_test.go @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package product_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/thunder-id/thunderid/tools/cli/internal/product" +) + +func TestConstants_NonEmpty(t *testing.T) { + assert.NotEmpty(t, product.Name) + assert.NotEmpty(t, product.Slug) + assert.NotEmpty(t, product.ReleasesURL) + assert.NotEmpty(t, product.GitHubAPI) +} + +func TestBrandColors_HexFormat(t *testing.T) { + for _, color := range []string{product.ColorDeepNavy, product.ColorElectricBlue, product.ColorWhite} { + assert.True(t, strings.HasPrefix(color, "#"), "color %q should start with #", color) + assert.Equal(t, 7, len(color), "color %q should be 7 chars (#RRGGBB)", color) + } +} + +func TestReleasesURL_HTTPS(t *testing.T) { + assert.True(t, strings.HasPrefix(product.ReleasesURL, "https://")) + assert.True(t, strings.HasPrefix(product.GitHubAPI, "https://")) +} diff --git a/tools/cli/internal/services/config/config.go b/tools/cli/internal/services/config/config.go new file mode 100644 index 0000000000..874accafa8 --- /dev/null +++ b/tools/cli/internal/services/config/config.go @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Package config manages persistent CLI state stored in ~/.thunderid/state.json. +package config + +import ( + "encoding/json" + "os" + "path/filepath" + + "github.com/thunder-id/thunderid/tools/cli/internal/product" +) + +// StateDir returns the hidden state directory under the user's home. +// Falls back to the OS temp directory when the home directory cannot be determined. +func StateDir() string { + home, err := os.UserHomeDir() + if err != nil { + home = os.TempDir() + } + return filepath.Join(home, "."+product.Slug) +} + +func statePath() string { + return filepath.Join(StateDir(), "state.json") +} + +type versionState struct { + InstallPath string `json:"installPath,omitempty"` + SetupComplete bool `json:"setupComplete,omitempty"` + OnboardingDone bool `json:"onboardingDone,omitempty"` +} + +type stateFile struct { + Active string `json:"active,omitempty"` + Versions map[string]versionState `json:"versions,omitempty"` + SkippedUpgrades []string `json:"skippedUpgrades,omitempty"` +} + +func load() stateFile { + data, err := os.ReadFile(statePath()) + if err != nil { + return stateFile{} + } + var s stateFile + if err := json.Unmarshal(data, &s); err != nil { + return stateFile{} + } + return s +} + +func save(s stateFile) error { + if err := os.MkdirAll(StateDir(), 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + return err + } + return os.WriteFile(statePath(), data, 0o644) +} + +// ReadActiveVersion returns the active version, or "" if none is recorded. +func ReadActiveVersion() string { + return load().Active +} + +// WriteActiveVersion records version as the active version. +func WriteActiveVersion(version string) error { + s := load() + s.Active = version + return save(s) +} + +// IsSetupComplete reports whether setup has been completed for version. +func IsSetupComplete(version string) bool { + s := load() + return s.Versions[version].SetupComplete +} + +// MarkSetupComplete records that setup has been completed for version. +func MarkSetupComplete(version string) error { + s := load() + if s.Versions == nil { + s.Versions = make(map[string]versionState) + } + v := s.Versions[version] + v.SetupComplete = true + s.Versions[version] = v + return save(s) +} + +// ReadInstallPath returns the recorded absolute install path for version, or "" if none is stored. +func ReadInstallPath(version string) string { + return load().Versions[version].InstallPath +} + +// WriteInstallPath records the absolute install path for version. +func WriteInstallPath(version, installPath string) error { + s := load() + if s.Versions == nil { + s.Versions = make(map[string]versionState) + } + v := s.Versions[version] + v.InstallPath = installPath + s.Versions[version] = v + return save(s) +} + +// IsOnboardingDone reports whether the first-run onboarding has been shown for version. +func IsOnboardingDone(version string) bool { + s := load() + return s.Versions[version].OnboardingDone +} + +// MarkOnboardingDone records that onboarding has been completed for version. +func MarkOnboardingDone(version string) error { + s := load() + if s.Versions == nil { + s.Versions = make(map[string]versionState) + } + v := s.Versions[version] + v.OnboardingDone = true + s.Versions[version] = v + return save(s) +} + +// IsVersionSkipped reports whether the user has chosen to skip upgrading to version. +func IsVersionSkipped(version string) bool { + for _, v := range load().SkippedUpgrades { + if v == version { + return true + } + } + return false +} + +// MarkVersionSkipped records that the user skipped upgrading to version. +func MarkVersionSkipped(version string) error { + s := load() + for _, v := range s.SkippedUpgrades { + if v == version { + return nil + } + } + s.SkippedUpgrades = append(s.SkippedUpgrades, version) + return save(s) +} diff --git a/tools/cli/internal/services/config/config_test.go b/tools/cli/internal/services/config/config_test.go new file mode 100644 index 0000000000..8673a8c0ec --- /dev/null +++ b/tools/cli/internal/services/config/config_test.go @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package config_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/thunder-id/thunderid/tools/cli/internal/services/config" +) + +// redirectState points the state file to a temp directory for test isolation. +func redirectState(t *testing.T) { + t.Helper() + tmp := t.TempDir() + t.Setenv("HOME", tmp) +} + +func TestReadActiveVersion_EmptyOnFirstRun(t *testing.T) { + redirectState(t) + assert.Empty(t, config.ReadActiveVersion()) +} + +func TestWriteAndReadActiveVersion(t *testing.T) { + redirectState(t) + require.NoError(t, config.WriteActiveVersion("1.2.3")) + assert.Equal(t, "1.2.3", config.ReadActiveVersion()) +} + +func TestIsSetupComplete_FalseByDefault(t *testing.T) { + redirectState(t) + assert.False(t, config.IsSetupComplete("1.2.3")) +} + +func TestMarkSetupComplete_RoundTrip(t *testing.T) { + redirectState(t) + require.NoError(t, config.MarkSetupComplete("1.2.3")) + assert.True(t, config.IsSetupComplete("1.2.3")) + assert.False(t, config.IsSetupComplete("9.9.9")) // unrelated version unchanged +} + +func TestIsOnboardingDone_FalseByDefault(t *testing.T) { + redirectState(t) + assert.False(t, config.IsOnboardingDone("1.2.3")) +} + +func TestMarkOnboardingDone_RoundTrip(t *testing.T) { + redirectState(t) + require.NoError(t, config.MarkOnboardingDone("1.2.3")) + assert.True(t, config.IsOnboardingDone("1.2.3")) +} + +func TestIsVersionSkipped_FalseByDefault(t *testing.T) { + redirectState(t) + assert.False(t, config.IsVersionSkipped("2.0.0")) +} + +func TestMarkVersionSkipped_RoundTrip(t *testing.T) { + redirectState(t) + require.NoError(t, config.MarkVersionSkipped("2.0.0")) + assert.True(t, config.IsVersionSkipped("2.0.0")) + assert.False(t, config.IsVersionSkipped("3.0.0")) +} + +func TestMarkVersionSkipped_Idempotent(t *testing.T) { + redirectState(t) + require.NoError(t, config.MarkVersionSkipped("2.0.0")) + require.NoError(t, config.MarkVersionSkipped("2.0.0")) // second call must not error or duplicate + assert.True(t, config.IsVersionSkipped("2.0.0")) +} + +func TestStateDir_UnderHome(t *testing.T) { + redirectState(t) + dir := config.StateDir() + home, _ := os.UserHomeDir() + assert.Equal(t, filepath.Join(home, ".thunderid"), dir) +} diff --git a/tools/cli/internal/services/health/health.go b/tools/cli/internal/services/health/health.go new file mode 100644 index 0000000000..7a67907c81 --- /dev/null +++ b/tools/cli/internal/services/health/health.go @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Package health polls the ThunderID readiness endpoint and provides browser-launch helpers. +package health + +import ( + "crypto/tls" + "fmt" + "net/http" + "time" +) + +// DefaultPort is the port ThunderID listens on by default. +const DefaultPort = 8090 + +// ResolveBaseURL polls until Thunder responds on https or http, returning the +// confirmed base URL and true. Returns ("", false) if neither scheme responds +// within timeout. Each individual probe is capped to min(2s, remaining budget) +// so the function never overruns its deadline. +func ResolveBaseURL(port int, timeout time.Duration) (string, bool) { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + for _, scheme := range []string{"https", "http"} { + remaining := time.Until(deadline) + if remaining <= 0 { + return "", false + } + probeTimeout := remaining + if probeTimeout > 2*time.Second { + probeTimeout = 2 * time.Second + } + base := fmt.Sprintf("%s://localhost:%d", scheme, port) + if checkReadyIn(base, probeTimeout) { + return base, true + } + } + time.Sleep(500 * time.Millisecond) + } + return "", false +} + +// CheckReady returns true if Thunder is responding on the readiness endpoint. +func CheckReady(baseURL string) bool { + return checkReadyIn(baseURL, 2*time.Second) +} + +func checkReadyIn(baseURL string, timeout time.Duration) bool { + client := &http.Client{ + Timeout: timeout, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec + }, + } + resp, err := client.Get(baseURL + "/health/readiness") + if err != nil { + return false + } + defer func() { _ = resp.Body.Close() }() + return resp.StatusCode == http.StatusOK +} diff --git a/tools/cli/internal/services/health/health_test.go b/tools/cli/internal/services/health/health_test.go new file mode 100644 index 0000000000..1218debd97 --- /dev/null +++ b/tools/cli/internal/services/health/health_test.go @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package health_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/thunder-id/thunderid/tools/cli/internal/services/health" +) + +func TestDefaultPort(t *testing.T) { + assert.Equal(t, 8090, health.DefaultPort) +} + +func TestCheckReady_ReturnsTrueOn200(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/health/readiness" { + w.WriteHeader(http.StatusOK) + } else { + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + assert.True(t, health.CheckReady(srv.URL)) +} + +func TestCheckReady_ReturnsFalseOn503(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + })) + defer srv.Close() + + assert.False(t, health.CheckReady(srv.URL)) +} + +func TestCheckReady_ReturnsFalseOnUnreachable(t *testing.T) { + assert.False(t, health.CheckReady("http://127.0.0.1:19999")) +} + +func TestResolveBaseURL_TimesOutWhenNotReady(t *testing.T) { + url, ok := health.ResolveBaseURL(19998, 200*time.Millisecond) + assert.False(t, ok) + assert.Empty(t, url) +} + +func TestResolveBaseURL_ReturnsURLWhenReady(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/health/readiness") { + w.WriteHeader(http.StatusOK) + } + })) + defer srv.Close() + + // Extract port from test server URL. + // ResolveBaseURL dials localhost: not the test server directly, so + // we verify CheckReady behavior via the lower-level helper instead. + assert.True(t, health.CheckReady(srv.URL)) +} diff --git a/tools/cli/internal/services/release/release.go b/tools/cli/internal/services/release/release.go new file mode 100644 index 0000000000..0248e263e8 --- /dev/null +++ b/tools/cli/internal/services/release/release.go @@ -0,0 +1,353 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Package release fetches release metadata and downloads product and sample binaries. +package release + +import ( + "archive/zip" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/thunder-id/thunderid/tools/cli/internal/product" +) + +var platformMap = map[string]string{ + "darwin": "macos", + "linux": "linux", + "windows": "win", +} + +var archMap = map[string]string{ + "amd64": "x64", + "arm64": "arm64", +} + +type releaseAsset struct { + Name string `json:"name"` + DownloadURL string `json:"downloadUrl"` +} + +type releaseEntry struct { + TagName string `json:"tagName"` + IsLatest bool `json:"isLatest"` + Assets []releaseAsset `json:"assets"` +} + +type releasesData struct { + LatestRelease releaseEntry `json:"latestRelease"` + Releases []releaseEntry `json:"releases"` +} + +// PlatformAssetName returns the platform-specific ZIP name for the product binary. +func PlatformAssetName(version string) (string, error) { + platform, ok := platformMap[runtime.GOOS] + if !ok { + return "", fmt.Errorf("unsupported platform: %s", runtime.GOOS) + } + arch, ok := archMap[runtime.GOARCH] + if !ok { + return "", fmt.Errorf("unsupported architecture: %s", runtime.GOARCH) + } + return fmt.Sprintf("%s-%s-%s-%s.zip", product.Slug, version, platform, arch), nil +} + +func fetchJSON(url string, dest any) error { + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return err + } + req.Header.Set("User-Agent", product.Slug+"-cli") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP %d for %s", resp.StatusCode, url) + } + return json.NewDecoder(resp.Body).Decode(dest) +} + +func fetchReleasesData() (*releasesData, error) { + var data releasesData + if err := fetchJSON(product.ReleasesURL, &data); err == nil { + return &data, nil + } + + // Fallback to GitHub API. + var gh struct { + TagName string `json:"tag_name"` + Assets []struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + } `json:"assets"` + } + if err := fetchJSON(product.GitHubAPI, &gh); err != nil { + return nil, err + } + if gh.TagName == "" { + return nil, fmt.Errorf("tag_name missing from GitHub release response") + } + assets := make([]releaseAsset, len(gh.Assets)) + for i, a := range gh.Assets { + assets[i] = releaseAsset{Name: a.Name, DownloadURL: a.BrowserDownloadURL} + } + r := releaseEntry{TagName: gh.TagName, IsLatest: true, Assets: assets} + return &releasesData{LatestRelease: r, Releases: []releaseEntry{r}}, nil +} + +// FetchLatestVersion queries releases metadata and returns the latest version string (no "v" prefix). +func FetchLatestVersion() (string, error) { + data, err := fetchReleasesData() + if err != nil { + return "", err + } + tag := data.LatestRelease.TagName + if tag == "" { + return "", fmt.Errorf("tagName missing from releases data") + } + return strings.TrimPrefix(tag, "v"), nil +} + +// ProgressFunc is called during download/extract operations. +// pct is the percentage (0โ€“100), or -1 for status-only messages (e.g. "Extracting..."). +type ProgressFunc func(pct int, msg string) + +// Download downloads and extracts the product release for the current platform. +func Download(version, destDir string, onProgress ProgressFunc) error { + assetName, err := PlatformAssetName(version) + if err != nil { + return err + } + + data, err := fetchReleasesData() + if err != nil { + return err + } + + found := findAsset(data, version, assetName) + if found == nil { + return fmt.Errorf("no release asset found for %s", assetName) + } + + if onProgress != nil { + onProgress(-1, fmt.Sprintf("Downloading Thunder v%s for %s/%s", version, runtime.GOOS, runtime.GOARCH)) + } + + zipPath := filepath.Join(os.TempDir(), assetName) + if err := downloadFile(found.DownloadURL, zipPath, func(received, total int64) { + if total > 0 && onProgress != nil { + pct := int(float64(received) / float64(total) * 100) + onProgress(pct, fmt.Sprintf("Downloading Thunder v%s", version)) + } + }); err != nil { + return err + } + defer func() { _ = os.Remove(zipPath) }() + + if onProgress != nil { + onProgress(-1, "Extracting...") + } + return extractZip(zipPath, destDir) +} + +// SampleAssetName returns the ZIP name for a sample app. +// Pattern: sample-app-{name}-{version}.zip +func SampleAssetName(sampleName, version string) (string, error) { + return fmt.Sprintf("sample-app-%s-%s.zip", sampleName, version), nil +} + +// DownloadSample downloads and extracts the named sample to destDir. +func DownloadSample(sampleName, version, destDir string, onProgress ProgressFunc) error { + assetName, err := SampleAssetName(sampleName, version) + if err != nil { + return err + } + + data, err := fetchReleasesData() + if err != nil { + return err + } + + found := findAsset(data, version, assetName) + if found == nil { + return fmt.Errorf("no release asset found for %s", assetName) + } + + if onProgress != nil { + onProgress(-1, fmt.Sprintf("Downloading %s sample v%s", sampleName, version)) + } + + zipPath := filepath.Join(os.TempDir(), assetName) + if err := downloadFile(found.DownloadURL, zipPath, func(received, total int64) { + if total > 0 && onProgress != nil { + pct := int(float64(received) / float64(total) * 100) + onProgress(pct, fmt.Sprintf("Downloading %s sample", sampleName)) + } + }); err != nil { + return err + } + defer func() { _ = os.Remove(zipPath) }() + + if onProgress != nil { + onProgress(-1, "Extracting...") + } + return extractZip(zipPath, destDir) +} + +func findAsset(data *releasesData, version, assetName string) *releaseAsset { + for _, r := range data.Releases { + if r.TagName == "v"+version { + for i := range r.Assets { + if r.Assets[i].Name == assetName { + return &r.Assets[i] + } + } + } + } + for i := range data.LatestRelease.Assets { + if data.LatestRelease.Assets[i].Name == assetName { + return &data.LatestRelease.Assets[i] + } + } + return nil +} + +func downloadFile(url, destPath string, onProgress func(received, total int64)) error { + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return err + } + req.Header.Set("User-Agent", product.Slug+"-cli") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP %d downloading %s", resp.StatusCode, url) + } + + f, err := os.Create(destPath) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + + total := resp.ContentLength + var received int64 + buf := make([]byte, 32*1024) + for { + n, err := resp.Body.Read(buf) + if n > 0 { + if _, werr := f.Write(buf[:n]); werr != nil { + return werr + } + received += int64(n) + if onProgress != nil { + onProgress(received, total) + } + } + if err == io.EOF { + break + } + if err != nil { + return err + } + } + return nil +} + +func extractZip(zipPath, destDir string) error { + if err := os.MkdirAll(destDir, 0o755); err != nil { + return err + } + + r, err := zip.OpenReader(zipPath) + if err != nil { + return err + } + defer func() { _ = r.Close() }() + + // Determine the top-level directory prefix to strip. + var prefix string + for _, f := range r.File { + if f.FileInfo().IsDir() { + prefix = f.Name + break + } + } + + cleanDest := filepath.Clean(destDir) + string(os.PathSeparator) + + for _, f := range r.File { + name := strings.TrimPrefix(f.Name, prefix) + if name == "" { + continue + } + + target := filepath.Join(destDir, filepath.Clean(name)) + + // Guard against zip-slip. + if !strings.HasPrefix(target, cleanDest) { + continue + } + + if f.FileInfo().IsDir() { + os.MkdirAll(target, f.Mode()) //nolint:errcheck + continue + } + + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + + out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, f.Mode()) + if err != nil { + return err + } + + rc, err := f.Open() + if err != nil { + _ = out.Close() + return err + } + + _, copyErr := io.Copy(out, rc) //nolint:gosec + if err := rc.Close(); err != nil && copyErr == nil { + copyErr = err + } + if err := out.Close(); err != nil && copyErr == nil { + copyErr = err + } + if copyErr != nil { + return copyErr + } + } + return nil +} diff --git a/tools/cli/internal/services/release/release_test.go b/tools/cli/internal/services/release/release_test.go new file mode 100644 index 0000000000..684a14ce8e --- /dev/null +++ b/tools/cli/internal/services/release/release_test.go @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package release_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/thunder-id/thunderid/tools/cli/internal/services/release" +) + +func TestPlatformAssetName_Format(t *testing.T) { + name, err := release.PlatformAssetName("1.2.3") + require.NoError(t, err) + assert.True(t, strings.HasPrefix(name, "thunderid-1.2.3-"), "got %q", name) + assert.True(t, strings.HasSuffix(name, ".zip"), "got %q", name) +} + +func TestPlatformAssetName_ContainsPlatformAndArch(t *testing.T) { + name, err := release.PlatformAssetName("0.5.0") + require.NoError(t, err) + + // Must contain one of the known platform names. + platforms := []string{"macos", "linux", "win"} + found := false + for _, p := range platforms { + if strings.Contains(name, p) { + found = true + break + } + } + assert.True(t, found, "expected a known platform in %q", name) + + // Must contain one of the known arch names. + arches := []string{"x64", "arm64"} + found = false + for _, a := range arches { + if strings.Contains(name, a) { + found = true + break + } + } + assert.True(t, found, "expected a known arch in %q", name) +} + +func TestSampleAssetName_Format(t *testing.T) { + name, err := release.SampleAssetName("wayfinder", "1.2.3") + require.NoError(t, err) + assert.Equal(t, "sample-app-wayfinder-1.2.3.zip", name) +} + +func TestSampleAssetName_DifferentSamples(t *testing.T) { + a, errA := release.SampleAssetName("wayfinder", "1.0.0") + b, errB := release.SampleAssetName("agentid", "1.0.0") + require.NoError(t, errA) + require.NoError(t, errB) + assert.NotEqual(t, a, b, "different sample names should produce different asset names") +} diff --git a/tools/cli/internal/services/setup/setup.go b/tools/cli/internal/services/setup/setup.go new file mode 100644 index 0000000000..a135d9cee0 --- /dev/null +++ b/tools/cli/internal/services/setup/setup.go @@ -0,0 +1,316 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Package setup runs the ThunderID setup script and manages the background server process. +package setup + +import ( + "bytes" + "fmt" + "io" + "net" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "syscall" + "time" + + "github.com/thunder-id/thunderid/tools/cli/internal/product" +) + +// LogDir returns the directory where Thunder background logs are written +// (e.g. ./thunderid/v0.41.0/logs/). +func LogDir(installPath string) string { + return filepath.Join(installPath, "logs") +} + +// LogFile returns the dated log file path for the current day +// (e.g. ./thunderid/v0.41.0/logs/thunderid-2026-06-05.log). +func LogFile(installPath string) string { + return filepath.Join(LogDir(installPath), product.Slug+"-"+time.Now().Format("2006-01-02")+".log") +} + +// pruneOldLogs removes log files older than 7 days from LogDir. +func pruneOldLogs(installPath string) { + dir := LogDir(installPath) + entries, err := os.ReadDir(dir) + if err != nil { + return + } + cutoff := time.Now().AddDate(0, 0, -7) + for _, e := range entries { + if e.IsDir() { + continue + } + info, err := e.Info() + if err != nil { + continue + } + if info.ModTime().Before(cutoff) { + os.Remove(filepath.Join(dir, e.Name())) //nolint:errcheck + } + } +} + +func isWindows() bool { + return runtime.GOOS == "windows" +} + +func findScript(installPath, name string) string { + root := filepath.Join(installPath, name) + if _, err := os.Stat(root); err == nil { + return root + } + entries, err := os.ReadDir(installPath) + if err != nil { + return "" + } + for _, e := range entries { + if !e.IsDir() { + continue + } + nested := filepath.Join(installPath, e.Name(), name) + if _, err := os.Stat(nested); err == nil { + return nested + } + } + return "" +} + +// FindThunderRoot returns the directory containing the setup script. +func FindThunderRoot(installPath string) (string, error) { + scriptName := "setup.sh" + if isWindows() { + scriptName = "setup.ps1" + } + script := findScript(installPath, scriptName) + if script == "" { + return "", fmt.Errorf("setup script not found in %s", installPath) + } + return filepath.Dir(script), nil +} + +// RunSetup executes the platform setup script non-interactively on the default port. +func RunSetup(installPath string, verbose bool) error { + return RunSetupOnPort(installPath, verbose, 0) +} + +// RunSetupOnPort executes the platform setup script with an optional custom port. +// Pass port=0 to use the default. +func RunSetupOnPort(installPath string, verbose bool, port int) error { + root, err := FindThunderRoot(installPath) + if err != nil { + return err + } + + var cmd *exec.Cmd + if isWindows() { + cmd = exec.Command("powershell.exe", "-ExecutionPolicy", "Bypass", "-File", "setup.ps1") + } else { + cmd = exec.Command("bash", "setup.sh") + } + cmd.Dir = root + adminUser := os.Getenv("THUNDER_ADMIN_USERNAME") + if adminUser == "" { + adminUser = "admin" + } + adminPass := os.Getenv("THUNDER_ADMIN_PASSWORD") + if adminPass == "" { + adminPass = "admin" + } + env := append(os.Environ(), + "ADMIN_USERNAME="+adminUser, + "ADMIN_PASSWORD="+adminPass, + "THUNDER_SKIP_SECURITY=true", + ) + if port > 0 { + env = append(env, fmt.Sprintf("THUNDER_PORT=%d", port)) + } + cmd.Env = env + cmd.Stdin = nil // no stdin โ†’ prevents any remaining interactive prompts + + if verbose { + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() + } + + // Non-verbose: capture stdout+stderr so we can surface them on failure. + var outBuf, errBuf bytes.Buffer + cmd.Stdout = &outBuf + cmd.Stderr = &errBuf + if err := cmd.Run(); err != nil { + detail := strings.TrimSpace(errBuf.String() + "\n" + outBuf.String()) + detail = strings.TrimSpace(detail) + if detail != "" { + return fmt.Errorf("%w\n\n%s", err, detail) + } + return fmt.Errorf("%w\n\nRun with --verbose for full setup output", err) + } + return nil +} + +// StartBackground starts Thunder detached from the terminal on the default port. +func StartBackground(installPath string, verbose bool) (*exec.Cmd, error) { + return StartBackgroundOnPort(installPath, verbose, 0) +} + +// StartBackgroundOnPort starts Thunder detached from the terminal with an optional custom port. +// Pass port=0 to use the default. Logs go to the state directory. +// The returned *exec.Cmd has already been started; call cmd.Process.Kill() to stop it. +func StartBackgroundOnPort(installPath string, verbose bool, port int) (*exec.Cmd, error) { + root, err := FindThunderRoot(installPath) + if err != nil { + return nil, err + } + + os.MkdirAll(LogDir(installPath), 0o755) //nolint:errcheck + pruneOldLogs(installPath) + out, err := os.OpenFile(LogFile(installPath), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) + if err != nil { + out, _ = os.OpenFile(os.DevNull, os.O_WRONLY, 0) + } + + var cmd *exec.Cmd + if isWindows() { + startPs1 := filepath.Join(root, "start.ps1") + if _, err := os.Stat(startPs1); err == nil { + cmd = exec.Command("powershell.exe", "-ExecutionPolicy", "Bypass", "-File", "start.ps1") + } else { + binary := filepath.Join(root, product.Slug+".exe") + if _, err := os.Stat(binary); err != nil { + return nil, fmt.Errorf("no start.ps1 or %s.exe found in %s", product.Slug, root) + } + cmd = exec.Command(binary) + } + } else { + startSh := filepath.Join(root, "start.sh") + if _, err := os.Stat(startSh); err == nil { + cmd = exec.Command("bash", "start.sh") + } else { + binary := filepath.Join(root, "thunder") + if _, err := os.Stat(binary); err != nil { + return nil, fmt.Errorf("no start.sh or thunder binary found in %s", root) + } + cmd = exec.Command(binary) + } + } + + cmd.Dir = root + if port > 0 { + cmd.Env = append(os.Environ(), fmt.Sprintf("THUNDER_PORT=%d", port)) + } + if verbose { + cmd.Stdout = io.MultiWriter(out, os.Stderr) + cmd.Stderr = io.MultiWriter(out, os.Stderr) + } else { + cmd.Stdout = out + cmd.Stderr = out + } + cmd.Stdin = nil + + if err := cmd.Start(); err != nil { + return nil, err + } + return cmd, nil +} + +// Start finds and runs the Thunder start script or binary with inherited stdio. +func Start(installPath string, args []string) error { + root, err := FindThunderRoot(installPath) + if err != nil { + return err + } + + var cmd *exec.Cmd + + if isWindows() { + startPs1 := filepath.Join(root, "start.ps1") + if _, err := os.Stat(startPs1); err == nil { + cmd = exec.Command("powershell.exe", append([]string{"-ExecutionPolicy", "Bypass", "-File", "start.ps1"}, args...)...) + cmd.Dir = root + } else { + binary := filepath.Join(root, product.Slug+".exe") + if _, err := os.Stat(binary); err != nil { + return fmt.Errorf("no start.ps1 or %s.exe found in %s", product.Slug, root) + } + cmd = exec.Command(binary, args...) + cmd.Dir = root + } + } else { + startSh := filepath.Join(root, "start.sh") + if _, err := os.Stat(startSh); err == nil { + cmd = exec.Command("bash", append([]string{"start.sh"}, args...)...) + cmd.Dir = root + } else { + binary := filepath.Join(root, "thunder") + if _, err := os.Stat(binary); err != nil { + return fmt.Errorf("no start.sh or thunder binary found in %s", root) + } + cmd = exec.Command(binary, args...) + cmd.Dir = root + } + } + + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// WaitForPortFree blocks until no process is accepting connections on the given TCP port, +// or until timeout elapses. Returns true if the port became free, false if it timed out. +func WaitForPortFree(port int, timeout time.Duration) bool { + deadline := time.Now().Add(timeout) + addr := fmt.Sprintf("localhost:%d", port) + for time.Now().Before(deadline) { + conn, err := net.DialTimeout("tcp", addr, 200*time.Millisecond) + if err != nil { + return true + } + _ = conn.Close() + time.Sleep(250 * time.Millisecond) + } + return false +} + +// KillPort sends SIGTERM to all processes listening on the given TCP port. +func KillPort(port int) { + if runtime.GOOS == "windows" { + _ = exec.Command("cmd", "/c", + fmt.Sprintf("for /f \"tokens=5\" %%a in ('netstat -aon ^| findstr :%d') do taskkill /f /pid %%a", port), + ).Run() + return + } + cmd := exec.Command("lsof", "-ti", fmt.Sprintf("tcp:%d", port)) + out, err := cmd.Output() + if err != nil { + return + } + for _, pidStr := range strings.Fields(string(out)) { + pid, err := strconv.Atoi(strings.TrimSpace(pidStr)) + if err != nil || pid <= 0 { + continue + } + if p, err := os.FindProcess(pid); err == nil { + p.Signal(syscall.SIGTERM) //nolint:errcheck + } + } +} diff --git a/tools/cli/internal/services/setup/setup_test.go b/tools/cli/internal/services/setup/setup_test.go new file mode 100644 index 0000000000..176ae10604 --- /dev/null +++ b/tools/cli/internal/services/setup/setup_test.go @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package setup_test + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/thunder-id/thunderid/tools/cli/internal/services/setup" +) + +// setupScript returns the platform-appropriate setup script filename. +func setupScript() string { + if runtime.GOOS == "windows" { + return "setup.ps1" + } + return "setup.sh" +} + +func TestLogDir(t *testing.T) { + base := t.TempDir() + dir := setup.LogDir(base) + assert.Equal(t, filepath.Join(base, "logs"), dir) +} + +func TestLogFile_UnderLogDir(t *testing.T) { + installPath := t.TempDir() + f := setup.LogFile(installPath) + assert.True(t, strings.HasPrefix(f, setup.LogDir(installPath)+string(os.PathSeparator)), + "LogFile should be inside LogDir, got %q", f) +} + +func TestLogFile_ContainsDate(t *testing.T) { + f := setup.LogFile("/tmp/test") + today := time.Now().Format("2006-01-02") + assert.Contains(t, f, today, "log file name should contain today's date") +} + +func TestLogFile_HasLogExtension(t *testing.T) { + f := setup.LogFile("/tmp/test") + assert.True(t, strings.HasSuffix(f, ".log"), "expected .log suffix, got %q", f) +} + +func TestFindThunderRoot_ScriptAtRoot(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, setupScript()), []byte(""), 0o644)) + + root, err := setup.FindThunderRoot(dir) + require.NoError(t, err) + assert.Equal(t, dir, root) +} + +func TestFindThunderRoot_ScriptInSubdir(t *testing.T) { + dir := t.TempDir() + sub := filepath.Join(dir, "inner") + require.NoError(t, os.MkdirAll(sub, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(sub, setupScript()), []byte(""), 0o644)) + + root, err := setup.FindThunderRoot(dir) + require.NoError(t, err) + assert.Equal(t, sub, root) +} + +func TestFindThunderRoot_Missing(t *testing.T) { + dir := t.TempDir() + _, err := setup.FindThunderRoot(dir) + require.Error(t, err) + assert.Contains(t, err.Error(), "setup script not found") +} + +func TestWaitForPortFree_UnoccupiedPort(t *testing.T) { + // Port 19999 is very unlikely to be in use; if nothing is listening, the + // function should detect a free port on the first probe and return true. + free := setup.WaitForPortFree(19999, 2*time.Second) + assert.True(t, free, "expected unoccupied port to be detected as free") +} diff --git a/tools/cli/internal/ui/banner.go b/tools/cli/internal/ui/banner.go new file mode 100644 index 0000000000..29a2840e53 --- /dev/null +++ b/tools/cli/internal/ui/banner.go @@ -0,0 +1,205 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Package ui provides terminal rendering helpers: banner, styled messages, and interactive prompts. +package ui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" + + "github.com/thunder-id/thunderid/tools/cli/internal/product" +) + +const ( + colorBrandBlue = product.ColorElectricBlue + colorGrey = "#808080" + colorGreen = "#22C55E" + colorRed = "#EF4444" + colorCyan = "#06B6D4" + colorYellow = "#EAB308" +) + +// CyanStyle renders text in cyan. +var CyanStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorCyan)) + +// YellowStyle renders text in yellow. +var YellowStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorYellow)) + +var ( + brandStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorBrandBlue)) + greyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorGrey)) + greenStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorGreen)) + redStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorRed)) + boldStyle = lipgloss.NewStyle().Bold(true) + + introBoxStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color(colorBrandBlue)). + Padding(1, 4) + + noteBoxStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color(colorGrey)). + Padding(0, 1) + + successBoxStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color(colorGreen)). + Padding(0, 1) + + errorBoxStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color(colorRed)). + Padding(0, 1) +) + +var thunderLines = []string{ + ` _____ _ _ `, + `|_ _| | | | `, + ` | | | |__ _ _ _ __ __| | ___ _ __ `, + ` | | | '_ \| | | | '_ \ / _` + "`" + ` |/ _ \ '__|`, + ` | | | | | | |_| | | | | (_| | __/ | `, + ` \_/ |_| |_|\__,_|_| |_|\__,_|\___|_| `, +} + +var idLines = []string{ + ` ___________ `, + `|_ _| _ \`, + ` | | | | | |`, + ` | | | | | |`, + ` _| |_| |/ / `, + ` \___/|___/ `, +} + +// BannerString returns the styled ASCII art banner as a string. +func BannerString() string { + logoWidth := 2 + len(thunderLines[0]) + len(idLines[0]) + + var lines []string + for i, t := range thunderLines { + line := " " + brandStyle.Render(t) + greyStyle.Render(idLines[i]) + lines = append(lines, line) + } + banner := strings.Join(lines, "\n") + + slogan := lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorGrey)). + Width(logoWidth). + Align(lipgloss.Center). + Render("Auth for the Modern Dev") + + return introBoxStyle.Render(banner + "\n\n" + slogan) +} + +// PrintBanner writes the styled banner to stdout. +func PrintBanner() { + fmt.Println(BannerString()) +} + +// Note prints a bordered note box with title and body. +func Note(title, body string) { + content := boldStyle.Render(title) + "\n" + greyStyle.Render(body) + fmt.Println(noteBoxStyle.Render(content)) +} + +// Success prints a green success box. +func Success(msg string) { + fmt.Println(successBoxStyle.Render(greenStyle.Render("โœ“ ") + msg)) +} + +// Outro prints a dimmed note box. +func Outro(msg string) { + fmt.Println(noteBoxStyle.Render(greyStyle.Render(msg))) +} + +// Warn prints a yellow warning box. +func Warn(msg string) { + fmt.Println(noteBoxStyle.Render(YellowStyle.Render("โš  " + msg))) +} + +// Fatal prints a red error box. +func Fatal(msg string) { + fmt.Println(errorBoxStyle.Render(redStyle.Render("โœ— ") + msg)) +} + +// Bold returns a bold-rendered string. +func Bold(s string) string { + return boldStyle.Render(s) +} + +// Dim returns a grey-rendered string. +func Dim(s string) string { + return greyStyle.Render(s) +} + +// Cyan returns a cyan-rendered string. +func Cyan(s string) string { + return CyanStyle.Render(s) +} + +// Green returns a green-rendered string. +func Green(s string) string { + return greenStyle.Render(s) +} + +// Yellow returns a yellow-rendered string. +func Yellow(s string) string { + return YellowStyle.Render(s) +} + +// Red returns a red-rendered string. +func Red(s string) string { + return redStyle.Render(s) +} + +// UpgradeChoice represents the user's response to the upgrade prompt. +type UpgradeChoice int + +const ( + // UpgradeNow instructs the CLI to download and apply the upgrade immediately. + UpgradeNow UpgradeChoice = iota + // StartCurrent skips the upgrade and starts the currently installed version. + StartCurrent + // SkipRelease marks the new version as skipped and starts the current version. + SkipRelease +) + +// PromptUpgrade shows the "new version available" banner and asks the user what to do. +// Returns the chosen action, or StartCurrent if the prompt is cancelled. +func PromptUpgrade(currentVersion, newVersion string) UpgradeChoice { + title := YellowStyle.Render("โœฆ " + product.Name + " v" + newVersion + " is available") + body := greyStyle.Render("You have v" + currentVersion + " installed.\nUpgrade for the latest features and security fixes.") + fmt.Println(noteBoxStyle.Render(title + "\n\n" + body)) + + var choice UpgradeChoice + if err := huh.NewSelect[UpgradeChoice](). + Title("What would you like to do?"). + Options( + huh.NewOption("Upgrade now", UpgradeNow), + huh.NewOption("Start v"+currentVersion+" (upgrade later)", StartCurrent), + huh.NewOption("Skip v"+newVersion, SkipRelease), + ). + Value(&choice). + Run(); err != nil { + return StartCurrent + } + return choice +} diff --git a/tools/cli/internal/ui/onboarding.go b/tools/cli/internal/ui/onboarding.go new file mode 100644 index 0000000000..bbe2ff6106 --- /dev/null +++ b/tools/cli/internal/ui/onboarding.go @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package ui + +import ( + "fmt" + "io" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/thunder-id/thunderid/tools/cli/internal/commands/sample" + "github.com/thunder-id/thunderid/tools/cli/internal/services/config" +) + +// onboardingItem is a single entry in the first-run picker. +type onboardingItem struct { + emoji string + title string + description string + sampleName string + comingSoon bool + requiredConfigs []ConfigInput + sampleEnvTarget string + sampleFeatures []string +} + +func (i onboardingItem) FilterValue() string { return "" } + +// onboardingDelegate renders two-line items; coming-soon items are dimmed. +type onboardingDelegate struct{} + +func (d onboardingDelegate) Height() int { return 2 } +func (d onboardingDelegate) Spacing() int { return 1 } +func (d onboardingDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } + +func (d onboardingDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) { + i, ok := item.(onboardingItem) + if !ok { + return + } + + isSelected := index == m.Index() + + if i.comingSoon { + fmt.Fprintln(w, " "+Dim(i.emoji+" "+i.title)+" "+Dim("ยท Coming Soon")) //nolint:errcheck + fmt.Fprint(w, " "+Dim(i.description)) //nolint:errcheck + return + } + + if isSelected { + //nolint:errcheck + fmt.Fprintln(w, " "+brandStyle.Render("โฏ ")+Bold(brandStyle.Render(i.emoji+" "+i.title))) + fmt.Fprint(w, " "+i.description) //nolint:errcheck + } else { + fmt.Fprintln(w, " "+i.emoji+" "+i.title) //nolint:errcheck + fmt.Fprint(w, " "+Dim(i.description)) //nolint:errcheck + } +} + +const onboardingListHeight = 18 + +func newOnboardingList(width int) list.Model { + var items []list.Item + for _, u := range Usecases { + items = append(items, onboardingItem{ + emoji: u.Emoji, + title: u.Title, + description: u.Description, + sampleName: u.SampleName, + comingSoon: u.ComingSoon, + requiredConfigs: u.RequiredConfigs, + sampleEnvTarget: u.SampleEnvTarget, + sampleFeatures: u.SampleFeatures, + }) + } + + l := list.New(items, onboardingDelegate{}, width, onboardingListHeight) + l.Title = "What would you like to try?" + l.SetShowStatusBar(false) + l.SetFilteringEnabled(false) + l.SetShowHelp(false) // we render a custom hint line below the list + l.DisableQuitKeybindings() + l.Styles.Title = lipgloss.NewStyle().Bold(true).MarginLeft(2) + l.Styles.TitleBar = lipgloss.NewStyle().Padding(0, 0, 1, 0) + return l +} + +// selectOnboarding is called when the user presses Enter in the picker. +// Coming-soon items are silently ignored; available items either trigger their +// sample directly or enter the generic config-collection flow first. +func (m *ReplModel) selectOnboarding() tea.Cmd { + item, ok := m.onboardingList.SelectedItem().(onboardingItem) + if !ok || item.comingSoon { + return nil + } + + _ = config.MarkOnboardingDone(m.version) + m.showOnboarding = false + m.messages = nil // clear startup messages; sample progress starts on a clean slate + + if item.sampleName == "" { + m.input.Focus() + m.input.Placeholder = "Type / for commands, Ctrl+C to exit" + m.messages = append(m.messages, Yellow("โณ")+" "+Bold(item.title)+" sample is coming soon.") + return nil + } + + if len(item.requiredConfigs) > 0 { + return func() tea.Msg { + return usecaseConfigRequestMsg{ + sampleName: item.sampleName, + inputs: item.requiredConfigs, + envTarget: item.sampleEnvTarget, + features: item.sampleFeatures, + } + } + } + + m.tryingOut = true + m.input.Blur() + return makeTryCmd(item.sampleName, m.installPath, m.verbose, sample.Options{}) +} diff --git a/tools/cli/internal/ui/repl.go b/tools/cli/internal/ui/repl.go new file mode 100644 index 0000000000..e26d7f851b --- /dev/null +++ b/tools/cli/internal/ui/repl.go @@ -0,0 +1,1197 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package ui + +import ( + "fmt" + "io" + "os" + "os/exec" + "strings" + "syscall" + "time" + + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/thunder-id/thunderid/tools/cli/internal/commands/sample" + "github.com/thunder-id/thunderid/tools/cli/internal/product" + "github.com/thunder-id/thunderid/tools/cli/internal/services/health" + "github.com/thunder-id/thunderid/tools/cli/internal/services/setup" + "github.com/thunder-id/thunderid/tools/cli/internal/utils" +) + +// SlashCommand represents a / command available in the REPL. +// Action (sync) or AsyncAction (async tea.Cmd) handles execution; AsyncAction takes priority. +type SlashCommand struct { + Name string + Description string + Section string // category label; same value = same group in the completion list + ComingSoon bool + Action func(baseURL string) ([]string, error) + AsyncAction func(baseURL string) tea.Cmd +} + +var defaultCommands = []SlashCommand{ + { + Name: "/open-console", + Description: "Open the Console in your browser", + Action: func(baseURL string) ([]string, error) { + url := baseURL + "/console" + if err := utils.OpenBrowser(url); err != nil { + return nil, err + } + return []string{Dim("Opening " + url + "...")}, nil + }, + }, + { + Name: "/status", + Description: "Show server status", + Action: func(baseURL string) ([]string, error) { + if health.CheckReady(baseURL) { + return []string{Green("โ—") + " " + product.Name + " is running at " + Cyan(baseURL)}, nil + } + return []string{Yellow("โ—‹") + " " + product.Name + " is not responding"}, nil + }, + }, + { + Name: "/upgrade", + Description: "Upgrade " + product.Name + " to the latest version", + AsyncAction: func(_ string) tea.Cmd { + return func() tea.Msg { return upgradeMsg{} } + }, + }, + { + Name: "/stop", + Description: "Stop " + product.Name + " and exit", + Action: nil, // handled specially in Update + }, +} + +// --- bubbletea messages --- + +type healthCheckMsg struct{ ready bool } +type cutoverMsg struct{} +type upgradeMsg struct{} +type thunderExitedMsg struct { + err error + pid int // PID of the process that exited โ€” used to ignore stale watches +} + +// sampleStartedMsg is sent immediately when a try-* command begins. +// It carries the live channels so the model can stream progress. +type sampleStartedMsg struct { + sampleName string + progressCh <-chan sample.ProgressEvent + resultCh <-chan sample.Result +} + +// sampleProgressMsg carries a single progress event from an async try-* operation. +type sampleProgressMsg struct { + line string + overwrite bool // when true, drives the bottom-status line instead of appending to messages +} + +// sampleProgressDoneMsg is sent when the progress channel closes (no more lines). +type sampleProgressDoneMsg struct{} + +// sampleDoneMsg signals that the try-* operation completed successfully. +type sampleDoneMsg struct { + proc *exec.Cmd + sampleName string + sampleURL string + serverURL string // confirmed-ready base URL from ResolveBaseURL + features []string +} + +// sampleErrMsg signals that the try-* operation failed. +type sampleErrMsg struct{ err error } + +// usecaseConfigRequestMsg is sent when a use case requires additional config before starting. +type usecaseConfigRequestMsg struct { + sampleName string + inputs []ConfigInput + envTarget string + features []string +} + +// walkthroughPane is one tab in the post-sample walkthrough overlay. +type walkthroughPane struct { + Title string + Lines []string // body lines; empty string = blank line + URL string // opened with 'o' +} + +func b2cWalkthroughPanes(sampleURL string) []walkthroughPane { + return []walkthroughPane{ + { + Title: "Log In", + URL: sampleURL, + Lines: []string{ + "Sign in with the demo consumer account.", + "", + " 1 Open the Wayfinder app at " + Cyan(sampleURL), + " 2 Click Sign in and enter:", + "", + " username " + Bold("john.doe"), + " password " + Bold("john.doe"), + }, + }, + { + Title: "Self Sign-Up", + URL: sampleURL, + Lines: []string{ + "Create a new account via self-registration.", + "", + " 1 Open " + Cyan(sampleURL), + " 2 Click Sign in โ†’ Register.", + " 3 Fill in your details and submit.", + }, + }, + { + Title: "View Profile", + URL: sampleURL, + Lines: []string{ + "Explore the user profile page.", + "", + " 1 Sign in as " + Bold("john.doe") + " / " + Bold("john.doe"), + " 2 Click your name in the top-right corner.", + " 3 Select Profile.", + }, + }, + { + Title: "Account Recovery", + URL: sampleURL, + Lines: []string{ + "Trigger the forgot-password flow.", + "", + " 1 Open " + Cyan(sampleURL) + " and click Sign in.", + " 2 Click Forgot password?", + " 3 Enter your email and follow the instructions.", + "", + Dim(" Requires SMTP configured in deployment.yaml."), + }, + }, + { + Title: "Onboard Staff", + URL: sampleURL, + Lines: []string{ + "Admin-invite a new internal user.", + "", + " 1 Sign in as " + Bold("alex.carter") + " / " + Bold("alex.carter") + Dim(" (Admin)"), + " 2 Open the Admin panel.", + " 3 Invite a new user by email.", + }, + }, + } +} + +func agentWalkthroughPanes(sampleURL string) []walkthroughPane { + return []walkthroughPane{ + { + Title: "AI Concierge", + URL: sampleURL, + Lines: []string{ + "Chat with the AI travel concierge.", + "", + " 1 Open the Wayfinder app at " + Cyan(sampleURL), + " 2 Click the chat bubble in the bottom-right corner.", + " 3 Ask about available flights.", + }, + }, + { + Title: "Book via Agent", + URL: sampleURL, + Lines: []string{ + "Let the agent book a flight on your behalf.", + "", + " 1 Open the chat and ask the concierge to book a flight.", + " 2 The agent requests user consent โ€” approve the prompt.", + " 3 The booking is created in your name.", + }, + }, + { + Title: "Agent Identity", + URL: sampleURL + "/signin-as-agent", + Lines: []string{ + "Sign in as the AI agent directly.", + "", + " 1 Open " + Cyan(sampleURL+"/signin-as-agent"), + " 2 The gate shows the Agent ID / Secret form.", + " 3 Enter the agent credentials to authenticate.", + }, + }, + } +} + +// choiceItem wraps a Choice value for use in a bubbletea list. +type choiceItem struct{ choice Choice } + +func (c choiceItem) FilterValue() string { return "" } + +// choiceDelegate renders single-line choice items. +type choiceDelegate struct{} + +func (choiceDelegate) Height() int { return 1 } +func (choiceDelegate) Spacing() int { return 0 } +func (choiceDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } + +func (choiceDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) { + ci, ok := item.(choiceItem) + if !ok { + return + } + if index == m.Index() { + fmt.Fprintln(w, " "+brandStyle.Render("โฏ ")+Bold(ci.choice.Label)) //nolint:errcheck + } else { + fmt.Fprintln(w, " "+Dim(ci.choice.Label)) //nolint:errcheck + } +} + +// --- model --- + +type serverStatus int + +const ( + statusStarting serverStatus = iota + statusReady + statusStopped +) + +// ReplModel is the bubbletea model for the interactive REPL. +type ReplModel struct { + input textinput.Model + spinner spinner.Model + + messages []string + commands []SlashCommand + + status serverStatus + version string + baseURL string + installPath string + verbose bool + + showCompletions bool + completions []SlashCommand + selectedComp int + + proc *exec.Cmd + sampleProgressCh <-chan sample.ProgressEvent + // trySampleStatus holds the current inline-overwrite line (progress bar or + // "Extractingโ€ฆ") shown in the spinner area at the bottom of the REPL while + // a try-* operation is running. + trySampleStatus string + tryingOut bool + quitting bool + width int + + showOnboarding bool + onboardingList list.Model + onboardingCmdMode bool // true while the slash-command input overlay is active + checkPort int // non-zero overrides health.DefaultPort for health checks + cutoverRequested bool // set when the /cutover command is executed + upgradeRequested bool // set when the /upgrade command is executed + newVersion string + + showWalkthrough bool + walkthroughPanes []walkthroughPane + walkthroughTab int + + // Generic use-case config collection โ€” active when showUsecaseConfig is true. + showUsecaseConfig bool + ucInputs []ConfigInput + ucValues map[string]string + ucStep int + ucList list.Model + ucText textinput.Model + ucSampleName string + ucEnvTarget string + ucFeatures []string +} + +// NewReplModel initializes the REPL model. +func NewReplModel(version string, proc *exec.Cmd, installPath string, verbose bool, isFirstRun bool) ReplModel { + ti := textinput.New() + ti.Placeholder = "Starting " + product.Name + "..." + ti.Prompt = "> " + ti.CharLimit = 256 + + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorBrandBlue)) + + var commands []SlashCommand + for _, u := range Usecases { + u := u + ip := installPath + if u.ComingSoon { + commands = append(commands, SlashCommand{ + Name: u.Command, + Description: u.Title + " ยท Coming Soon", + Section: "Try", + ComingSoon: true, + Action: func(_ string) ([]string, error) { + return []string{Yellow("โณ") + " " + Bold(u.Title) + " is coming soon."}, nil + }, + }) + } else if len(u.RequiredConfigs) > 0 { + commands = append(commands, SlashCommand{ + Name: u.Command, + Description: u.Title, + Section: "Try", + AsyncAction: func(_ string) tea.Cmd { + return func() tea.Msg { + return usecaseConfigRequestMsg{ + sampleName: u.SampleName, + inputs: u.RequiredConfigs, + envTarget: u.SampleEnvTarget, + features: u.SampleFeatures, + } + } + }, + }) + } else { + commands = append(commands, SlashCommand{ + Name: u.Command, + Description: u.Title, + Section: "Try", + AsyncAction: func(_ string) tea.Cmd { + return makeTryCmd(u.SampleName, ip, verbose, sample.Options{}) + }, + }) + } + } + logCmd := SlashCommand{ + Name: "/logs", + Description: "Show recent server logs", + Action: func(_ string) ([]string, error) { + logPath := setup.LogFile(installPath) + data, err := os.ReadFile(logPath) + if err != nil { + return nil, fmt.Errorf("could not read logs: %w", err) + } + lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n") + const maxLines = 30 + if len(lines) > maxLines { + lines = lines[len(lines)-maxLines:] + } + out := make([]string, 0, len(lines)+1) + out = append(out, Dim(fmt.Sprintf("โ”€โ”€ last %d lines of %s โ”€โ”€", len(lines), logPath))) + for _, l := range lines { + out = append(out, Dim(l)) + } + return out, nil + }, + } + commands = append(commands, logCmd) + commands = append(commands, defaultCommands...) + + return ReplModel{ + input: ti, + spinner: s, + commands: commands, + version: version, + installPath: installPath, + verbose: verbose, + status: statusStarting, + proc: proc, + width: 80, + showOnboarding: isFirstRun, + onboardingList: newOnboardingList(80), + } +} + +// makeTryCmd starts RunAsync and immediately returns sampleStartedMsg so the +// model can begin streaming progress without blocking the event loop. +func makeTryCmd(sampleName, installPath string, verbose bool, opts sample.Options) tea.Cmd { + return func() tea.Msg { + progressCh, resultCh := sample.RunAsync(sampleName, installPath, verbose, opts) + return sampleStartedMsg{sampleName: sampleName, progressCh: progressCh, resultCh: resultCh} + } +} + +// waitForSampleProgress reads one event from the progress channel. +// Returns sampleProgressMsg, or sampleProgressDoneMsg when the channel closes. +func waitForSampleProgress(ch <-chan sample.ProgressEvent) tea.Cmd { + return func() tea.Msg { + ev, ok := <-ch + if !ok { + return sampleProgressDoneMsg{} + } + return sampleProgressMsg{line: ev.Line, overwrite: ev.Overwrite} + } +} + +// waitForSampleResult blocks until the result channel delivers its single value. +func waitForSampleResult(sampleName string, ch <-chan sample.Result) tea.Cmd { + return func() tea.Msg { + r := <-ch + if r.Err != nil { + return sampleErrMsg{err: r.Err} + } + return sampleDoneMsg{proc: r.Proc, sampleName: sampleName, sampleURL: r.SampleURL, serverURL: r.ServerURL, features: r.Features} + } +} + +func (m ReplModel) effectivePort() int { + if m.checkPort > 0 { + return m.checkPort + } + return health.DefaultPort +} + +// Init implements tea.Model. +func (m ReplModel) Init() tea.Cmd { + p := m.effectivePort() + return tea.Batch( + textinput.Blink, + m.spinner.Tick, + func() tea.Msg { return doHealthCheckOn(p) }, + pollHealthCmdOn(p), + watchProcessCmd(m.proc), + ) +} + +func pollHealthCmdOn(port int) tea.Cmd { + return tea.Tick(time.Second, func(_ time.Time) tea.Msg { + return doHealthCheckOn(port) + }) +} + +func doHealthCheckOn(port int) tea.Msg { + for _, scheme := range []string{"https", "http"} { + base := fmt.Sprintf("%s://localhost:%d", scheme, port) + if health.CheckReady(base) { + return healthCheckMsg{ready: true} + } + } + return healthCheckMsg{ready: false} +} + +func watchProcessCmd(proc *exec.Cmd) tea.Cmd { + if proc == nil || proc.Process == nil { + return nil + } + pid := proc.Process.Pid + return func() tea.Msg { + err := proc.Wait() + return thunderExitedMsg{err: err, pid: pid} + } +} + +// newChoiceList builds a bubbletea list for a set of Choice values. +func newChoiceList(choices []Choice, width int) list.Model { + items := make([]list.Item, len(choices)) + for i, c := range choices { + items[i] = choiceItem{c} + } + height := len(choices)*choiceDelegate{}.Height() + 2 + l := list.New(items, choiceDelegate{}, width, height) + l.SetShowTitle(false) + l.SetShowStatusBar(false) + l.SetFilteringEnabled(false) + l.SetShowHelp(false) + l.DisableQuitKeybindings() + return l +} + +// initUCStep prepares the UI widget for the current config-collection step. +func (m *ReplModel) initUCStep() { + if m.ucStep >= len(m.ucInputs) { + return + } + inp := m.ucInputs[m.ucStep] + if len(inp.Choices) > 0 { + m.ucList = newChoiceList(inp.Choices, m.width) + } else { + ti := textinput.New() + ti.Placeholder = "enter valueโ€ฆ" + ti.Prompt = " > " + ti.CharLimit = 512 + if inp.Secret { + ti.EchoMode = textinput.EchoPassword + } + ti.Focus() + m.ucText = ti + } +} + +// advanceUCStep records value for the current step then moves to the next. +// When all steps are done it clears the config state and returns a makeTryCmd. +func (m *ReplModel) advanceUCStep(value string) tea.Cmd { + m.ucValues[m.ucInputs[m.ucStep].Key] = value + m.ucStep++ + if m.ucStep < len(m.ucInputs) { + m.initUCStep() + return nil + } + m.showUsecaseConfig = false + m.tryingOut = true + m.input.Blur() + opts := sample.Options{ + Config: m.ucValues, + EnvTarget: m.ucEnvTarget, + Features: m.ucFeatures, + } + return makeTryCmd(m.ucSampleName, m.installPath, m.verbose, opts) +} + +// Update implements tea.Model. +func (m ReplModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:cyclop,funlen + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.onboardingList.SetSize(msg.Width, onboardingListHeight) + + case tea.KeyMsg: + if msg.Type == tea.KeyCtrlC { + m.quitting = true + m.killThunder() + return m, tea.Quit + } + + if m.showOnboarding && m.status == statusReady { + if m.onboardingCmdMode { + // โ”€โ”€ Slash-command overlay โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + switch msg.Type { + case tea.KeyEsc: + m.onboardingCmdMode = false + m.input.SetValue("") + m.input.Blur() + m.showCompletions = false + m.selectedComp = 0 + case tea.KeyEnter: + val := strings.TrimSpace(m.input.Value()) + if m.showCompletions && len(m.completions) > 0 { + val = m.completions[m.selectedComp].Name + } + if val != "" { + m.showOnboarding = false + m.onboardingCmdMode = false + m.input.Placeholder = "Type / for commands, Ctrl+C to exit" + m.messages = append(m.messages, "> "+val) + m.input.SetValue("") + m.showCompletions = false + m.selectedComp = 0 + if cmd := m.runCommand(val); cmd != nil { + cmds = append(cmds, cmd) + } + } + case tea.KeyUp: + if m.showCompletions && m.selectedComp > 0 { + m.selectedComp-- + } + case tea.KeyDown: + if m.showCompletions && m.selectedComp < len(m.completions)-1 { + m.selectedComp++ + } + case tea.KeyTab: + if m.showCompletions && len(m.completions) > 0 { + m.input.SetValue(m.completions[m.selectedComp].Name) + m.input.CursorEnd() + } + } + } else { + // โ”€โ”€ Onboarding list navigation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + if msg.Type == tea.KeyEnter { + if cmd := m.selectOnboarding(); cmd != nil { + cmds = append(cmds, cmd) + } + } else if msg.Type == tea.KeyRunes && (msg.String() == "/" || msg.String() == "?") { + m.onboardingCmdMode = true + m.input.Focus() + m.input.SetValue("/") + m.input.CursorEnd() + } else { + prevIdx := m.onboardingList.Index() + var listCmd tea.Cmd + m.onboardingList, listCmd = m.onboardingList.Update(msg) + cmds = append(cmds, listCmd) + if item, ok := m.onboardingList.SelectedItem().(onboardingItem); ok && item.comingSoon { + m.onboardingList.Select(prevIdx) + } + } + } + } else if m.showUsecaseConfig { + // โ”€โ”€ Generic use-case config collection โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + inp := m.ucInputs[m.ucStep] + if len(inp.Choices) > 0 { + switch msg.Type { + case tea.KeyEnter: + if ci, ok := m.ucList.SelectedItem().(choiceItem); ok { + if cmd := m.advanceUCStep(ci.choice.Value); cmd != nil { + cmds = append(cmds, cmd) + } + } + default: + var listCmd tea.Cmd + m.ucList, listCmd = m.ucList.Update(msg) + cmds = append(cmds, listCmd) + } + } else { + switch msg.Type { + case tea.KeyEnter: + if val := strings.TrimSpace(m.ucText.Value()); val != "" { + if cmd := m.advanceUCStep(val); cmd != nil { + cmds = append(cmds, cmd) + } + } + default: + var tiCmd tea.Cmd + m.ucText, tiCmd = m.ucText.Update(msg) + cmds = append(cmds, tiCmd) + } + } + } else if m.showWalkthrough { + // โ”€โ”€ Walkthrough tab navigation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + switch { + case msg.Type == tea.KeyLeft: + if m.walkthroughTab > 0 { + m.walkthroughTab-- + } + case msg.Type == tea.KeyRight: + if m.walkthroughTab < len(m.walkthroughPanes)-1 { + m.walkthroughTab++ + } + case msg.Type == tea.KeyRunes && msg.String() == "o": + if pane := m.walkthroughPanes[m.walkthroughTab]; pane.URL != "" { + utils.OpenBrowser(pane.URL) //nolint:errcheck + } + case msg.Type == tea.KeyEsc: + m.showWalkthrough = false + m.input.Focus() + case msg.Type == tea.KeyRunes && msg.String() == "/": + m.showWalkthrough = false + m.input.Focus() + m.input.SetValue("/") + m.input.CursorEnd() + } + } else { + // โ”€โ”€ Regular REPL โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + switch msg.Type { + case tea.KeyEnter: + if m.status != statusReady { + break + } + val := strings.TrimSpace(m.input.Value()) + if val == "" { + break + } + if m.showCompletions && len(m.completions) > 0 { + val = m.completions[m.selectedComp].Name + } + m.messages = append(m.messages, "> "+val) + m.input.SetValue("") + m.showCompletions = false + m.selectedComp = 0 + if cmd := m.runCommand(val); cmd != nil { + cmds = append(cmds, cmd) + } + case tea.KeyUp: + if m.showCompletions && m.selectedComp > 0 { + m.selectedComp-- + } + case tea.KeyDown: + if m.showCompletions && m.selectedComp < len(m.completions)-1 { + m.selectedComp++ + } + case tea.KeyTab: + if m.showCompletions && len(m.completions) > 0 { + m.input.SetValue(m.completions[m.selectedComp].Name) + m.input.CursorEnd() + } + } + } + + case usecaseConfigRequestMsg: + m.ucInputs = msg.inputs + m.ucSampleName = msg.sampleName + m.ucEnvTarget = msg.envTarget + m.ucFeatures = msg.features + + // Pre-populate from a previous run so the user is not re-prompted. + sampleDir := sample.SampleDir(m.installPath, msg.sampleName) + m.ucValues = sample.ReadServiceEnv(sampleDir, msg.envTarget) + + // Advance past any steps that already have a non-empty saved value. + m.ucStep = 0 + for m.ucStep < len(m.ucInputs) { + if val, ok := m.ucValues[m.ucInputs[m.ucStep].Key]; ok && val != "" { + m.ucStep++ + } else { + break + } + } + + if m.ucStep >= len(m.ucInputs) { + // All values already present โ€” launch immediately without prompting. + m.tryingOut = true + m.input.Blur() + opts := sample.Options{ + Config: m.ucValues, + EnvTarget: m.ucEnvTarget, + Features: m.ucFeatures, + } + cmds = append(cmds, makeTryCmd(m.ucSampleName, m.installPath, m.verbose, opts)) + } else { + m.showUsecaseConfig = true + m.input.Blur() + m.initUCStep() + } + + case healthCheckMsg: + if msg.ready { + if m.status == statusStarting { + port := m.effectivePort() + for _, scheme := range []string{"https", "http"} { + base := fmt.Sprintf("%s://localhost:%d", scheme, port) + if health.CheckReady(base) { + m.baseURL = base + break + } + } + if m.baseURL == "" { + m.baseURL = fmt.Sprintf("http://localhost:%d", port) + } + m.status = statusReady + if m.showOnboarding { + // Input stays blurred; user enters command mode explicitly with / or ? + } else { + m.input.Focus() + m.input.Placeholder = "Type / for commands, Ctrl+C to exit" + m.messages = append(m.messages, + Green("โ—")+" "+product.Name+" is running at "+Cyan(m.baseURL), + ) + } + if m.newVersion != "" { + m.messages = append(m.messages, + Yellow("โœฆ")+" "+Bold(product.Name+" v"+m.newVersion+" is available")+" โ€” type "+Cyan("/upgrade")+" to upgrade", + ) + } + } + // Always keep polling so we can detect crashes via health check. + cmds = append(cmds, pollHealthCmdOn(m.effectivePort())) + } else { + // Only report "stopped responding" when the product was healthy and we + // are not deliberately restarting it for a try-* operation. + if m.status == statusReady && !m.tryingOut { + m.status = statusStopped + m.input.Blur() + m.input.Placeholder = product.Name + " stopped. Ctrl+C to exit." + m.messages = append(m.messages, Red("โœ—")+" "+product.Name+" stopped responding.") + } + if m.status != statusStopped || m.tryingOut { + cmds = append(cmds, pollHealthCmdOn(m.effectivePort())) + } + } + + case thunderExitedMsg: + // Two independent guards โ€” either one is sufficient to suppress the message: + // 1. tryingOut: kill was intentional (try-* restart is in progress). + // 2. PID mismatch: stale watch from a previous proc that was already replaced. + if m.tryingOut { + break + } + currentPID := 0 + if m.proc != nil && m.proc.Process != nil { + currentPID = m.proc.Process.Pid + } + if msg.pid != currentPID { + break + } + m.status = statusStopped + m.input.Blur() + m.input.Placeholder = product.Name + " stopped. Ctrl+C to exit." + m.messages = append(m.messages, Red("โœ—")+" "+product.Name+" process exited unexpectedly.") + + case sampleStartedMsg: + m.sampleProgressCh = msg.progressCh + cmds = append(cmds, + waitForSampleProgress(msg.progressCh), + waitForSampleResult(msg.sampleName, msg.resultCh), + ) + + case sampleProgressMsg: + if msg.overwrite { + // Drive the bottom-status line โ€” same role as the \r overwrite in CLI mode. + m.trySampleStatus = msg.line + } else { + // A status line arrived (Stoppingโ€ฆ, Writingโ€ฆ, Startingโ€ฆ): clear the + // bottom progress bar so the spinner shows a neutral state. + m.trySampleStatus = "" + m.messages = append(m.messages, " "+msg.line) + } + cmds = append(cmds, waitForSampleProgress(m.sampleProgressCh)) + + case sampleProgressDoneMsg: + // Progress channel closed โ€” result channel will deliver the final outcome. + + case sampleDoneMsg: + m.tryingOut = false + m.trySampleStatus = "" + m.sampleProgressCh = nil + m.proc = msg.proc + // The server was confirmed ready by ResolveBaseURL before the sample + // services started. Mark it ready now so the normal health-check + // stopped-detection fires immediately if the sample's start.sh kills + // and fails to restart it, rather than spinning on "Startingโ€ฆ" forever. + if msg.serverURL != "" { + m.baseURL = msg.serverURL + m.status = statusReady + m.input.Focus() + m.input.Placeholder = "Type / for commands, Ctrl+C to exit" + } else { + m.status = statusStarting + m.input.Placeholder = "Starting " + product.Name + "..." + } + cmds = append(cmds, pollHealthCmdOn(m.effectivePort())) + m.messages = append(m.messages, Green("โœ“")+" "+msg.sampleName+" is live at "+Cyan(msg.sampleURL)) + if msg.sampleName == "wayfinder" { + hasAI := false + for _, f := range msg.features { + if f == "ai" { + hasAI = true + break + } + } + if hasAI { + m.walkthroughPanes = agentWalkthroughPanes(msg.sampleURL) + } else { + m.walkthroughPanes = b2cWalkthroughPanes(msg.sampleURL) + } + m.walkthroughTab = 0 + m.showWalkthrough = true + m.input.Blur() + } + + case sampleErrMsg: + m.tryingOut = false + m.trySampleStatus = "" + m.sampleProgressCh = nil + m.messages = append(m.messages, Red("โœ—")+" "+msg.err.Error()) + if m.status == statusReady { + m.input.Focus() + m.input.Placeholder = "Type / for commands, Ctrl+C to exit" + } + + case cutoverMsg: + m.cutoverRequested = true + m.quitting = true + return m, tea.Quit + + case upgradeMsg: + m.killThunder() + m.upgradeRequested = true + m.quitting = true + return m, tea.Quit + + case spinner.TickMsg: + var spinCmd tea.Cmd + m.spinner, spinCmd = m.spinner.Update(msg) + cmds = append(cmds, spinCmd) + } + + var tiCmd tea.Cmd + m.input, tiCmd = m.input.Update(msg) + cmds = append(cmds, tiCmd) + + m.updateCompletions() + return m, tea.Batch(cmds...) +} + +func (m *ReplModel) updateCompletions() { + val := m.input.Value() + if val == "/" { + m.completions = m.commands + m.showCompletions = true + if m.selectedComp >= len(m.completions) { + m.selectedComp = 0 + } + return + } + if !strings.HasPrefix(val, "/") { + m.showCompletions = false + m.completions = nil + return + } + filter := strings.ToLower(strings.TrimSpace(val)) + var matches []SlashCommand + for _, c := range m.commands { + if strings.HasPrefix(strings.ToLower(c.Name), filter) { + matches = append(matches, c) + } + } + m.completions = matches + m.showCompletions = len(matches) > 0 + if m.selectedComp >= len(matches) { + m.selectedComp = 0 + } +} + +func (m *ReplModel) runCommand(val string) tea.Cmd { + if val == "/stop" { + m.killThunder() + return tea.Quit + } + if m.tryingOut { + m.messages = append(m.messages, Yellow("โณ")+" Please wait โ€” setup is in progress.") + return nil + } + for _, c := range m.commands { + if c.Name != val { + continue + } + if c.AsyncAction != nil { + m.tryingOut = true + m.input.Blur() + return c.AsyncAction(m.baseURL) + } + if c.Action != nil { + lines, err := c.Action(m.baseURL) + m.messages = append(m.messages, lines...) + if err != nil { + m.messages = append(m.messages, Red("โœ—")+" "+err.Error()) + } + } + return nil + } + if !strings.HasPrefix(val, "/") { + return nil + } + m.messages = append(m.messages, Yellow("?")+" Unknown command. "+Dim("Type / to see available commands.")) + return nil +} + +func (m *ReplModel) killThunder() { + if m.proc == nil || m.proc.Process == nil { + return + } + // SIGTERM lets start.sh's cleanup trap kill ThunderID and the consent server + // before exiting. SIGKILL would bypass the trap and leave port 9090 occupied, + // causing the next invocation to fail. + m.proc.Process.Signal(syscall.SIGTERM) //nolint:errcheck + time.Sleep(time.Second) +} + +func renderCompletions(m ReplModel) string { + if !m.showCompletions || len(m.completions) == 0 { + return "" + } + var b strings.Builder + separator := Dim(strings.Repeat("โ”€", clamp(m.width-2, 20, 80))) + b.WriteString(separator + "\n") + const nameW = 24 + lastSection := "" + for i, c := range m.completions { + if c.Section != lastSection { + if i > 0 { + b.WriteString("\n") + } + if c.Section != "" { + b.WriteString(" " + Dim(c.Section) + "\n") + } + lastSection = c.Section + } + var namePart, descPart string + indicator := " " + if c.ComingSoon { + namePart = Dim(fmt.Sprintf("%-*s", nameW, c.Name)) + descPart = Dim(c.Description) + } else if i == m.selectedComp { + indicator = "โ–ถ " + namePart = lipgloss.NewStyle().Foreground(lipgloss.Color(colorCyan)).Bold(true).Render(fmt.Sprintf("%-*s", nameW, c.Name)) + descPart = lipgloss.NewStyle().Foreground(lipgloss.Color(colorCyan)).Render(c.Description) + } else { + namePart = Dim(fmt.Sprintf("%-*s", nameW, c.Name)) + descPart = Dim(c.Description) + } + b.WriteString(" " + indicator + namePart + " " + descPart + "\n") + } + b.WriteString(separator + "\n") + return b.String() +} + +func renderWalkthrough(m ReplModel) string { + if len(m.walkthroughPanes) == 0 { + return "" + } + var b strings.Builder + + var tabParts []string + for i, p := range m.walkthroughPanes { + if i == m.walkthroughTab { + tabParts = append(tabParts, lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorCyan)). + Bold(true). + Underline(true). + Render(p.Title)) + } else { + tabParts = append(tabParts, Dim(p.Title)) + } + } + b.WriteString(" " + strings.Join(tabParts, Dim(" ยท ")) + "\n") + b.WriteString(" " + Dim(strings.Repeat("โ”€", clamp(m.width-4, 20, 76))) + "\n\n") + + pane := m.walkthroughPanes[m.walkthroughTab] + for _, line := range pane.Lines { + b.WriteString(" " + line + "\n") + } + + b.WriteString("\n") + hint := Dim(" โ† โ†’ switch tabs") + if pane.URL != "" { + hint += Dim(" โ€ข o open in browser") + } + hint += Dim(" โ€ข esc dismiss โ€ข / for commands") + b.WriteString(hint + "\n") + + return b.String() +} + +// View implements tea.Model. +func (m ReplModel) View() string { + if m.quitting { + return Dim("Stopping " + product.Name + "...\n") + } + + var b strings.Builder + + b.WriteString(BannerString() + "\n") + + statusPart := "" + switch m.status { + case statusStarting: + statusPart = m.spinner.View() + " Starting..." + case statusReady: + statusPart = Green("โ—") + " Running at " + Cyan(m.baseURL) + case statusStopped: + statusPart = Red("โ—‹") + " Stopped" + } + + b.WriteString(Bold("โšก "+product.Name+" v"+m.version) + " " + statusPart + "\n") + b.WriteString(Dim(strings.Repeat("โ”€", clamp(m.width-2, 20, 80))) + "\n\n") + + if m.showOnboarding && m.status == statusReady { + if m.onboardingCmdMode { + // Slash-command overlay: show completions and input, no list. + b.WriteString(renderCompletions(m)) + b.WriteString(m.input.View()) + b.WriteString("\n\n" + Dim(" esc back to use-case picker")) + } else { + // List mode with custom hint replacing the list's built-in help. + b.WriteString(strings.TrimRight(m.onboardingList.View(), "\n")) + b.WriteString("\n" + Dim(" โ†‘/k up โ€ข โ†“/j down โ€ข / commands")) + } + return b.String() + } + + if m.showUsecaseConfig { + inp := m.ucInputs[m.ucStep] + b.WriteString(" " + Bold(inp.Label) + "\n\n") + if len(inp.Choices) > 0 { + b.WriteString(m.ucList.View()) + b.WriteString("\n" + Dim(" โ†‘/โ†“ select โ€ข Enter to continue")) + } else { + b.WriteString(m.ucText.View() + "\n") + b.WriteString("\n" + Dim(" Enter to continue")) + } + return b.String() + } + + for _, msg := range m.messages { + b.WriteString(" " + msg + "\n") + } + if len(m.messages) > 0 { + b.WriteString("\n") + } + + if m.showWalkthrough { + b.WriteString(renderWalkthrough(m)) + return b.String() + } + + b.WriteString(renderCompletions(m)) + + switch { + case m.tryingOut && m.trySampleStatus != "": + b.WriteString(m.spinner.View() + " " + m.trySampleStatus) + case m.tryingOut: + b.WriteString(m.spinner.View() + Dim(" Please waitโ€ฆ (Ctrl+C to abort)")) + case m.status == statusStarting: + b.WriteString(m.spinner.View() + Dim(" Starting "+product.Name+"โ€ฆ")) + default: + b.WriteString(m.input.View()) + } + return b.String() +} + +func clamp(v, min, max int) int { + if v < min { + return min + } + if v > max { + return max + } + return v +} + +// RunREPL starts the interactive REPL and blocks until the user exits. +// newVersion, if non-empty, causes a banner to appear prompting the user to /upgrade. +// Returns upgradeRequested=true when the user ran /upgrade. +func RunREPL( + version string, proc *exec.Cmd, installPath string, + verbose, isFirstRun bool, newVersion string, +) (upgradeRequested bool, err error) { + m := NewReplModel(version, proc, installPath, verbose, isFirstRun) + m.newVersion = newVersion + p := tea.NewProgram(m, tea.WithAltScreen()) + finalModel, runErr := p.Run() + if rm, ok := finalModel.(ReplModel); ok { + return rm.upgradeRequested, runErr + } + return false, runErr +} + +// RunStagingREPL runs the REPL connected to a staging instance on stagingPort. +// It injects a /cutover command; when the user runs it the REPL exits and +// cutoverRequested=true is returned so the caller can perform the cut-over. +func RunStagingREPL(version string, proc *exec.Cmd, installPath string, verbose bool, stagingPort int) (cutoverRequested bool, err error) { + m := NewReplModel(version, proc, installPath, verbose, false) + m.checkPort = stagingPort + m.commands = append([]SlashCommand{ + { + Name: "/cutover", + Description: "Cut over to this version and restart on the default port", + AsyncAction: func(_ string) tea.Cmd { + return func() tea.Msg { return cutoverMsg{} } + }, + }, + }, m.commands...) + p := tea.NewProgram(m, tea.WithAltScreen()) + finalModel, runErr := p.Run() + if rm, ok := finalModel.(ReplModel); ok { + return rm.cutoverRequested, runErr + } + return false, runErr +} diff --git a/tools/cli/internal/ui/spinner/spinner.go b/tools/cli/internal/ui/spinner/spinner.go new file mode 100644 index 0000000000..1f72523bef --- /dev/null +++ b/tools/cli/internal/ui/spinner/spinner.go @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Package spinner renders a styled download progress bar using charmbracelet/bubbles. +package spinner + +import ( + "github.com/charmbracelet/bubbles/progress" + + "github.com/thunder-id/thunderid/tools/cli/internal/product" +) + +// DefaultWidth is the character width of the rendered progress bar. +const DefaultWidth = 30 + +var bar = progress.New( + progress.WithSolidFill(product.ColorElectricBlue), + progress.WithWidth(DefaultWidth), + progress.WithoutPercentage(), +) + +// Render returns a styled progress bar string for a given percentage (0โ€“100). +func Render(pct int) string { + return bar.ViewAs(float64(pct) / 100) +} diff --git a/tools/cli/internal/ui/spinner/spinner_test.go b/tools/cli/internal/ui/spinner/spinner_test.go new file mode 100644 index 0000000000..5eae3dbf86 --- /dev/null +++ b/tools/cli/internal/ui/spinner/spinner_test.go @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package spinner_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/thunder-id/thunderid/tools/cli/internal/ui/spinner" +) + +func TestDefaultWidth_Positive(t *testing.T) { + assert.Greater(t, spinner.DefaultWidth, 0) +} + +func TestRender_ReturnsNonEmptyString(t *testing.T) { + for _, pct := range []int{0, 25, 50, 75, 100} { + result := spinner.Render(pct) + assert.NotEmpty(t, result, "Render(%d) returned empty string", pct) + } +} diff --git a/tools/cli/internal/ui/usecases.go b/tools/cli/internal/ui/usecases.go new file mode 100644 index 0000000000..40e949e61f --- /dev/null +++ b/tools/cli/internal/ui/usecases.go @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package ui + +// ConfigInput describes one value the user must supply before the sample runs. +// If Choices is non-empty the TUI renders a list picker; otherwise a text input. +type ConfigInput struct { + Key string // env var key written to the target .env, e.g. "LLM_PROVIDER" + Label string // prompt text shown to the user + Choices []Choice // non-empty โ†’ list picker; empty โ†’ text input + Secret bool // mask text input with EchoPassword +} + +// Choice is a single option in a ConfigInput list picker. +type Choice struct { + Value string // stored in the collected config map + Label string // displayed in the TUI +} + +// Usecase describes a try-able auth use case, shared by the onboarding picker and slash commands. +type Usecase struct { + Emoji string + Title string + Description string + SampleName string // empty = coming soon + Command string // slash command, e.g. "/try-consumer" + ComingSoon bool + RequiredConfigs []ConfigInput // fields to collect before the sample starts; nil = no prompt + SampleEnvTarget string // service sub-dir to write collected config into, e.g. "ai-agent" + SampleFeatures []string // feature tags passed to the sample runner, e.g. ["ai"] +} + +// Usecases is the canonical list of try-able auth use cases. +var Usecases = []Usecase{ + { + Emoji: "๐Ÿ‘ค", + Title: "Consumer Login (B2C)", + Description: "Sign in with email, social providers, passkeys and MFA", + SampleName: "wayfinder", + Command: "/try-consumer", + }, + { + Emoji: "๐Ÿค–", + Title: "Agent Login (AgentID)", + Description: "Secure access for AI agents and automated workflows acting on behalf of users", + SampleName: "wayfinder", + Command: "/try-agentid", + RequiredConfigs: []ConfigInput{ + { + Key: "LLM_PROVIDER", + Label: "LLM provider for the AI concierge", + Choices: []Choice{ + {Value: "anthropic", Label: "Anthropic (Claude)"}, + {Value: "gemini", Label: "Gemini"}, + }, + }, + { + Key: "LLM_API_KEY", + Label: "API key", + Secret: true, + }, + }, + SampleEnvTarget: "ai-agent", + SampleFeatures: []string{"ai"}, + }, +} diff --git a/tools/cli/internal/utils/browser.go b/tools/cli/internal/utils/browser.go new file mode 100644 index 0000000000..b6724536b4 --- /dev/null +++ b/tools/cli/internal/utils/browser.go @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Package utils provides shared utility helpers for the CLI. +package utils + +import ( + "fmt" + "os/exec" + "runtime" +) + +// OpenBrowser opens url in the system's default browser. +func OpenBrowser(url string) error { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) + case "linux": + cmd = exec.Command("xdg-open", url) + case "windows": + cmd = exec.Command("cmd", "/c", "start", url) + default: + return fmt.Errorf("unsupported platform: %s", runtime.GOOS) + } + return cmd.Start() +} diff --git a/tools/cli/scripts/build.ps1 b/tools/cli/scripts/build.ps1 new file mode 100644 index 0000000000..2d97ce005a --- /dev/null +++ b/tools/cli/scripts/build.ps1 @@ -0,0 +1,59 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +# +# WSO2 LLC. licenses this file to you under the Apache License, +# Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ============================================================================= +# CLI Build Script (Windows) +# +# Cross-compiles the CLI for all supported platforms. +# Output binaries are written to dist/ inside the cli directory. +# Mirrors build.sh for Windows developers. +# ============================================================================= + +$ErrorActionPreference = "Stop" + +$PRODUCT_NAME = "ThunderID" +$PRODUCT_NAME_LOWERCASE = $PRODUCT_NAME.ToLower() +$CLI_DIR = Join-Path $PSScriptRoot ".." +$DIST_DIR = Join-Path $CLI_DIR "dist" + +New-Item -ItemType Directory -Force -Path $DIST_DIR | Out-Null + +$TARGETS = @( + @{ GOOS = "darwin"; GOARCH = "amd64"; OUT = "${PRODUCT_NAME_LOWERCASE}-darwin-x64" }, + @{ GOOS = "darwin"; GOARCH = "arm64"; OUT = "${PRODUCT_NAME_LOWERCASE}-darwin-arm64" }, + @{ GOOS = "linux"; GOARCH = "amd64"; OUT = "${PRODUCT_NAME_LOWERCASE}-linux-x64" }, + @{ GOOS = "linux"; GOARCH = "arm64"; OUT = "${PRODUCT_NAME_LOWERCASE}-linux-arm64" }, + @{ GOOS = "windows"; GOARCH = "amd64"; OUT = "${PRODUCT_NAME_LOWERCASE}-win-x64.exe" } +) + +Push-Location $CLI_DIR +try { + foreach ($t in $TARGETS) { + $outPath = Join-Path $DIST_DIR $t.OUT + Write-Host "Building $($t.GOOS)/$($t.GOARCH) -> $($t.OUT)" + $env:GOOS = $t.GOOS + $env:GOARCH = $t.GOARCH + $env:CGO_ENABLED = "0" + go build -ldflags="-s -w" -o $outPath "./cmd/${PRODUCT_NAME_LOWERCASE}/" + } +} finally { + "GOOS", "GOARCH", "CGO_ENABLED" | ForEach-Object { Remove-Item "Env:\$_" -ErrorAction SilentlyContinue } + Pop-Location +} + +Write-Host "Done. Binaries written to cli/dist/" diff --git a/tools/cli/scripts/build.sh b/tools/cli/scripts/build.sh new file mode 100644 index 0000000000..ba20b09ced --- /dev/null +++ b/tools/cli/scripts/build.sh @@ -0,0 +1,57 @@ +#!/bin/bash +set -euo pipefail +# ---------------------------------------------------------------------------- +# Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +# +# WSO2 LLC. licenses this file to you under the Apache License, +# Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ============================================================================= +# CLI Build Script +# +# Cross-compiles the CLI for all supported platforms. +# Output binaries are written to dist/ inside the cli directory. +# ============================================================================= + +PRODUCT_NAME="ThunderID" +PRODUCT_NAME_LOWERCASE="$(echo "$PRODUCT_NAME" | tr '[:upper:]' '[:lower:]')" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CLI_DIR="$SCRIPT_DIR/.." +DIST_DIR="$CLI_DIR/dist" + +mkdir -p "$DIST_DIR" + +# Format: "GOOS GOARCH output-name" +TARGETS=( + "darwin amd64 ${PRODUCT_NAME_LOWERCASE}-darwin-x64" + "darwin arm64 ${PRODUCT_NAME_LOWERCASE}-darwin-arm64" + "linux amd64 ${PRODUCT_NAME_LOWERCASE}-linux-x64" + "linux arm64 ${PRODUCT_NAME_LOWERCASE}-linux-arm64" + "windows amd64 ${PRODUCT_NAME_LOWERCASE}-win-x64.exe" +) + +cd "$CLI_DIR" + +for entry in "${TARGETS[@]}"; do + GOOS=$(echo "$entry" | awk '{print $1}') + GOARCH=$(echo "$entry" | awk '{print $2}') + OUT_NAME=$(echo "$entry" | awk '{print $3}') + OUT="$DIST_DIR/$OUT_NAME" + echo "Building $GOOS/$GOARCH โ†’ $OUT_NAME" + GOOS="$GOOS" GOARCH="$GOARCH" CGO_ENABLED=0 go build -ldflags="-s -w" -o "$OUT" ./cmd/"${PRODUCT_NAME_LOWERCASE}"/ +done + +echo "Done. Binaries written to cli/dist/" diff --git a/tools/npx-thunderid/README.md b/tools/npx-thunderid/README.md deleted file mode 100644 index bf314e942d..0000000000 --- a/tools/npx-thunderid/README.md +++ /dev/null @@ -1,43 +0,0 @@ -![ThunderID NPX](https://raw.githubusercontent.com/thunder-id/thunderid/refs/heads/main/docs/static/assets/images/readme/repo-banner-npx-thunderid.png) - -Run ThunderID instantly โ€” no manual download or setup required. - -## Quick Start - -```bash -npx thunderid -``` - -On first run this downloads the latest ThunderID release, initializes the platform, and starts it. Later runs reuse -the cached installation and start immediately. - -## Options - -| Option | Description | -| ----------- | ------------------------------------------------------------------------ | -| `--setup` | Re-run setup even if ThunderID is already installed | -| `-- ` | Forward arguments directly to ThunderID (e.g. `npx thunderid -- --help`) | - -## Requirements - -- **Node.js** `>= 18` -- **macOS / Linux:** `unzip` in `PATH` -- **Windows:** `tar` in `PATH` and a Unix-like shell (WSL or Git Bash) - -## Supported Platforms - -| OS | Architectures | -| ------- | --------------------------- | -| macOS | `x64`, `arm64` | -| Linux | `x64`, `arm64` | -| Windows | `x64` (via WSL or Git Bash) | - -## About - -- **npm:** [`thunderid`](https://www.npmjs.com/package/thunderid) -- **source:** -- **docs:** - -## License - -[Apache License 2.0](https://github.com/thunder-id/thunderid/blob/main/LICENSE) diff --git a/tools/npx-thunderid/bin/thunderid.js b/tools/npx-thunderid/bin/thunderid.js deleted file mode 100755 index 43b3750381..0000000000 --- a/tools/npx-thunderid/bin/thunderid.js +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env node -/** - * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -/* eslint-disable @thunderid/copyright-header, @typescript-eslint/no-require-imports, no-undef */ - -require('../dist/index.cjs'); diff --git a/tools/npx-thunderid/eslint.config.js b/tools/npx-thunderid/eslint.config.js deleted file mode 100644 index 7de9261a8a..0000000000 --- a/tools/npx-thunderid/eslint.config.js +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import thunderIdPlugin from '@thunderid/eslint-plugin'; - -export default [ - { - ignores: ['dist/**', 'build/**', 'node_modules/**', 'coverage/**'], - }, - ...thunderIdPlugin.configs.typescript, - ...thunderIdPlugin.configs.vitest, -]; diff --git a/tools/npx-thunderid/package.json b/tools/npx-thunderid/package.json deleted file mode 100644 index 1af094b9db..0000000000 --- a/tools/npx-thunderid/package.json +++ /dev/null @@ -1,61 +0,0 @@ -{ - "name": "thunderid", - "version": "0.1.2", - "description": "High-performance open-source identity stack, engineered for developers โ€” instantly via npx", - "keywords": [ - "thunderid", - "cli", - "npx" - ], - "homepage": "https://github.com/thunder-id/thunderid/tree/main/tools/npx-thunderid#readme", - "bugs": { - "url": "https://github.com/thunder-id/thunderid/issues" - }, - "author": "WSO2", - "license": "Apache-2.0", - "bin": { - "thunderid": "./bin/thunderid.js" - }, - "engines": { - "node": ">=18" - }, - "files": [ - "bin", - "dist" - ], - "repository": { - "type": "git", - "url": "https://github.com/thunder-id/thunderid", - "directory": "tools/npx-thunderid" - }, - "scripts": { - "build": "pnpm clean:dist && rolldown -c rolldown.config.js", - "clean": "pnpm clean:node_modules && pnpm clean:dist", - "clean:dist": "rimraf dist", - "clean:node_modules": "rimraf node_modules", - "format:check": "prettier --check --cache .", - "format:fix": "prettier --write --cache .", - "lint": "eslint . --ext .js,.jsx,.ts,.tsx,.cjs,.mjs", - "lint:fix": "eslint . --fix --ext .js,.jsx,.ts,.tsx,.cjs,.mjs", - "test": "vitest --passWithNoTests", - "typecheck": "tsc -p tsconfig.lib.json" - }, - "publishConfig": { - "access": "public" - }, - "dependencies": { - "@clack/prompts": "^0.9.1", - "picocolors": "^1.1.1" - }, - "devDependencies": { - "@thunderid/eslint-plugin": "workspace:^", - "@thunderid/prettier-config": "workspace:^", - "@types/node": "catalog:", - "eslint": "catalog:", - "prettier": "catalog:", - "rimraf": "catalog:", - "rolldown": "catalog:", - "typescript": "catalog:", - "vitest": "catalog:" - } -} diff --git a/tools/npx-thunderid/pnpm-lock.yaml b/tools/npx-thunderid/pnpm-lock.yaml deleted file mode 100644 index ddff851cfa..0000000000 --- a/tools/npx-thunderid/pnpm-lock.yaml +++ /dev/null @@ -1,1917 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - .: - dependencies: - '@clack/prompts': - specifier: ^0.9.1 - version: 0.9.1 - picocolors: - specifier: ^1.1.1 - version: 1.1.1 - devDependencies: - '@types/node': - specifier: 24.7.2 - version: 24.7.2 - eslint: - specifier: 9.39.4 - version: 9.39.4 - prettier: - specifier: 3.6.2 - version: 3.6.2 - rimraf: - specifier: 6.1.3 - version: 6.1.3 - rolldown: - specifier: 1.0.0-beta.45 - version: 1.0.0-beta.45(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) - typescript: - specifier: 5.9.3 - version: 5.9.3 - vitest: - specifier: 4.1.3 - version: 4.1.3(@types/node@24.7.2)(vite@8.0.13(@types/node@24.7.2)) - -packages: - '@clack/core@0.4.1': - resolution: - {integrity: sha512-Pxhij4UXg8KSr7rPek6Zowm+5M22rbd2g1nfojHJkxp5YkFqiZ2+YLEM/XGVIzvGOcM0nqjIFxrpDwWRZYWYjA==} - - '@clack/prompts@0.9.1': - resolution: - {integrity: sha512-JIpyaboYZeWYlyP0H+OoPPxd6nqueG/CmN6ixBiNFsIDHREevjIf0n0Ohh5gr5C8pEDknzgvz+pIJ8dMhzWIeg==} - - '@emnapi/core@1.10.0': - resolution: - {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} - - '@emnapi/runtime@1.10.0': - resolution: - {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} - - '@emnapi/wasi-threads@1.2.1': - resolution: - {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} - - '@eslint-community/eslint-utils@4.9.1': - resolution: - {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - - '@eslint-community/regexpp@4.12.2': - resolution: - {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} - engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - - '@eslint/config-array@0.21.2': - resolution: - {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/config-helpers@0.4.2': - resolution: - {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/core@0.17.0': - resolution: - {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/eslintrc@3.3.5': - resolution: - {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/js@9.39.4': - resolution: - {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/object-schema@2.1.7': - resolution: - {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/plugin-kit@0.4.1': - resolution: - {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@humanfs/core@0.19.2': - resolution: - {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} - engines: {node: '>=18.18.0'} - - '@humanfs/node@0.16.8': - resolution: - {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} - engines: {node: '>=18.18.0'} - - '@humanfs/types@0.15.0': - resolution: - {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} - engines: {node: '>=18.18.0'} - - '@humanwhocodes/module-importer@1.0.1': - resolution: - {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} - engines: {node: '>=12.22'} - - '@humanwhocodes/retry@0.4.3': - resolution: - {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} - engines: {node: '>=18.18'} - - '@jridgewell/sourcemap-codec@1.5.5': - resolution: - {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - - '@napi-rs/wasm-runtime@1.1.4': - resolution: - {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} - peerDependencies: - '@emnapi/core': ^1.7.1 - '@emnapi/runtime': ^1.7.1 - - '@oxc-project/types@0.130.0': - resolution: - {integrity: sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==} - - '@oxc-project/types@0.95.0': - resolution: - {integrity: sha512-vACy7vhpMPhjEJhULNxrdR0D943TkA/MigMpJCHmBHvMXxRStRi/dPtTlfQ3uDwWSzRpT8z+7ImjZVf8JWBocQ==} - - '@rolldown/binding-android-arm64@1.0.0-beta.45': - resolution: - {integrity: sha512-bfgKYhFiXJALeA/riil908+2vlyWGdwa7Ju5S+JgWZYdR4jtiPOGdM6WLfso1dojCh+4ZWeiTwPeV9IKQEX+4g==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [android] - - '@rolldown/binding-android-arm64@1.0.1': - resolution: - {integrity: sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [android] - - '@rolldown/binding-darwin-arm64@1.0.0-beta.45': - resolution: - {integrity: sha512-xjCv4CRVsSnnIxTuyH1RDJl5OEQ1c9JYOwfDAHddjJDxCw46ZX9q80+xq7Eok7KC4bRSZudMJllkvOKv0T9SeA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [darwin] - - '@rolldown/binding-darwin-arm64@1.0.1': - resolution: - {integrity: sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [darwin] - - '@rolldown/binding-darwin-x64@1.0.0-beta.45': - resolution: - {integrity: sha512-ddcO9TD3D/CLUa/l8GO8LHzBOaZqWg5ClMy3jICoxwCuoz47h9dtqPsIeTiB6yR501LQTeDsjA4lIFd7u3Ljfw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [darwin] - - '@rolldown/binding-darwin-x64@1.0.1': - resolution: - {integrity: sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [darwin] - - '@rolldown/binding-freebsd-x64@1.0.0-beta.45': - resolution: - {integrity: sha512-MBTWdrzW9w+UMYDUvnEuh0pQvLENkl2Sis15fHTfHVW7ClbGuez+RWopZudIDEGkpZXdeI4CkRXk+vdIIebrmg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [freebsd] - - '@rolldown/binding-freebsd-x64@1.0.1': - resolution: - {integrity: sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [freebsd] - - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.45': - resolution: - {integrity: sha512-4YgoCFiki1HR6oSg+GxxfzfnVCesQxLF1LEnw9uXS/MpBmuog0EOO2rYfy69rWP4tFZL9IWp6KEfGZLrZ7aUog==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@rolldown/binding-linux-arm-gnueabihf@1.0.1': - resolution: - {integrity: sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.45': - resolution: - {integrity: sha512-LE1gjAwQRrbCOorJJ7LFr10s5vqYf5a00V5Ea9wXcT2+56n5YosJkcp8eQ12FxRBv2YX8dsdQJb+ZTtYJwb6XQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-arm64-gnu@1.0.1': - resolution: - {integrity: sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-arm64-musl@1.0.0-beta.45': - resolution: - {integrity: sha512-tdy8ThO/fPp40B81v0YK3QC+KODOmzJzSUOO37DinQxzlTJ026gqUSOM8tzlVixRbQJltgVDCTYF8HNPRErQTA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@rolldown/binding-linux-arm64-musl@1.0.1': - resolution: - {integrity: sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@rolldown/binding-linux-ppc64-gnu@1.0.1': - resolution: - {integrity: sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-s390x-gnu@1.0.1': - resolution: - {integrity: sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-x64-gnu@1.0.0-beta.45': - resolution: - {integrity: sha512-lS082ROBWdmOyVY/0YB3JmsiClaWoxvC+dA8/rbhyB9VLkvVEaihLEOr4CYmrMse151C4+S6hCw6oa1iewox7g==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-x64-gnu@1.0.1': - resolution: - {integrity: sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@rolldown/binding-linux-x64-musl@1.0.0-beta.45': - resolution: - {integrity: sha512-Hi73aYY0cBkr1/SvNQqH8Cd+rSV6S9RB5izCv0ySBcRnd/Wfn5plguUoGYwBnhHgFbh6cPw9m2dUVBR6BG1gxA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [musl] - - '@rolldown/binding-linux-x64-musl@1.0.1': - resolution: - {integrity: sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - libc: [musl] - - '@rolldown/binding-openharmony-arm64@1.0.0-beta.45': - resolution: - {integrity: sha512-fljEqbO7RHHogNDxYtTzr+GNjlfOx21RUyGmF+NrkebZ8emYYiIqzPxsaMZuRx0rgZmVmliOzEp86/CQFDKhJQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [openharmony] - - '@rolldown/binding-openharmony-arm64@1.0.1': - resolution: - {integrity: sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [openharmony] - - '@rolldown/binding-wasm32-wasi@1.0.0-beta.45': - resolution: - {integrity: sha512-ZJDB7lkuZE9XUnWQSYrBObZxczut+8FZ5pdanm8nNS1DAo8zsrPuvGwn+U3fwU98WaiFsNrA4XHngesCGr8tEQ==} - engines: {node: '>=14.0.0'} - cpu: [wasm32] - - '@rolldown/binding-wasm32-wasi@1.0.1': - resolution: - {integrity: sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [wasm32] - - '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.45': - resolution: - {integrity: sha512-zyzAjItHPUmxg6Z8SyRhLdXlJn3/D9KL5b9mObUrBHhWS/GwRH4665xCiFqeuktAhhWutqfc+rOV2LjK4VYQGQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [win32] - - '@rolldown/binding-win32-arm64-msvc@1.0.1': - resolution: - {integrity: sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [win32] - - '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.45': - resolution: - {integrity: sha512-wODcGzlfxqS6D7BR0srkJk3drPwXYLu7jPHN27ce2c4PUnVVmJnp9mJzUQGT4LpmHmmVdMZ+P6hKvyTGBzc1CA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ia32] - os: [win32] - - '@rolldown/binding-win32-x64-msvc@1.0.0-beta.45': - resolution: - {integrity: sha512-wiU40G1nQo9rtfvF9jLbl79lUgjfaD/LTyUEw2Wg/gdF5OhjzpKMVugZQngO+RNdwYaNj+Fs+kWBWfp4VXPMHA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [win32] - - '@rolldown/binding-win32-x64-msvc@1.0.1': - resolution: - {integrity: sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [win32] - - '@rolldown/pluginutils@1.0.0-beta.45': - resolution: - {integrity: sha512-Le9ulGCrD8ggInzWw/k2J8QcbPz7eGIOWqfJ2L+1R0Opm7n6J37s2hiDWlh6LJN0Lk9L5sUzMvRHKW7UxBZsQA==} - - '@rolldown/pluginutils@1.0.1': - resolution: - {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} - - '@standard-schema/spec@1.1.0': - resolution: - {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - - '@tybys/wasm-util@0.10.2': - resolution: - {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} - - '@types/chai@5.2.3': - resolution: - {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} - - '@types/deep-eql@4.0.2': - resolution: - {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} - - '@types/estree@1.0.9': - resolution: - {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} - - '@types/json-schema@7.0.15': - resolution: - {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - - '@types/node@24.7.2': - resolution: - {integrity: sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA==} - - '@vitest/expect@4.1.3': - resolution: - {integrity: sha512-CW8Q9KMtXDGHj0vCsqui0M5KqRsu0zm0GNDW7Gd3U7nZ2RFpPKSCpeCXoT+/+5zr1TNlsoQRDEz+LzZUyq6gnQ==} - - '@vitest/mocker@4.1.3': - resolution: - {integrity: sha512-XN3TrycitDQSzGRnec/YWgoofkYRhouyVQj4YNsJ5r/STCUFqMrP4+oxEv3e7ZbLi4og5kIHrZwekDJgw6hcjw==} - peerDependencies: - msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 - peerDependenciesMeta: - msw: - optional: true - vite: - optional: true - - '@vitest/pretty-format@4.1.3': - resolution: - {integrity: sha512-hYqqwuMbpkkBodpRh4k4cQSOELxXky1NfMmQvOfKvV8zQHz8x8Dla+2wzElkMkBvSAJX5TRGHJAQvK0TcOafwg==} - - '@vitest/runner@4.1.3': - resolution: - {integrity: sha512-VwgOz5MmT0KhlUj40h02LWDpUBVpflZ/b7xZFA25F29AJzIrE+SMuwzFf0b7t4EXdwRNX61C3B6auIXQTR3ttA==} - - '@vitest/snapshot@4.1.3': - resolution: - {integrity: sha512-9l+k/J9KG5wPJDX9BcFFzhhwNjwkRb8RsnYhaT1vPY7OufxmQFc9sZzScRCPTiETzl37mrIWVY9zxzmdVeJwDQ==} - - '@vitest/spy@4.1.3': - resolution: - {integrity: sha512-ujj5Uwxagg4XUIfAUyRQxAg631BP6e9joRiN99mr48Bg9fRs+5mdUElhOoZ6rP5mBr8Bs3lmrREnkrQWkrsTCw==} - - '@vitest/utils@4.1.3': - resolution: - {integrity: sha512-Pc/Oexse/khOWsGB+w3q4yzA4te7W4gpZZAvk+fr8qXfTURZUMj5i7kuxsNK5mP/dEB6ao3jfr0rs17fHhbHdw==} - - acorn-jsx@5.3.2: - resolution: - {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} - peerDependencies: - acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - - acorn@8.16.0: - resolution: - {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} - engines: {node: '>=0.4.0'} - hasBin: true - - ajv@6.15.0: - resolution: - {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} - - ansi-styles@4.3.0: - resolution: - {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - - argparse@2.0.1: - resolution: - {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - - assertion-error@2.0.1: - resolution: - {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} - engines: {node: '>=12'} - - balanced-match@1.0.2: - resolution: - {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - - balanced-match@4.0.4: - resolution: - {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} - engines: {node: 18 || 20 || >=22} - - brace-expansion@1.1.14: - resolution: - {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} - - brace-expansion@5.0.6: - resolution: - {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} - engines: {node: 18 || 20 || >=22} - - callsites@3.1.0: - resolution: - {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} - engines: {node: '>=6'} - - chai@6.2.2: - resolution: - {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} - engines: {node: '>=18'} - - chalk@4.1.2: - resolution: - {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} - - color-convert@2.0.1: - resolution: - {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-name@1.1.4: - resolution: - {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - - concat-map@0.0.1: - resolution: - {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - - convert-source-map@2.0.0: - resolution: - {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - - cross-spawn@7.0.6: - resolution: - {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} - - debug@4.4.3: - resolution: - {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - deep-is@0.1.4: - resolution: - {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - - detect-libc@2.1.2: - resolution: - {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} - engines: {node: '>=8'} - - es-module-lexer@2.1.0: - resolution: - {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} - - escape-string-regexp@4.0.0: - resolution: - {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} - engines: {node: '>=10'} - - eslint-scope@8.4.0: - resolution: - {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - eslint-visitor-keys@3.4.3: - resolution: - {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - eslint-visitor-keys@4.2.1: - resolution: - {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - eslint@9.39.4: - resolution: - {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - hasBin: true - peerDependencies: - jiti: '*' - peerDependenciesMeta: - jiti: - optional: true - - espree@10.4.0: - resolution: - {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - esquery@1.7.0: - resolution: - {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} - engines: {node: '>=0.10'} - - esrecurse@4.3.0: - resolution: - {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} - engines: {node: '>=4.0'} - - estraverse@5.3.0: - resolution: - {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} - engines: {node: '>=4.0'} - - estree-walker@3.0.3: - resolution: - {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - - esutils@2.0.3: - resolution: - {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} - engines: {node: '>=0.10.0'} - - expect-type@1.3.0: - resolution: - {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} - engines: {node: '>=12.0.0'} - - fast-deep-equal@3.1.3: - resolution: - {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - - fast-json-stable-stringify@2.1.0: - resolution: - {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - - fast-levenshtein@2.0.6: - resolution: - {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - - fdir@6.5.0: - resolution: - {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} - engines: {node: '>=12.0.0'} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - - file-entry-cache@8.0.0: - resolution: - {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} - engines: {node: '>=16.0.0'} - - find-up@5.0.0: - resolution: - {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} - engines: {node: '>=10'} - - flat-cache@4.0.1: - resolution: - {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} - engines: {node: '>=16'} - - flatted@3.4.2: - resolution: - {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} - - fsevents@2.3.3: - resolution: - {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - glob-parent@6.0.2: - resolution: - {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} - engines: {node: '>=10.13.0'} - - glob@13.0.6: - resolution: - {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} - engines: {node: 18 || 20 || >=22} - - globals@14.0.0: - resolution: - {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} - engines: {node: '>=18'} - - has-flag@4.0.0: - resolution: - {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - - ignore@5.3.2: - resolution: - {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} - engines: {node: '>= 4'} - - import-fresh@3.3.1: - resolution: - {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} - engines: {node: '>=6'} - - imurmurhash@0.1.4: - resolution: - {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} - engines: {node: '>=0.8.19'} - - is-extglob@2.1.1: - resolution: - {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} - - is-glob@4.0.3: - resolution: - {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} - - isexe@2.0.0: - resolution: - {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - - js-yaml@4.1.1: - resolution: - {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} - hasBin: true - - json-buffer@3.0.1: - resolution: - {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} - - json-schema-traverse@0.4.1: - resolution: - {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - - json-stable-stringify-without-jsonify@1.0.1: - resolution: - {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} - - keyv@4.5.4: - resolution: - {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - - levn@0.4.1: - resolution: - {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} - engines: {node: '>= 0.8.0'} - - lightningcss-android-arm64@1.32.0: - resolution: - {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [android] - - lightningcss-darwin-arm64@1.32.0: - resolution: - {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [darwin] - - lightningcss-darwin-x64@1.32.0: - resolution: - {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [darwin] - - lightningcss-freebsd-x64@1.32.0: - resolution: - {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [freebsd] - - lightningcss-linux-arm-gnueabihf@1.32.0: - resolution: - {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} - engines: {node: '>= 12.0.0'} - cpu: [arm] - os: [linux] - - lightningcss-linux-arm64-gnu@1.32.0: - resolution: - {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - lightningcss-linux-arm64-musl@1.32.0: - resolution: - {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - libc: [musl] - - lightningcss-linux-x64-gnu@1.32.0: - resolution: - {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - libc: [glibc] - - lightningcss-linux-x64-musl@1.32.0: - resolution: - {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - libc: [musl] - - lightningcss-win32-arm64-msvc@1.32.0: - resolution: - {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [win32] - - lightningcss-win32-x64-msvc@1.32.0: - resolution: - {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [win32] - - lightningcss@1.32.0: - resolution: - {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} - engines: {node: '>= 12.0.0'} - - locate-path@6.0.0: - resolution: - {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} - engines: {node: '>=10'} - - lodash.merge@4.6.2: - resolution: - {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - - lru-cache@11.4.0: - resolution: - {integrity: sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA==} - engines: {node: 20 || >=22} - - magic-string@0.30.21: - resolution: - {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - - minimatch@10.2.5: - resolution: - {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} - engines: {node: 18 || 20 || >=22} - - minimatch@3.1.5: - resolution: - {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} - - minipass@7.1.3: - resolution: - {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} - engines: {node: '>=16 || 14 >=14.17'} - - ms@2.1.3: - resolution: - {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - nanoid@3.3.12: - resolution: - {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - natural-compare@1.4.0: - resolution: - {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - - obug@2.1.1: - resolution: - {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} - - optionator@0.9.4: - resolution: - {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} - engines: {node: '>= 0.8.0'} - - p-limit@3.1.0: - resolution: - {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} - engines: {node: '>=10'} - - p-locate@5.0.0: - resolution: - {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} - engines: {node: '>=10'} - - package-json-from-dist@1.0.1: - resolution: - {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} - - parent-module@1.0.1: - resolution: - {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} - engines: {node: '>=6'} - - path-exists@4.0.0: - resolution: - {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} - engines: {node: '>=8'} - - path-key@3.1.1: - resolution: - {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - - path-scurry@2.0.2: - resolution: - {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} - engines: {node: 18 || 20 || >=22} - - pathe@2.0.3: - resolution: - {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - - picocolors@1.1.1: - resolution: - {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - picomatch@4.0.4: - resolution: - {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} - engines: {node: '>=12'} - - postcss@8.5.15: - resolution: - {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} - engines: {node: ^10 || ^12 || >=14} - - prelude-ls@1.2.1: - resolution: - {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} - engines: {node: '>= 0.8.0'} - - prettier@3.6.2: - resolution: - {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} - engines: {node: '>=14'} - hasBin: true - - punycode@2.3.1: - resolution: - {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} - engines: {node: '>=6'} - - resolve-from@4.0.0: - resolution: - {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} - engines: {node: '>=4'} - - rimraf@6.1.3: - resolution: - {integrity: sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==} - engines: {node: 20 || >=22} - hasBin: true - - rolldown@1.0.0-beta.45: - resolution: - {integrity: sha512-iMmuD72XXLf26Tqrv1cryNYLX6NNPLhZ3AmNkSf8+xda0H+yijjGJ+wVT9UdBUHOpKzq9RjKtQKRCWoEKQQBZQ==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - - rolldown@1.0.1: - resolution: - {integrity: sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - - shebang-command@2.0.0: - resolution: - {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: - {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - - siginfo@2.0.0: - resolution: - {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - - sisteransi@1.0.5: - resolution: - {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} - - source-map-js@1.2.1: - resolution: - {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - - stackback@0.0.2: - resolution: - {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - - std-env@4.1.0: - resolution: - {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} - - strip-json-comments@3.1.1: - resolution: - {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} - engines: {node: '>=8'} - - supports-color@7.2.0: - resolution: - {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - - tinybench@2.9.0: - resolution: - {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - - tinyexec@1.1.2: - resolution: - {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} - engines: {node: '>=18'} - - tinyglobby@0.2.16: - resolution: - {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} - engines: {node: '>=12.0.0'} - - tinyrainbow@3.1.0: - resolution: - {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} - engines: {node: '>=14.0.0'} - - tslib@2.8.1: - resolution: - {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - - type-check@0.4.0: - resolution: - {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} - engines: {node: '>= 0.8.0'} - - typescript@5.9.3: - resolution: - {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true - - undici-types@7.14.0: - resolution: - {integrity: sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==} - - uri-js@4.4.1: - resolution: - {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - - vite@8.0.13: - resolution: - {integrity: sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.1.18 - esbuild: ^0.27.0 || ^0.28.0 - jiti: '>=1.21.0' - less: ^4.0.0 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: '>=0.54.8' - sugarss: ^5.0.0 - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - '@types/node': - optional: true - '@vitejs/devtools': - optional: true - esbuild: - optional: true - jiti: - optional: true - less: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - - vitest@4.1.3: - resolution: - {integrity: sha512-DBc4Tx0MPNsqb9isoyOq00lHftVx/KIU44QOm2q59npZyLUkENn8TMFsuzuO+4U2FUa9rgbbPt3udrP25GcjXw==} - engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} - hasBin: true - peerDependencies: - '@edge-runtime/vm': '*' - '@opentelemetry/api': ^1.9.0 - '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.3 - '@vitest/browser-preview': 4.1.3 - '@vitest/browser-webdriverio': 4.1.3 - '@vitest/coverage-istanbul': 4.1.3 - '@vitest/coverage-v8': 4.1.3 - '@vitest/ui': 4.1.3 - happy-dom: '*' - jsdom: '*' - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 - peerDependenciesMeta: - '@edge-runtime/vm': - optional: true - '@opentelemetry/api': - optional: true - '@types/node': - optional: true - '@vitest/browser-playwright': - optional: true - '@vitest/browser-preview': - optional: true - '@vitest/browser-webdriverio': - optional: true - '@vitest/coverage-istanbul': - optional: true - '@vitest/coverage-v8': - optional: true - '@vitest/ui': - optional: true - happy-dom: - optional: true - jsdom: - optional: true - - which@2.0.2: - resolution: - {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - - why-is-node-running@2.3.0: - resolution: - {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} - engines: {node: '>=8'} - hasBin: true - - word-wrap@1.2.5: - resolution: - {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} - engines: {node: '>=0.10.0'} - - yocto-queue@0.1.0: - resolution: - {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} - engines: {node: '>=10'} - -snapshots: - '@clack/core@0.4.1': - dependencies: - picocolors: 1.1.1 - sisteransi: 1.0.5 - - '@clack/prompts@0.9.1': - dependencies: - '@clack/core': 0.4.1 - picocolors: 1.1.1 - sisteransi: 1.0.5 - - '@emnapi/core@1.10.0': - dependencies: - '@emnapi/wasi-threads': 1.2.1 - tslib: 2.8.1 - optional: true - - '@emnapi/runtime@1.10.0': - dependencies: - tslib: 2.8.1 - optional: true - - '@emnapi/wasi-threads@1.2.1': - dependencies: - tslib: 2.8.1 - optional: true - - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)': - dependencies: - eslint: 9.39.4 - eslint-visitor-keys: 3.4.3 - - '@eslint-community/regexpp@4.12.2': {} - - '@eslint/config-array@0.21.2': - dependencies: - '@eslint/object-schema': 2.1.7 - debug: 4.4.3 - minimatch: 3.1.5 - transitivePeerDependencies: - - supports-color - - '@eslint/config-helpers@0.4.2': - dependencies: - '@eslint/core': 0.17.0 - - '@eslint/core@0.17.0': - dependencies: - '@types/json-schema': 7.0.15 - - '@eslint/eslintrc@3.3.5': - dependencies: - ajv: 6.15.0 - debug: 4.4.3 - espree: 10.4.0 - globals: 14.0.0 - ignore: 5.3.2 - import-fresh: 3.3.1 - js-yaml: 4.1.1 - minimatch: 3.1.5 - strip-json-comments: 3.1.1 - transitivePeerDependencies: - - supports-color - - '@eslint/js@9.39.4': {} - - '@eslint/object-schema@2.1.7': {} - - '@eslint/plugin-kit@0.4.1': - dependencies: - '@eslint/core': 0.17.0 - levn: 0.4.1 - - '@humanfs/core@0.19.2': - dependencies: - '@humanfs/types': 0.15.0 - - '@humanfs/node@0.16.8': - dependencies: - '@humanfs/core': 0.19.2 - '@humanfs/types': 0.15.0 - '@humanwhocodes/retry': 0.4.3 - - '@humanfs/types@0.15.0': {} - - '@humanwhocodes/module-importer@1.0.1': {} - - '@humanwhocodes/retry@0.4.3': {} - - '@jridgewell/sourcemap-codec@1.5.5': {} - - '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': - dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@tybys/wasm-util': 0.10.2 - optional: true - - '@oxc-project/types@0.130.0': {} - - '@oxc-project/types@0.95.0': {} - - '@rolldown/binding-android-arm64@1.0.0-beta.45': - optional: true - - '@rolldown/binding-android-arm64@1.0.1': - optional: true - - '@rolldown/binding-darwin-arm64@1.0.0-beta.45': - optional: true - - '@rolldown/binding-darwin-arm64@1.0.1': - optional: true - - '@rolldown/binding-darwin-x64@1.0.0-beta.45': - optional: true - - '@rolldown/binding-darwin-x64@1.0.1': - optional: true - - '@rolldown/binding-freebsd-x64@1.0.0-beta.45': - optional: true - - '@rolldown/binding-freebsd-x64@1.0.1': - optional: true - - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.45': - optional: true - - '@rolldown/binding-linux-arm-gnueabihf@1.0.1': - optional: true - - '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.45': - optional: true - - '@rolldown/binding-linux-arm64-gnu@1.0.1': - optional: true - - '@rolldown/binding-linux-arm64-musl@1.0.0-beta.45': - optional: true - - '@rolldown/binding-linux-arm64-musl@1.0.1': - optional: true - - '@rolldown/binding-linux-ppc64-gnu@1.0.1': - optional: true - - '@rolldown/binding-linux-s390x-gnu@1.0.1': - optional: true - - '@rolldown/binding-linux-x64-gnu@1.0.0-beta.45': - optional: true - - '@rolldown/binding-linux-x64-gnu@1.0.1': - optional: true - - '@rolldown/binding-linux-x64-musl@1.0.0-beta.45': - optional: true - - '@rolldown/binding-linux-x64-musl@1.0.1': - optional: true - - '@rolldown/binding-openharmony-arm64@1.0.0-beta.45': - optional: true - - '@rolldown/binding-openharmony-arm64@1.0.1': - optional: true - - '@rolldown/binding-wasm32-wasi@1.0.0-beta.45(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': - dependencies: - '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' - optional: true - - '@rolldown/binding-wasm32-wasi@1.0.1': - dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) - optional: true - - '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.45': - optional: true - - '@rolldown/binding-win32-arm64-msvc@1.0.1': - optional: true - - '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.45': - optional: true - - '@rolldown/binding-win32-x64-msvc@1.0.0-beta.45': - optional: true - - '@rolldown/binding-win32-x64-msvc@1.0.1': - optional: true - - '@rolldown/pluginutils@1.0.0-beta.45': {} - - '@rolldown/pluginutils@1.0.1': {} - - '@standard-schema/spec@1.1.0': {} - - '@tybys/wasm-util@0.10.2': - dependencies: - tslib: 2.8.1 - optional: true - - '@types/chai@5.2.3': - dependencies: - '@types/deep-eql': 4.0.2 - assertion-error: 2.0.1 - - '@types/deep-eql@4.0.2': {} - - '@types/estree@1.0.9': {} - - '@types/json-schema@7.0.15': {} - - '@types/node@24.7.2': - dependencies: - undici-types: 7.14.0 - - '@vitest/expect@4.1.3': - dependencies: - '@standard-schema/spec': 1.1.0 - '@types/chai': 5.2.3 - '@vitest/spy': 4.1.3 - '@vitest/utils': 4.1.3 - chai: 6.2.2 - tinyrainbow: 3.1.0 - - '@vitest/mocker@4.1.3(vite@8.0.13(@types/node@24.7.2))': - dependencies: - '@vitest/spy': 4.1.3 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 8.0.13(@types/node@24.7.2) - - '@vitest/pretty-format@4.1.3': - dependencies: - tinyrainbow: 3.1.0 - - '@vitest/runner@4.1.3': - dependencies: - '@vitest/utils': 4.1.3 - pathe: 2.0.3 - - '@vitest/snapshot@4.1.3': - dependencies: - '@vitest/pretty-format': 4.1.3 - '@vitest/utils': 4.1.3 - magic-string: 0.30.21 - pathe: 2.0.3 - - '@vitest/spy@4.1.3': {} - - '@vitest/utils@4.1.3': - dependencies: - '@vitest/pretty-format': 4.1.3 - convert-source-map: 2.0.0 - tinyrainbow: 3.1.0 - - acorn-jsx@5.3.2(acorn@8.16.0): - dependencies: - acorn: 8.16.0 - - acorn@8.16.0: {} - - ajv@6.15.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-json-stable-stringify: 2.1.0 - json-schema-traverse: 0.4.1 - uri-js: 4.4.1 - - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 - - argparse@2.0.1: {} - - assertion-error@2.0.1: {} - - balanced-match@1.0.2: {} - - balanced-match@4.0.4: {} - - brace-expansion@1.1.14: - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - - brace-expansion@5.0.6: - dependencies: - balanced-match: 4.0.4 - - callsites@3.1.0: {} - - chai@6.2.2: {} - - chalk@4.1.2: - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.4: {} - - concat-map@0.0.1: {} - - convert-source-map@2.0.0: {} - - cross-spawn@7.0.6: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - - debug@4.4.3: - dependencies: - ms: 2.1.3 - - deep-is@0.1.4: {} - - detect-libc@2.1.2: {} - - es-module-lexer@2.1.0: {} - - escape-string-regexp@4.0.0: {} - - eslint-scope@8.4.0: - dependencies: - esrecurse: 4.3.0 - estraverse: 5.3.0 - - eslint-visitor-keys@3.4.3: {} - - eslint-visitor-keys@4.2.1: {} - - eslint@9.39.4: - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) - '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.2 - '@eslint/config-helpers': 0.4.2 - '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.5 - '@eslint/js': 9.39.4 - '@eslint/plugin-kit': 0.4.1 - '@humanfs/node': 0.16.8 - '@humanwhocodes/module-importer': 1.0.1 - '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.9 - ajv: 6.15.0 - chalk: 4.1.2 - cross-spawn: 7.0.6 - debug: 4.4.3 - escape-string-regexp: 4.0.0 - eslint-scope: 8.4.0 - eslint-visitor-keys: 4.2.1 - espree: 10.4.0 - esquery: 1.7.0 - esutils: 2.0.3 - fast-deep-equal: 3.1.3 - file-entry-cache: 8.0.0 - find-up: 5.0.0 - glob-parent: 6.0.2 - ignore: 5.3.2 - imurmurhash: 0.1.4 - is-glob: 4.0.3 - json-stable-stringify-without-jsonify: 1.0.1 - lodash.merge: 4.6.2 - minimatch: 3.1.5 - natural-compare: 1.4.0 - optionator: 0.9.4 - transitivePeerDependencies: - - supports-color - - espree@10.4.0: - dependencies: - acorn: 8.16.0 - acorn-jsx: 5.3.2(acorn@8.16.0) - eslint-visitor-keys: 4.2.1 - - esquery@1.7.0: - dependencies: - estraverse: 5.3.0 - - esrecurse@4.3.0: - dependencies: - estraverse: 5.3.0 - - estraverse@5.3.0: {} - - estree-walker@3.0.3: - dependencies: - '@types/estree': 1.0.9 - - esutils@2.0.3: {} - - expect-type@1.3.0: {} - - fast-deep-equal@3.1.3: {} - - fast-json-stable-stringify@2.1.0: {} - - fast-levenshtein@2.0.6: {} - - fdir@6.5.0(picomatch@4.0.4): - optionalDependencies: - picomatch: 4.0.4 - - file-entry-cache@8.0.0: - dependencies: - flat-cache: 4.0.1 - - find-up@5.0.0: - dependencies: - locate-path: 6.0.0 - path-exists: 4.0.0 - - flat-cache@4.0.1: - dependencies: - flatted: 3.4.2 - keyv: 4.5.4 - - flatted@3.4.2: {} - - fsevents@2.3.3: - optional: true - - glob-parent@6.0.2: - dependencies: - is-glob: 4.0.3 - - glob@13.0.6: - dependencies: - minimatch: 10.2.5 - minipass: 7.1.3 - path-scurry: 2.0.2 - - globals@14.0.0: {} - - has-flag@4.0.0: {} - - ignore@5.3.2: {} - - import-fresh@3.3.1: - dependencies: - parent-module: 1.0.1 - resolve-from: 4.0.0 - - imurmurhash@0.1.4: {} - - is-extglob@2.1.1: {} - - is-glob@4.0.3: - dependencies: - is-extglob: 2.1.1 - - isexe@2.0.0: {} - - js-yaml@4.1.1: - dependencies: - argparse: 2.0.1 - - json-buffer@3.0.1: {} - - json-schema-traverse@0.4.1: {} - - json-stable-stringify-without-jsonify@1.0.1: {} - - keyv@4.5.4: - dependencies: - json-buffer: 3.0.1 - - levn@0.4.1: - dependencies: - prelude-ls: 1.2.1 - type-check: 0.4.0 - - lightningcss-android-arm64@1.32.0: - optional: true - - lightningcss-darwin-arm64@1.32.0: - optional: true - - lightningcss-darwin-x64@1.32.0: - optional: true - - lightningcss-freebsd-x64@1.32.0: - optional: true - - lightningcss-linux-arm-gnueabihf@1.32.0: - optional: true - - lightningcss-linux-arm64-gnu@1.32.0: - optional: true - - lightningcss-linux-arm64-musl@1.32.0: - optional: true - - lightningcss-linux-x64-gnu@1.32.0: - optional: true - - lightningcss-linux-x64-musl@1.32.0: - optional: true - - lightningcss-win32-arm64-msvc@1.32.0: - optional: true - - lightningcss-win32-x64-msvc@1.32.0: - optional: true - - lightningcss@1.32.0: - dependencies: - detect-libc: 2.1.2 - optionalDependencies: - lightningcss-android-arm64: 1.32.0 - lightningcss-darwin-arm64: 1.32.0 - lightningcss-darwin-x64: 1.32.0 - lightningcss-freebsd-x64: 1.32.0 - lightningcss-linux-arm-gnueabihf: 1.32.0 - lightningcss-linux-arm64-gnu: 1.32.0 - lightningcss-linux-arm64-musl: 1.32.0 - lightningcss-linux-x64-gnu: 1.32.0 - lightningcss-linux-x64-musl: 1.32.0 - lightningcss-win32-arm64-msvc: 1.32.0 - lightningcss-win32-x64-msvc: 1.32.0 - - locate-path@6.0.0: - dependencies: - p-locate: 5.0.0 - - lodash.merge@4.6.2: {} - - lru-cache@11.4.0: {} - - magic-string@0.30.21: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - - minimatch@10.2.5: - dependencies: - brace-expansion: 5.0.6 - - minimatch@3.1.5: - dependencies: - brace-expansion: 1.1.14 - - minipass@7.1.3: {} - - ms@2.1.3: {} - - nanoid@3.3.12: {} - - natural-compare@1.4.0: {} - - obug@2.1.1: {} - - optionator@0.9.4: - dependencies: - deep-is: 0.1.4 - fast-levenshtein: 2.0.6 - levn: 0.4.1 - prelude-ls: 1.2.1 - type-check: 0.4.0 - word-wrap: 1.2.5 - - p-limit@3.1.0: - dependencies: - yocto-queue: 0.1.0 - - p-locate@5.0.0: - dependencies: - p-limit: 3.1.0 - - package-json-from-dist@1.0.1: {} - - parent-module@1.0.1: - dependencies: - callsites: 3.1.0 - - path-exists@4.0.0: {} - - path-key@3.1.1: {} - - path-scurry@2.0.2: - dependencies: - lru-cache: 11.4.0 - minipass: 7.1.3 - - pathe@2.0.3: {} - - picocolors@1.1.1: {} - - picomatch@4.0.4: {} - - postcss@8.5.15: - dependencies: - nanoid: 3.3.12 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - prelude-ls@1.2.1: {} - - prettier@3.6.2: {} - - punycode@2.3.1: {} - - resolve-from@4.0.0: {} - - rimraf@6.1.3: - dependencies: - glob: 13.0.6 - package-json-from-dist: 1.0.1 - - rolldown@1.0.0-beta.45(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0): - dependencies: - '@oxc-project/types': 0.95.0 - '@rolldown/pluginutils': 1.0.0-beta.45 - optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.0-beta.45 - '@rolldown/binding-darwin-arm64': 1.0.0-beta.45 - '@rolldown/binding-darwin-x64': 1.0.0-beta.45 - '@rolldown/binding-freebsd-x64': 1.0.0-beta.45 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.45 - '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.45 - '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.45 - '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.45 - '@rolldown/binding-linux-x64-musl': 1.0.0-beta.45 - '@rolldown/binding-openharmony-arm64': 1.0.0-beta.45 - '@rolldown/binding-wasm32-wasi': 1.0.0-beta.45(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) - '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.45 - '@rolldown/binding-win32-ia32-msvc': 1.0.0-beta.45 - '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.45 - transitivePeerDependencies: - - '@emnapi/core' - - '@emnapi/runtime' - - rolldown@1.0.1: - dependencies: - '@oxc-project/types': 0.130.0 - '@rolldown/pluginutils': 1.0.1 - optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.1 - '@rolldown/binding-darwin-arm64': 1.0.1 - '@rolldown/binding-darwin-x64': 1.0.1 - '@rolldown/binding-freebsd-x64': 1.0.1 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.1 - '@rolldown/binding-linux-arm64-gnu': 1.0.1 - '@rolldown/binding-linux-arm64-musl': 1.0.1 - '@rolldown/binding-linux-ppc64-gnu': 1.0.1 - '@rolldown/binding-linux-s390x-gnu': 1.0.1 - '@rolldown/binding-linux-x64-gnu': 1.0.1 - '@rolldown/binding-linux-x64-musl': 1.0.1 - '@rolldown/binding-openharmony-arm64': 1.0.1 - '@rolldown/binding-wasm32-wasi': 1.0.1 - '@rolldown/binding-win32-arm64-msvc': 1.0.1 - '@rolldown/binding-win32-x64-msvc': 1.0.1 - - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - - siginfo@2.0.0: {} - - sisteransi@1.0.5: {} - - source-map-js@1.2.1: {} - - stackback@0.0.2: {} - - std-env@4.1.0: {} - - strip-json-comments@3.1.1: {} - - supports-color@7.2.0: - dependencies: - has-flag: 4.0.0 - - tinybench@2.9.0: {} - - tinyexec@1.1.2: {} - - tinyglobby@0.2.16: - dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - - tinyrainbow@3.1.0: {} - - tslib@2.8.1: - optional: true - - type-check@0.4.0: - dependencies: - prelude-ls: 1.2.1 - - typescript@5.9.3: {} - - undici-types@7.14.0: {} - - uri-js@4.4.1: - dependencies: - punycode: 2.3.1 - - vite@8.0.13(@types/node@24.7.2): - dependencies: - lightningcss: 1.32.0 - picomatch: 4.0.4 - postcss: 8.5.15 - rolldown: 1.0.1 - tinyglobby: 0.2.16 - optionalDependencies: - '@types/node': 24.7.2 - fsevents: 2.3.3 - - vitest@4.1.3(@types/node@24.7.2)(vite@8.0.13(@types/node@24.7.2)): - dependencies: - '@vitest/expect': 4.1.3 - '@vitest/mocker': 4.1.3(vite@8.0.13(@types/node@24.7.2)) - '@vitest/pretty-format': 4.1.3 - '@vitest/runner': 4.1.3 - '@vitest/snapshot': 4.1.3 - '@vitest/spy': 4.1.3 - '@vitest/utils': 4.1.3 - es-module-lexer: 2.1.0 - expect-type: 1.3.0 - magic-string: 0.30.21 - obug: 2.1.1 - pathe: 2.0.3 - picomatch: 4.0.4 - std-env: 4.1.0 - tinybench: 2.9.0 - tinyexec: 1.1.2 - tinyglobby: 0.2.16 - tinyrainbow: 3.1.0 - vite: 8.0.13(@types/node@24.7.2) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/node': 24.7.2 - transitivePeerDependencies: - - msw - - which@2.0.2: - dependencies: - isexe: 2.0.0 - - why-is-node-running@2.3.0: - dependencies: - siginfo: 2.0.0 - stackback: 0.0.2 - - word-wrap@1.2.5: {} - - yocto-queue@0.1.0: {} diff --git a/tools/npx-thunderid/prettier.config.js b/tools/npx-thunderid/prettier.config.js deleted file mode 100644 index 7e978e97d6..0000000000 --- a/tools/npx-thunderid/prettier.config.js +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import config from '@thunderid/prettier-config'; - -export default config; diff --git a/tools/npx-thunderid/rolldown.config.js b/tools/npx-thunderid/rolldown.config.js deleted file mode 100644 index 20abe94c17..0000000000 --- a/tools/npx-thunderid/rolldown.config.js +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import {readFileSync} from 'fs'; -import {join} from 'path'; -import {defineConfig} from 'rolldown'; - -const pkg = JSON.parse(readFileSync('./package.json', 'utf8')); - -const external = [...Object.keys(pkg.dependencies || {}), ...Object.keys(pkg.peerDependencies || {})]; - -export default defineConfig({ - input: join('src', 'index.ts'), - external, - platform: 'node', - target: 'es2020', - sourcemap: true, - output: { - file: 'dist/index.cjs', - format: 'cjs', - }, -}); diff --git a/tools/npx-thunderid/src/constants/ThunderRepo.ts b/tools/npx-thunderid/src/constants/ThunderRepo.ts deleted file mode 100644 index 242314fb18..0000000000 --- a/tools/npx-thunderid/src/constants/ThunderRepo.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -const ThunderRepo = { - DOMAIN: 'thunderid.dev', - REPO: 'thunder-id/thunderid', - HANDLE: 'thunderid', -} as const; - -export default ThunderRepo; diff --git a/tools/npx-thunderid/src/deploy/index.ts b/tools/npx-thunderid/src/deploy/index.ts deleted file mode 100644 index fa6d04e7c1..0000000000 --- a/tools/npx-thunderid/src/deploy/index.ts +++ /dev/null @@ -1,374 +0,0 @@ -/** - * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import {spawnSync, execSync} from 'child_process'; -import * as fs from 'fs'; -import * as path from 'path'; -import {intro, outro, select, text, confirm, spinner, note, isCancel, cancel} from '@clack/prompts'; -import colors from 'picocolors'; -import {loadRecipes} from './recipes/index'; -import Product from '../constants/Product'; -import {getLatestThunderVersion} from '../download'; -import type {DbType} from '../models/db'; -import type {Recipe} from '../models/deploy'; -import {readState} from '../state'; - -function getDeploymentYamlContent(): string { - return ( - [ - 'server:', - ' hostname: "0.0.0.0"', - ' port: __SERVER_PORT__', - ' http_only: true', - ' public_url: "__PUBLIC_URL__"', - '', - 'gate_client:', - ' hostname: "__PUBLIC_HOST__"', - ' port: __GATE_PORT__', - ' scheme: "__GATE_SCHEME__"', - ' path: "/gate"', - '', - 'cors:', - ' allowed_origins:', - ' - "__PUBLIC_URL__"', - '', - 'passkey:', - ' allowed_origins:', - ' - "__PUBLIC_URL__"', - ].join('\n') + '\n' - ); -} - -function getDockerfileContent(version: string): string { - const dirName = `thunder-${version}-linux-x64`; - return `FROM alpine:3.19 -RUN apk add --no-cache sqlite openssl ca-certificates bash curl unzip lsof - -RUN mkdir -p /app \\ - && curl -fsSL -o /tmp/thunder.zip \\ - "https://github.com/asgardeo/thunder/releases/download/v${version}/${dirName}.zip" \\ - && unzip /tmp/thunder.zip -d /app \\ - && rm /tmp/thunder.zip - -WORKDIR /app/${dirName} - -# Replace the bundled deployment.yaml with a cloud-ready template. -# Placeholders are substituted at runtime by entrypoint.sh using provider env vars. -COPY .thunderdeploy/deployment.yaml repository/conf/deployment.yaml - -RUN addgroup -S thunder && adduser -S thunder -G thunder \\ - && chown -R thunder:thunder . - -COPY .thunderdeploy/entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh - -USER thunder -EXPOSE 8090 -ENTRYPOINT ["/entrypoint.sh"] -`; -} - -function getEntrypointContent(): string { - return `#!/bin/bash -set -e - -# Resolve the public URL from provider-injected environment variables. -# Each platform sets a different variable; we normalise them into PUBLIC_URL. -# Users can also set PUBLIC_URL explicitly to override auto-detection. -if [ -z "$PUBLIC_URL" ]; then - if [ -n "$RAILWAY_PUBLIC_DOMAIN" ]; then - PUBLIC_URL="https://$RAILWAY_PUBLIC_DOMAIN" - elif [ -n "$RENDER_EXTERNAL_URL" ]; then - PUBLIC_URL="$RENDER_EXTERNAL_URL" - elif [ -n "$FLY_APP_NAME" ]; then - PUBLIC_URL="https://$FLY_APP_NAME.fly.dev" - fi -fi - -# Railway (and other platforms) inject PORT โ€” use it so the proxy routes to the right port. -SERVER_PORT="\${PORT:-8090}" - -# Fill in deployment.yaml placeholders with the resolved public URL and port. -DEPLOY_YAML="repository/conf/deployment.yaml" -if [ -n "$PUBLIC_URL" ]; then - PUBLIC_HOST=$(echo "$PUBLIC_URL" | sed 's|https://||; s|http://||; s|[:/].*||') - if echo "$PUBLIC_URL" | grep -q "^https://"; then - GATE_SCHEME="https" - GATE_PORT="443" - else - GATE_SCHEME="http" - GATE_PORT="$SERVER_PORT" - fi -else - PUBLIC_URL="http://localhost:$SERVER_PORT" - PUBLIC_HOST="localhost" - GATE_SCHEME="http" - GATE_PORT="$SERVER_PORT" -fi -sed -i "s|__PUBLIC_URL__|$PUBLIC_URL|g" "$DEPLOY_YAML" -sed -i "s|__PUBLIC_HOST__|$PUBLIC_HOST|g" "$DEPLOY_YAML" -sed -i "s|__GATE_SCHEME__|$GATE_SCHEME|g" "$DEPLOY_YAML" -sed -i "s|__GATE_PORT__|$GATE_PORT|g" "$DEPLOY_YAML" -sed -i "s|__SERVER_PORT__|$SERVER_PORT|g" "$DEPLOY_YAML" - -# Use /data as sentinel location when a volume is mounted (e.g. Fly.io SQLite), -# otherwise fall back to WORKDIR (resets on redeploy, which is correct since the DB does too). -if [ -d "/data" ]; then - SENTINEL="/data/.thunder-setup-complete" -else - SENTINEL=".setup-complete" -fi - -if [ ! -f "$SENTINEL" ]; then - # setup.sh reads hostname from deployment.yaml to build its BASE_URL for health polling. - # "0.0.0.0" is the right binding address for the server but is not a valid client destination โ€” - # curl to http://0.0.0.0:8090 fails or times out on every retry, stalling setup for minutes. - # Swap to localhost just for the setup phase, then restore the binding address afterwards. - sed -i 's|hostname: "0.0.0.0"|hostname: "localhost"|g' "$DEPLOY_YAML" - THUNDER_SKIP_SECURITY=true bash setup.sh - sed -i 's|hostname: "localhost"|hostname: "0.0.0.0"|g' "$DEPLOY_YAML" - touch "$SENTINEL" - # In newer Thunder versions, setup.sh invokes start.sh internally and captures that PID. - # Killing start.sh leaves the Thunder binary and the embedded OpenFGA server as orphans. - # start.sh refuses to run if either port is occupied, which exits the container โ†’ 502. - lsof -ti tcp:"$SERVER_PORT" 2>/dev/null | xargs kill -9 2>/dev/null || true - lsof -ti tcp:9090 2>/dev/null | xargs kill -9 2>/dev/null || true - sleep 1 -fi - -# Patch config.js files AFTER setup so that setup.sh cannot overwrite the changes. -# Only patch when the public domain is actually resolved (RAILWAY_PUBLIC_DOMAIN etc. may not be -# injected on the very first container start for a brand-new service). Track the last-patched -# hostname in a stamp file so we re-apply whenever the domain changes between restarts. -if [ -d "/data" ]; then - DOMAIN_STAMP="/data/.thunder-patched-domain" -else - DOMAIN_STAMP=".thunder-patched-domain" -fi -LAST_DOMAIN=$(cat "$DOMAIN_STAMP" 2>/dev/null || echo "") - -if [ "$PUBLIC_HOST" != "localhost" ] && [ "$PUBLIC_HOST" != "$LAST_DOMAIN" ]; then - for CONFIG_FILE in apps/console/config.js apps/gate/config.js; do - if [ -f "$CONFIG_FILE" ]; then - # Replace any quoted hostname value (handles localhost and previously-patched domains). - sed -i "s|hostname: '[^']*'|hostname: '$PUBLIC_HOST'|g" "$CONFIG_FILE" - # Replace any numeric port value in the server block. - sed -i "s|port: [0-9]*|port: $GATE_PORT|g" "$CONFIG_FILE" - # Set http_only to match the actual scheme (Thunder now ships with http_only: true). - if [ "$GATE_SCHEME" = "https" ]; then - sed -i "s|http_only: true|http_only: false|g" "$CONFIG_FILE" - else - sed -i "s|http_only: false|http_only: true|g" "$CONFIG_FILE" - fi - fi - done - echo "$PUBLIC_HOST" > "$DOMAIN_STAMP" -fi - -# Forward the resolved port to start.sh so Thunder binds on Railway's expected port. -export BACKEND_PORT="$SERVER_PORT" -exec bash start.sh -`; -} - -function isCLIAvailable(cliName: string | undefined): boolean { - if (!cliName) return true; - const result = spawnSync(cliName, ['--version'], {stdio: 'pipe'}); - return !result.error && result.status === 0; -} - -async function ensureCLI(recipe: Recipe): Promise { - if (!recipe.cliName || isCLIAvailable(recipe.cliName)) return; - - note( - `${colors.cyan(recipe.cliName)} is not installed.\n\nInstall command:\n ${colors.bold(recipe.installCmd)}`, - `${recipe.displayName} โ€” setup needed`, - ); - - const shouldInstall = await confirm({ - message: `Install ${colors.cyan(recipe.cliName)} now?`, - initialValue: true, - }); - - if (isCancel(shouldInstall) || !shouldInstall) { - cancel(`Install ${recipe.cliName} and re-run to continue.`); - process.exit(0); - } - - if (!recipe.installCmd) return; - - const s = spinner(); - s.start(`Installing ${recipe.cliName}...`); - try { - execSync(recipe.installCmd, {stdio: 'pipe'}); - s.stop(`${recipe.cliName} installed`); - } catch (err) { - s.stop(`Install failed: ${(err as Error).message}`); - note(`Run this manually, then re-run deploy:\n ${colors.bold(recipe.installCmd)}`, 'Manual install needed'); - process.exit(1); - } - - if (recipe.postInstallPath) { - process.env['PATH'] = `${recipe.postInstallPath}${path.delimiter}${process.env['PATH']}`; - } - - if (!isCLIAvailable(recipe.cliName)) { - note( - `Installed but ${colors.cyan(recipe.cliName)} isn't on PATH yet.\n\nRestart your terminal, then run:\n ${colors.bold('npx thunderid deploy')}`, - 'Restart terminal needed', - ); - process.exit(0); - } -} - -export async function deploy(): Promise { - // eslint-disable-next-line no-console - console.clear(); - - intro(colors.bold(`โšก ${Product.NAME}`) + colors.dim(' โ€” Deploy')); - - let version: string; - const localState = readState(); - if (localState.lastUsedVersion) { - version = localState.lastUsedVersion; - note(`Deploying the version you tested locally: v${version}`, 'Version'); - } else { - const s = spinner(); - s.start('Fetching latest Thunder release...'); - try { - version = await getLatestThunderVersion(); - s.stop(`Thunder v${version}`); - } catch (err) { - s.stop('Could not fetch latest Thunder release.'); - process.stderr.write(`\nError: ${(err as Error).message}\n`); - process.exit(1); - } - } - - const recipes = loadRecipes(); - - const availability: Record = Object.fromEntries( - recipes.map((r) => [r.id, isCLIAvailable(r.cliName)]), - ); - - const recipeId = await select({ - message: 'Deploy to which platform?', - initialValue: 'railway', - options: [ - ...recipes - .filter((r) => !r.comingSoon) - .map((r) => ({ - value: r.id, - label: r.displayName, - hint: availability[r.id] ? r.description : `${r.description} โ€” ${colors.yellow(`needs ${r.cliName}`)}`, - })), - ...recipes - .filter((r) => r.comingSoon) - .map((r) => ({ - value: r.id, - label: colors.dim(r.displayName), - hint: colors.dim('Coming soon'), - disabled: true, - })), - ], - }); - - if (isCancel(recipeId)) { - cancel('Deploy cancelled.'); - process.exit(0); - } - - const recipe = recipes.find((r) => r.id === recipeId); - if (!recipe) { - cancel('Unknown recipe selected.'); - process.exit(1); - } - - await ensureCLI(recipe); - - try { - await recipe.preflight(); - } catch (err) { - process.stderr.write(`\n${colors.red('Preflight failed:')} ${(err as Error).message}\n`); - process.exit(1); - } - - const dbType = await select({ - message: 'Which database?', - options: [ - {value: 'sqlite', label: 'SQLite', hint: 'Embedded, zero-config (recommended)'}, - {value: 'postgres', label: 'PostgreSQL / Supabase', hint: 'External managed database'}, - ], - }); - - if (isCancel(dbType)) { - cancel('Deploy cancelled.'); - process.exit(0); - } - - let dbUrl: string | undefined; - if (dbType === 'postgres') { - const dbUrlInput = await text({ - message: 'DATABASE_URL:', - placeholder: 'postgresql://user:pass@db.example.com/dbname', - validate: (v) => (v ? undefined : 'DATABASE_URL is required'), - }); - if (isCancel(dbUrlInput)) { - cancel('Deploy cancelled.'); - process.exit(0); - } - dbUrl = dbUrlInput; - } - - let appName: string | undefined; - if (recipe.needsAppName !== false) { - const defaultName = `thunder-${Math.random().toString(36).slice(2, 7)}`; - const appNameInput = await text({ - message: 'App name:', - placeholder: defaultName, - defaultValue: defaultName, - }); - - if (isCancel(appNameInput)) { - cancel('Deploy cancelled.'); - process.exit(0); - } - - appName = appNameInput || defaultName; - } - - const deployDir = path.join(process.cwd(), '.thunderdeploy'); - fs.mkdirSync(deployDir, {recursive: true}); - fs.writeFileSync(path.join(deployDir, 'deployment.yaml'), getDeploymentYamlContent(), 'utf8'); - fs.writeFileSync(path.join(deployDir, 'entrypoint.sh'), getEntrypointContent(), 'utf8'); - - const dockerfilePath = path.join(process.cwd(), 'Dockerfile'); - if (fs.existsSync(dockerfilePath)) { - note('Existing Dockerfile found โ€” it will be overwritten.', 'Warning'); - } - fs.writeFileSync(dockerfilePath, getDockerfileContent(version), 'utf8'); - - try { - await recipe.deploy({appName, dbType: dbType as DbType, dbUrl, thunderVersion: version}); - } catch (err) { - process.stderr.write(`\n${colors.red('Deploy failed:')} ${(err as Error).message}\n`); - process.exit(1); - } - - outro(colors.green(`${Product.NAME} v${version} deployed${appName ? ` as ${colors.bold(appName)}` : ''}`)); -} diff --git a/tools/npx-thunderid/src/deploy/recipes/fly.ts b/tools/npx-thunderid/src/deploy/recipes/fly.ts deleted file mode 100644 index 3d4b1e3f4d..0000000000 --- a/tools/npx-thunderid/src/deploy/recipes/fly.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import {execSync, spawnSync} from 'child_process'; -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import {log} from '@clack/prompts'; -import colors from 'picocolors'; -import type {Recipe, DeployOptions} from '../../models/deploy'; - -interface FlyTomlOptions { - appName: string; - dbType: string; -} - -function getFlyToml({appName, dbType}: FlyTomlOptions): string { - const lines = [ - `app = "${appName}"`, - `primary_region = "iad"`, - ``, - `[http_service]`, - ` internal_port = 8090`, - ` force_https = true`, - ` auto_stop_machines = true`, - ` auto_start_machines = true`, - ` min_machines_running = 0`, - ]; - - if (dbType === 'sqlite') { - lines.push(``, `[[mounts]]`, ` source = "thunder_data"`, ` destination = "/data"`); - } - - return lines.join('\n') + '\n'; -} - -const fly: Recipe = { - id: 'fly', - displayName: 'Fly.io', - description: 'Free tier, persistent volumes for SQLite, single command', - comingSoon: true, - cliName: 'flyctl', - installCmd: 'curl -L https://fly.io/install.sh | sh', - postInstallPath: path.join(os.homedir(), '.fly', 'bin'), - - preflight(): Promise { - const auth = spawnSync('flyctl', ['auth', 'whoami'], {stdio: 'pipe'}); - if (auth.status !== 0) { - log.info('Not logged in to Fly.io โ€” opening browser to authenticate...'); - execSync('flyctl auth login', {stdio: 'inherit'}); - } - return Promise.resolve(); - }, - - deploy({appName = 'thunder-app', dbType, dbUrl}: DeployOptions): Promise { - const cwd = process.cwd(); - - fs.writeFileSync(path.join(cwd, 'fly.toml'), getFlyToml({appName, dbType}), 'utf8'); - log.success('Generated fly.toml'); - - log.info(`Creating Fly.io app: ${colors.cyan(appName)}`); - execSync(`flyctl launch --name "${appName}" --no-deploy --copy-config --yes`, {stdio: 'inherit'}); - - if (dbType === 'sqlite') { - log.info('Creating persistent volume for SQLite...'); - execSync(`flyctl volumes create thunder_data --size 1 --yes --app "${appName}"`, {stdio: 'inherit'}); - } - - if (dbType === 'postgres' && dbUrl) { - log.info('Setting database secret...'); - execSync(`flyctl secrets set "DATABASE_URL=${dbUrl}" --app "${appName}"`, {stdio: 'inherit'}); - } - - log.info('Building and deploying (this takes a few minutes)...'); - execSync('flyctl deploy', {stdio: 'inherit'}); - - log.success(`${colors.bold(colors.green('Deployed!'))} ${colors.cyan(`https://${appName}.fly.dev`)}`); - return Promise.resolve(); - }, -}; - -export default fly; diff --git a/tools/npx-thunderid/src/deploy/recipes/index.ts b/tools/npx-thunderid/src/deploy/recipes/index.ts deleted file mode 100644 index 5b7db41be4..0000000000 --- a/tools/npx-thunderid/src/deploy/recipes/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import fly from './fly'; -import railway from './railway'; -import render from './render'; -import type {Recipe} from '../../models/deploy'; - -export function loadRecipes(): Recipe[] { - return [railway, fly, render]; -} diff --git a/tools/npx-thunderid/src/deploy/recipes/railway.ts b/tools/npx-thunderid/src/deploy/recipes/railway.ts deleted file mode 100644 index 1cb5f7c384..0000000000 --- a/tools/npx-thunderid/src/deploy/recipes/railway.ts +++ /dev/null @@ -1,136 +0,0 @@ -/** - * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import {execSync, spawnSync} from 'child_process'; -import * as fs from 'fs'; -import * as path from 'path'; -import {log, select, text, isCancel, cancel} from '@clack/prompts'; -import colors from 'picocolors'; -import type {Recipe, DeployOptions} from '../../models/deploy'; - -interface RailwayProject { - id: string; - name: string; -} - -function getRailwayToml(): string { - return [`[build]`, ` builder = "dockerfile"`, ``, `[deploy]`, ` healthcheckTimeout = 300`].join('\n') + '\n'; -} - -const railway: Recipe = { - id: 'railway', - displayName: 'Railway', - description: 'Simple deploys, built-in managed Postgres option', - cliName: 'railway', - installCmd: 'npm install -g @railway/cli', - needsAppName: false, - - preflight(): Promise { - const auth = spawnSync('railway', ['whoami'], {stdio: 'pipe'}); - if (auth.status !== 0) { - log.info('Not logged in to Railway โ€” opening browser to authenticate...'); - execSync('railway login', {stdio: 'inherit'}); - } - return Promise.resolve(); - }, - - async deploy({dbType, dbUrl}: DeployOptions) { - let appName: string | undefined; - const cwd = process.cwd(); - - let existingProjects: RailwayProject[] = []; - try { - const result = spawnSync('railway', ['list', '--json'], {stdio: 'pipe', encoding: 'utf8'}); - if (result.status === 0) existingProjects = JSON.parse(result.stdout) as RailwayProject[]; - } catch { - /** - * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - } - - let linkToProject: string | null = null; - if (existingProjects.length > 0) { - const choice = await select({ - message: 'Railway project:', - options: [ - ...existingProjects.map((p) => ({value: p.id, label: p.name})), - {value: '__new__', label: 'Create new project'}, - ], - }); - if (isCancel(choice)) { - cancel('Deploy cancelled.'); - process.exit(0); - } - if (choice !== '__new__') linkToProject = choice; - } - - fs.writeFileSync(path.join(cwd, 'railway.toml'), getRailwayToml(), 'utf8'); - log.success('Generated railway.toml'); - - if (linkToProject) { - log.info('Linking to existing Railway project...'); - execSync(`railway link -p "${linkToProject}"`, {stdio: 'inherit'}); - } else { - const defaultName = `thunder-${Math.random().toString(36).slice(2, 7)}`; - const appNameInput = await text({ - message: 'App name:', - placeholder: defaultName, - defaultValue: defaultName, - }); - if (isCancel(appNameInput)) { - cancel('Deploy cancelled.'); - process.exit(0); - } - appName = appNameInput || defaultName; - log.info(`Initializing Railway project: ${colors.cyan(appName)}`); - execSync(`railway init --name "${appName}"`, {stdio: 'inherit'}); - } - - if (dbType === 'postgres' && dbUrl) { - log.info('Setting DATABASE_URL...'); - execSync(`railway variables set "DATABASE_URL=${dbUrl}"`, {stdio: 'inherit'}); - } - - log.info('Deploying (this takes a few minutes)...'); - execSync('railway up --detach', {stdio: 'inherit'}); - - const domainResult = spawnSync('railway', ['domain'], {stdio: 'pipe', encoding: 'utf8'}); - const domain = domainResult.stdout?.trim(); - if (domain) { - log.success(`${colors.bold(colors.green('Deployed!'))} ${colors.cyan(`https://${domain}`)}`); - } else { - log.success(`${colors.bold(colors.green('Deployed!'))} Run ${colors.cyan('railway open')} to view your app.`); - } - }, -}; - -export default railway; diff --git a/tools/npx-thunderid/src/deploy/recipes/render.ts b/tools/npx-thunderid/src/deploy/recipes/render.ts deleted file mode 100644 index 1589dcf292..0000000000 --- a/tools/npx-thunderid/src/deploy/recipes/render.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import * as fs from 'fs'; -import * as path from 'path'; -import {log, note} from '@clack/prompts'; -import colors from 'picocolors'; -import type {Recipe, DeployOptions} from '../../models/deploy'; - -interface RenderYamlOptions { - appName: string; - dbType: string; -} - -function getRenderYaml({appName, dbType}: RenderYamlOptions): string { - const lines = [ - `services:`, - ` - type: web`, - ` name: ${appName}`, - ` env: docker`, - ` dockerfilePath: ./Dockerfile`, - ` dockerContext: .`, - ` healthCheckPath: /health`, - ]; - - if (dbType === 'sqlite') { - lines.push(` disk:`, ` name: thunder-data`, ` mountPath: /data`, ` sizeGB: 1`); - } - - if (dbType === 'postgres') { - lines.push(` envVars:`, ` - key: DATABASE_URL`, ` sync: false`); - } - - return lines.join('\n') + '\n'; -} - -const render: Recipe = { - id: 'render', - displayName: 'Render', - description: 'Free tier web services โ€” generates files, requires GitHub', - comingSoon: true, - - preflight(): Promise { - return Promise.resolve(); - }, - - deploy({appName = 'thunder-app', dbType}: DeployOptions): Promise { - const cwd = process.cwd(); - - fs.writeFileSync(path.join(cwd, 'render.yaml'), getRenderYaml({appName, dbType}), 'utf8'); - log.success('Generated render.yaml'); - - const steps = [ - `Files ready: ${colors.cyan('Dockerfile')} + ${colors.cyan('render.yaml')}`, - ``, - `Next steps:`, - ` 1. Commit and push this directory to a GitHub repository`, - ` 2. Go to ${colors.cyan('https://render.com')} โ†’ New โ†’ Web Service`, - ` 3. Connect your GitHub repo โ€” Render auto-detects ${colors.cyan('render.yaml')}`, - ]; - - if (dbType === 'postgres') { - steps.push(` 4. Set ${colors.cyan('DATABASE_URL')} under Environment in the Render dashboard`); - } - - note(steps.join('\n'), 'Render โ€” complete setup in the dashboard'); - return Promise.resolve(); - }, -}; - -export default render; diff --git a/tools/npx-thunderid/src/download.ts b/tools/npx-thunderid/src/download.ts deleted file mode 100644 index c19a4ce09e..0000000000 --- a/tools/npx-thunderid/src/download.ts +++ /dev/null @@ -1,189 +0,0 @@ -/** - * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import {execSync} from 'child_process'; -import * as fs from 'fs'; -import type {IncomingMessage} from 'http'; -import * as https from 'https'; -import * as os from 'os'; -import * as path from 'path'; -import ThunderRepo from './constants/ThunderRepo'; - -const PLATFORM_MAP: Record = {darwin: 'macos', linux: 'linux', win32: 'win'}; -const ARCH_MAP: Record = {x64: 'x64', arm64: 'arm64'}; -const RELEASES_URL = `https://${ThunderRepo.DOMAIN}/data/releases.json`; - -interface ReleaseAsset { - name: string; - downloadUrl: string; -} - -interface Release { - tagName: string; - isLatest: boolean; - assets: ReleaseAsset[]; -} - -interface ReleasesData { - latestRelease: Release; - releases: Release[]; -} - -export function getPlatformAsset(version: string): string { - const platform = PLATFORM_MAP[process.platform]; - const arch = ARCH_MAP[process.arch]; - if (!platform || !arch) { - throw new Error(`Unsupported platform: ${process.platform}/${process.arch}`); - } - return `${ThunderRepo.HANDLE}-${version}-${platform}-${arch}.zip`; -} - -function fetchWithRedirects(url: string): Promise { - return new Promise((resolve, reject) => { - https - .get(url, {headers: {'User-Agent': 'thunderid-npx'}}, (res) => { - if (res.statusCode === 301 || res.statusCode === 302) { - void fetchWithRedirects(res.headers.location).then(resolve, reject); - return; - } - if (res.statusCode !== 200) { - reject(new Error(`HTTP ${res.statusCode} for ${url}`)); - return; - } - resolve(res); - }) - .on('error', reject); - }); -} - -function fetchJson(url: string): Promise { - return new Promise((resolve, reject) => { - https - .get(url, {headers: {'User-Agent': 'thunderid-npx'}}, (res) => { - if (res.statusCode === 301 || res.statusCode === 302) { - void fetchJson(res.headers.location).then(resolve, reject); - return; - } - if (res.statusCode !== 200) { - reject(new Error(`HTTP ${res.statusCode} for ${url}`)); - return; - } - let body = ''; - res.on('data', (chunk: string) => { - body += chunk; - }); - res.on('end', () => { - try { - resolve(JSON.parse(body) as T); - } catch (err) { - reject(err instanceof Error ? err : new Error(String(err))); - } - }); - }) - .on('error', reject); - }); -} - -async function fetchReleasesData(): Promise { - try { - return await fetchJson(RELEASES_URL); - } catch { - // Fallback: reconstruct ReleasesData shape from GitHub API - const ghUrl = `https://api.github.com/repos/${ThunderRepo.REPO}/releases/latest`; - const gh = await fetchJson<{tag_name?: string; assets?: {name: string; browser_download_url: string}[]}>(ghUrl); - if (!gh.tag_name) throw new Error('tag_name missing from GitHub release response'); - const release: Release = { - tagName: gh.tag_name, - isLatest: true, - assets: (gh.assets ?? []).map((a) => ({name: a.name, downloadUrl: a.browser_download_url})), - }; - return {latestRelease: release, releases: [release]}; - } -} - -async function downloadFile( - url: string, - destPath: string, - onProgress?: (received: number, total: number) => void, -): Promise { - const res = await fetchWithRedirects(url); - const total = parseInt(res.headers['content-length'] ?? '0', 10); - let received = 0; - - await new Promise((resolve, reject) => { - const file = fs.createWriteStream(destPath); - res.on('data', (chunk: Buffer) => { - received += chunk.length; - if (total && onProgress) { - onProgress(received, total); - } - }); - res.pipe(file); - file.on('finish', () => file.close(() => resolve())); - file.on('error', reject); - res.on('error', reject); - }); -} - -function extractZip(zipPath: string, destDir: string): void { - fs.mkdirSync(destDir, {recursive: true}); - if (process.platform === 'win32') { - execSync(`tar -xf "${zipPath}" -C "${destDir}"`, {stdio: 'pipe'}); - } else { - execSync(`unzip -o "${zipPath}" -d "${destDir}"`, {stdio: 'pipe'}); - } -} - -export async function downloadAndExtract( - version: string, - destDir: string, - onStatus?: (msg: string) => void, -): Promise { - const assetName = getPlatformAsset(version); - - const data = await fetchReleasesData(); - const release = data.releases.find((r) => r.tagName === `v${version}`) ?? data.latestRelease; - const asset = release.assets.find((a) => a.name === assetName); - if (!asset) { - throw new Error(`No release asset found for ${assetName}`); - } - - const zipPath = path.join(os.tmpdir(), assetName); - - onStatus?.(`Downloading Thunder v${version} for ${process.platform}/${process.arch}`); - await downloadFile(asset.downloadUrl, zipPath, (received, total) => { - const pct = Math.round((received / total) * 100); - onStatus?.(`Downloading Thunder v${version} โ€” ${pct}%`); - }); - - onStatus?.('Extracting...'); - extractZip(zipPath, destDir); - - try { - fs.unlinkSync(zipPath); - } catch { - /* ignore */ - } -} - -export async function getLatestThunderVersion(): Promise { - const data = await fetchReleasesData(); - const tag = data.latestRelease.tagName; - if (!tag) throw new Error('tagName missing from releases data'); - return tag.replace(/^v/, ''); -} diff --git a/tools/npx-thunderid/src/index.ts b/tools/npx-thunderid/src/index.ts deleted file mode 100644 index 8a2e5fd122..0000000000 --- a/tools/npx-thunderid/src/index.ts +++ /dev/null @@ -1,180 +0,0 @@ -/** - * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import * as path from 'path'; -import {intro, outro, text, spinner, note, cancel, isCancel} from '@clack/prompts'; -import colors from 'picocolors'; -import Product from './constants/Product'; -import {deploy} from './deploy'; -import {downloadAndExtract, getLatestThunderVersion} from './download'; -import {findThunderRoot, runSetup, runStart} from './setup'; -import {readState, writeState, markSetupComplete} from './state'; - -function parseCliArgs(argv: string[]): {forceSetup: boolean; installDir?: string; forwardedArgs: string[]} { - let forceSetup = false; - let installDir: string | undefined; - const forwardedArgs: string[] = []; - - for (let i = 0; i < argv.length; i++) { - const arg = argv[i]; - if (arg === '--setup') { - forceSetup = true; - } else if (arg === '--install-dir') { - if (i + 1 >= argv.length) { - process.stderr.write('Error: --install-dir requires a value\n'); - process.exit(1); - } - installDir = argv[++i]; - } else { - forwardedArgs.push(arg); - } - } - - return {forceSetup, installDir, forwardedArgs}; -} - -async function main(): Promise { - const rawArgs = process.argv.slice(2); - if (rawArgs[0] === 'deploy') { - await deploy(); - return; - } - - // eslint-disable-next-line no-console - console.clear(); - - const {forceSetup, installDir: cliInstallDir, forwardedArgs} = parseCliArgs(rawArgs); - - const s = spinner(); - s.start('Fetching latest Thunder release...'); - let VERSION: string; - try { - VERSION = await getLatestThunderVersion(); - s.stop(`Latest Thunder release: v${VERSION}`); - } catch (err) { - s.stop('Could not fetch latest Thunder release.'); - process.stderr.write(`\nError: ${(err as Error).message}\n`); - process.exit(1); - } - - const state = readState(); - const versionState = state.installs[VERSION]; - const alreadyInstalled = Boolean(versionState?.installPath && findThunderRoot(versionState.installPath) !== null); - - const BRAND_BLUE = '\x1b[38;2;54;136;255m'; - const RESET = '\x1b[0m'; - const GREY = '\x1b[38;2;128;128;128m'; - - const thunderLines = [ - ' _____ _ _ ', - '|_ _| | | | ', - ' | | | |__ _ _ _ __ __| | ___ _ __ ', - " | | | '_ \\| | | | '_ \\ / _` |/ _ \\ '__|", - ' | | | | | | |_| | | | | (_| | __/ | ', - ' \\_/ |_| |_|\\__,_|_| |_|\\__,_|\\___|_| ', - ]; - const idLines = [ - ' ___________ ', - '|_ _| _ \\', - ' | | | | | |', - ' | | | | | |', - ' _| |_| |/ / ', - ' \\___/|___/ ', - ]; - const banner = thunderLines.map((t, i) => ` ${BRAND_BLUE}${t}${RESET}${GREY}${idLines[i]}${RESET}`).join('\n'); - - intro(`\n${banner}\n\n${colors.dim('ยท High-performance open-source identity stack, engineered for developers')}\n`); - - let installPath: string; - - if (alreadyInstalled && versionState.setupComplete && !forceSetup) { - installPath = versionState.installPath; - note(`${Product.NAME} v${VERSION} is ready\n${installPath}`, `Starting ${Product.NAME}`); - try { - runStart(installPath, forwardedArgs); - } catch (err) { - process.stderr.write(`\nFailed to start ${Product.NAME}: ${(err as Error).message}\n`); - process.exit(1); - } - return; - } - - if (alreadyInstalled) { - installPath = versionState.installPath; - if (forceSetup) { - note(`Re-running setup for ${Product.NAME} v${VERSION}\n${installPath}`, 'Setup requested'); - } else { - note(`Using ${Product.NAME} v${VERSION}\n${installPath}`, 'Already installed'); - } - } else { - const defaultPath = path.join(process.cwd(), VERSION); - const nonInteractivePath = cliInstallDir ?? process.env.THUNDER_INSTALL_DIR; - - if (nonInteractivePath) { - installPath = nonInteractivePath; - } else { - const rawInstallPath = await text({ - message: 'Install directory', - placeholder: defaultPath, - defaultValue: defaultPath, - }); - - if (isCancel(rawInstallPath)) { - cancel('Installation cancelled.'); - process.exit(0); - } - - installPath = rawInstallPath || defaultPath; - } - - const dl = spinner(); - dl.start(`Downloading Thunder v${VERSION}...`); - - try { - await downloadAndExtract(VERSION, installPath, (msg) => dl.message(msg)); - } catch (err) { - dl.stop('Download failed.'); - process.stderr.write(`\nError: ${(err as Error).message}\n`); - process.exit(1); - } - - dl.stop(`${Product.NAME} v${VERSION} installed to ${installPath}`); - writeState(VERSION, installPath); - - outro(`Running ${Product.NAME} setup for the first time...`); - } - - try { - runSetup(installPath, forwardedArgs); - markSetupComplete(VERSION); - } catch (err) { - process.stderr.write(`\nSetup failed: ${(err as Error).message}\n`); - process.exit(1); - } - - note(`Setup complete for ${Product.NAME} v${VERSION}\n${installPath}`, `Starting ${Product.NAME}`); - - try { - runStart(installPath, forwardedArgs); - } catch (err) { - process.stderr.write(`\nSetup succeeded but failed to start ${Product.NAME}: ${(err as Error).message}\n`); - process.exit(1); - } -} - -void main(); diff --git a/tools/npx-thunderid/src/models/db.ts b/tools/npx-thunderid/src/models/db.ts deleted file mode 100644 index c3d35fb619..0000000000 --- a/tools/npx-thunderid/src/models/db.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -export type DbType = 'sqlite' | 'postgres'; diff --git a/tools/npx-thunderid/src/models/deploy.ts b/tools/npx-thunderid/src/models/deploy.ts deleted file mode 100644 index 0506f2aad0..0000000000 --- a/tools/npx-thunderid/src/models/deploy.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import type {DbType} from './db'; - -export interface DeployOptions { - appName?: string; - dbType: DbType; - dbUrl?: string; - thunderVersion: string; -} - -export interface Recipe { - id: string; - displayName: string; - description: string; - comingSoon?: boolean; - cliName?: string; - installCmd?: string; - postInstallPath?: string; - needsAppName?: boolean; - preflight(): Promise; - deploy(opts: DeployOptions): Promise; -} diff --git a/tools/npx-thunderid/src/setup.ts b/tools/npx-thunderid/src/setup.ts deleted file mode 100644 index fc8b5c2c90..0000000000 --- a/tools/npx-thunderid/src/setup.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import {execFileSync} from 'child_process'; -import * as fs from 'fs'; -import * as path from 'path'; - -const isWindows = process.platform === 'win32'; - -export function findSetupScript(installPath: string): string | null { - const scriptName = isWindows ? 'setup.ps1' : 'setup.sh'; - - const rootScript = path.join(installPath, scriptName); - if (fs.existsSync(rootScript)) return rootScript; - - for (const entry of fs.readdirSync(installPath)) { - const nested = path.join(installPath, entry, scriptName); - if (fs.existsSync(nested)) return nested; - } - - return null; -} - -export function findThunderRoot(installPath: string): string | null { - const setupScript = findSetupScript(installPath); - if (!setupScript) return null; - return path.dirname(setupScript); -} - -export function runSetup(installPath: string, args: string[] = []): void { - const thunderRoot = findThunderRoot(installPath); - if (!thunderRoot) { - throw new Error(`setup script not found in ${installPath}`); - } - - if (isWindows) { - execFileSync('powershell.exe', ['-ExecutionPolicy', 'Bypass', '-File', 'setup.ps1', ...args], { - cwd: thunderRoot, - stdio: 'inherit', - }); - } else { - execFileSync('bash', ['setup.sh', ...args], {cwd: thunderRoot, stdio: 'inherit'}); - } -} - -export function runStart(installPath: string, args: string[] = []): void { - const thunderRoot = findThunderRoot(installPath); - if (!thunderRoot) { - throw new Error(`Thunder installation not found in ${installPath}`); - } - - if (isWindows) { - const startPs1 = path.join(thunderRoot, 'start.ps1'); - if (fs.existsSync(startPs1)) { - execFileSync('powershell.exe', ['-ExecutionPolicy', 'Bypass', '-File', 'start.ps1', ...args], { - cwd: thunderRoot, - stdio: 'inherit', - }); - return; - } - const binary = path.join(thunderRoot, 'thunderid.exe'); - if (fs.existsSync(binary)) { - execFileSync(binary, args, {cwd: thunderRoot, stdio: 'inherit'}); - return; - } - throw new Error(`No start.ps1 or thunderid.exe found in ${thunderRoot}`); - } - - const startScript = path.join(thunderRoot, 'start.sh'); - if (fs.existsSync(startScript)) { - execFileSync('bash', ['start.sh', ...args], {cwd: thunderRoot, stdio: 'inherit'}); - return; - } - - const binary = path.join(thunderRoot, 'thunder'); - if (fs.existsSync(binary)) { - execFileSync(binary, args, {cwd: thunderRoot, stdio: 'inherit'}); - return; - } - - throw new Error(`No start.sh or thunder binary found in ${thunderRoot}`); -} diff --git a/tools/npx-thunderid/src/state.ts b/tools/npx-thunderid/src/state.ts deleted file mode 100644 index c6a60a1dad..0000000000 --- a/tools/npx-thunderid/src/state.ts +++ /dev/null @@ -1,115 +0,0 @@ -/** - * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; - -interface InstallEntry { - installPath: string; - setupComplete: boolean; - installedAt: string; -} - -interface ThunderState { - installs: Record; - lastUsedVersion: string | null; -} - -export const STATE_DIR = path.join(os.homedir(), '.thunderid'); -const STATE_FILE = path.join(STATE_DIR, 'state.json'); - -function normalizeState(rawState: unknown): ThunderState { - if (!rawState || typeof rawState !== 'object') { - return {installs: {}, lastUsedVersion: null}; - } - - const raw = rawState as Record; - - if (raw['installs'] && typeof raw['installs'] === 'object') { - return { - installs: raw['installs'] as Record, - lastUsedVersion: (raw['lastUsedVersion'] as string) || null, - }; - } - - if (typeof raw['version'] === 'string' && typeof raw['installPath'] === 'string') { - return { - installs: { - [raw['version']]: { - installPath: raw['installPath'], - setupComplete: Boolean(raw['setupComplete']), - installedAt: (raw['installedAt'] as string) || new Date().toISOString(), - }, - }, - lastUsedVersion: raw['version'], - }; - } - - return {installs: {}, lastUsedVersion: null}; -} - -export function readState(): ThunderState { - try { - const rawState: unknown = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')); - return normalizeState(rawState); - } catch { - return normalizeState(null); - } -} - -export function writeState(version: string, installPath: string, setupComplete = false): void { - const currentState = readState(); - const nextState: ThunderState = { - installs: { - ...currentState.installs, - [version]: { - installPath, - setupComplete, - installedAt: currentState.installs[version]?.installedAt || new Date().toISOString(), - }, - }, - lastUsedVersion: version, - }; - - fs.mkdirSync(STATE_DIR, {recursive: true}); - fs.writeFileSync(STATE_FILE, JSON.stringify(nextState, null, 2)); -} - -export function markSetupComplete(version: string): void { - const currentState = readState(); - const versionEntry = currentState.installs[version]; - - if (!versionEntry) { - return; - } - - const nextState: ThunderState = { - installs: { - ...currentState.installs, - [version]: { - ...versionEntry, - setupComplete: true, - }, - }, - lastUsedVersion: version, - }; - - fs.mkdirSync(STATE_DIR, {recursive: true}); - fs.writeFileSync(STATE_FILE, JSON.stringify(nextState, null, 2)); -} diff --git a/tools/npx-thunderid/tsconfig.eslint.json b/tools/npx-thunderid/tsconfig.eslint.json deleted file mode 100644 index d5eefbbbbe..0000000000 --- a/tools/npx-thunderid/tsconfig.eslint.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "./tsconfig.json", - "include": ["**/.*.js", "**/.*.cjs", "**/.*.ts", "**/*.js", "**/*.cjs", "**/*.ts"] -} diff --git a/tools/npx-thunderid/tsconfig.json b/tools/npx-thunderid/tsconfig.json deleted file mode 100644 index 1c3812d664..0000000000 --- a/tools/npx-thunderid/tsconfig.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "compileOnSave": false, - "compilerOptions": { - "declaration": false, - "emitDecoratorMetadata": true, - "esModuleInterop": true, - "experimentalDecorators": true, - "importHelpers": true, - "lib": ["ESNext", "DOM"], - "module": "ESNext", - "moduleResolution": "node", - "skipLibCheck": true, - "skipDefaultLibCheck": true, - "strictNullChecks": true, - "sourceMap": true, - "target": "ESNext", - "forceConsistentCasingInFileNames": true, - "strict": false, - "noImplicitOverride": true, - "noPropertyAccessFromIndexSignature": true, - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true - }, - "exclude": ["node_modules", "tmp", "dist"], - "files": [], - "include": [], - "references": [ - { - "path": "./tsconfig.lib.json" - }, - { - "path": "./tsconfig.spec.json" - } - ] -} diff --git a/tools/npx-thunderid/tsconfig.lib.json b/tools/npx-thunderid/tsconfig.lib.json deleted file mode 100644 index b5f7a77b6f..0000000000 --- a/tools/npx-thunderid/tsconfig.lib.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "declaration": true, - "declarationMap": true, - "outDir": "dist", - "declarationDir": "dist", - "types": ["node"] - }, - "exclude": [ - "**/*.spec.ts", - "**/*.test.ts", - "**/*.spec.tsx", - "**/*.test.tsx", - "**/*.spec.js", - "**/*.test.js", - "**/*.spec.jsx", - "**/*.test.jsx" - ], - "include": ["src/**/*.js", "src/**/*.ts", "types/**/*.d.ts"] -} diff --git a/tools/npx-thunderid/tsconfig.spec.json b/tools/npx-thunderid/tsconfig.spec.json deleted file mode 100644 index 3f9daf174b..0000000000 --- a/tools/npx-thunderid/tsconfig.spec.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "outDir": "dist", - "module": "commonjs", - "types": ["vitest/globals"] - }, - "include": [ - "test-configs", - "vitest.config.ts", - "**/*.test.ts", - "**/*.spec.ts", - "**/*.test.js", - "**/*.spec.js", - "**/*.d.ts" - ] -} diff --git a/tools/npx-thunderid/vitest.config.ts b/tools/npx-thunderid/vitest.config.ts deleted file mode 100644 index 0c37262699..0000000000 --- a/tools/npx-thunderid/vitest.config.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import {defineConfig} from 'vitest/config'; - -export default defineConfig({ - test: { - globals: true, - }, -}); diff --git a/tools/npx-thunderid/.editorconfig b/tools/npx/.editorconfig similarity index 100% rename from tools/npx-thunderid/.editorconfig rename to tools/npx/.editorconfig diff --git a/tools/npx-thunderid/.gitignore b/tools/npx/.gitignore similarity index 100% rename from tools/npx-thunderid/.gitignore rename to tools/npx/.gitignore diff --git a/tools/npx-thunderid/.prettierignore b/tools/npx/.prettierignore similarity index 100% rename from tools/npx-thunderid/.prettierignore rename to tools/npx/.prettierignore diff --git a/tools/npx/README.md b/tools/npx/README.md new file mode 100644 index 0000000000..d613b0ae6c --- /dev/null +++ b/tools/npx/README.md @@ -0,0 +1,59 @@ +# ThunderID + +![ThunderID NPX](https://raw.githubusercontent.com/thunder-id/thunderid/refs/heads/main/docs/static/assets/images/readme/repo-banner-npx.png) + +Run ThunderID instantly โ€” no manual download or setup required. + +## Quick Start + +```bash +npx thunderid +``` + +On first run this downloads the latest ThunderID release, initializes the platform, and starts it. Later runs reuse +the cached installation and start immediately. + +## Commands + +| Command | Description | +| -------------------- | -------------------------------------------------- | +| _(none)_ | Install (if needed) and start ThunderID | +| `upgrade` | Upgrade to the latest release (side-by-side) | +| `try ` | Download and launch a use-case sample app | +| `integrate ` | Configure a technology integration _(coming soon)_ | + +## Flags + +| Flag | Description | +| ----------------- | ---------------------------------------- | +| `--setup` | Force re-run setup | +| `--verbose`, `-v` | Show detailed output | +| `--help`, `-h` | Show help | + +### Upgrade flags + +| Flag | Description | +| ---------- | ---------------------------------------------------- | +| `--direct` | Upgrade in-place (stop current, upgrade, restart) | + +## Requirements + +- **Node.js** `>= 18` + +## Supported Platforms + +| OS | Architectures | +| ------- | -------------- | +| macOS | `x64`, `arm64` | +| Linux | `x64`, `arm64` | +| Windows | `x64` | + +## About + +- **npm:** [`thunderid`](https://www.npmjs.com/package/thunderid) +- **source:** +- **docs:** + +## License + +[Apache License 2.0](https://github.com/thunder-id/thunderid/blob/main/LICENSE) diff --git a/tools/npx/bin/thunderid.js b/tools/npx/bin/thunderid.js new file mode 100755 index 0000000000..f3b56682c9 --- /dev/null +++ b/tools/npx/bin/thunderid.js @@ -0,0 +1,70 @@ +#!/usr/bin/env node +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +const { spawnSync } = require('child_process'); +const path = require('path'); +const fs = require('fs'); + +const PRODUCT_NAME_LOWERCASE = 'thunderid'; +const PLATFORM_MAP = { darwin: 'darwin', linux: 'linux', win32: 'win' }; +const ARCH_MAP = { x64: 'x64', arm64: 'arm64' }; + +const platform = PLATFORM_MAP[process.platform]; +const arch = ARCH_MAP[process.arch]; + +if (!platform || !arch) { + process.stderr.write(`Unsupported platform: ${process.platform}/${process.arch}\n`); + process.exit(1); +} + +const ext = process.platform === 'win32' ? '.exe' : ''; +const binaryName = `${PRODUCT_NAME_LOWERCASE}-${platform}-${arch}${ext}`; +const binaryPath = path.join(__dirname, '..', 'dist', binaryName); + +if (!fs.existsSync(binaryPath)) { + // Dev fallback: run via `go run` if the pre-built binary is absent. + const cliDir = path.join(__dirname, '..', 'cli'); + if (fs.existsSync(path.join(cliDir, 'go.mod'))) { + const result = spawnSync('go', ['run', '.', ...process.argv.slice(2)], { + cwd: cliDir, + stdio: 'inherit', + env: process.env, + }); + process.exit(result.status ?? 1); + } + process.stderr.write( + `Binary not found: ${binaryPath}\nRun "pnpm build" to compile the Go CLI.\n`, + ); + process.exit(1); +} + +if (process.platform !== 'win32') { + try { + fs.chmodSync(binaryPath, 0o755); + } catch { + // ignore โ€” may already be executable + } +} + +const result = spawnSync(binaryPath, process.argv.slice(2), { + stdio: 'inherit', + env: process.env, +}); + +process.exit(result.status ?? 1); diff --git a/tools/npx/package.json b/tools/npx/package.json new file mode 100644 index 0000000000..fe3718268a --- /dev/null +++ b/tools/npx/package.json @@ -0,0 +1,35 @@ +{ + "name": "thunderid", + "version": "0.1.2", + "description": "ThunderID: Auth for the Modern Dev โ€” instantly via npx", + "keywords": [ + "thunderid", + "cli", + "npx" + ], + "homepage": "https://github.com/thunder-id/thunderid/tree/main/tools/npx#readme", + "bugs": { + "url": "https://github.com/thunder-id/thunderid/issues" + }, + "author": "WSO2", + "license": "Apache-2.0", + "bin": { + "thunderid": "./bin/thunderid.js" + }, + "files": [ + "bin", + "dist" + ], + "repository": { + "type": "git", + "url": "https://github.com/thunder-id/thunderid", + "directory": "tools/npx" + }, + "scripts": { + "build": "node scripts/build.js", + "clean": "node scripts/clean.js" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/tools/npx/scripts/build.js b/tools/npx/scripts/build.js new file mode 100644 index 0000000000..91f368b09c --- /dev/null +++ b/tools/npx/scripts/build.js @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +const { execSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const cliDir = path.resolve(__dirname, '../../cli'); +const cliDist = path.join(cliDir, 'dist'); +const npxDist = path.resolve(__dirname, '../dist'); + +if (process.platform === 'win32') { + execSync( + `powershell.exe -ExecutionPolicy Bypass -File "${path.join(cliDir, 'scripts', 'build.ps1')}"`, + { stdio: 'inherit' }, + ); +} else { + execSync(`bash "${path.join(cliDir, 'scripts', 'build.sh')}"`, { + stdio: 'inherit', + }); +} + +fs.mkdirSync(npxDist, { recursive: true }); +for (const file of fs.readdirSync(cliDist)) { + fs.copyFileSync(path.join(cliDist, file), path.join(npxDist, file)); +} + +console.log('Done. Binaries available in npx/dist/'); diff --git a/tools/npx-thunderid/src/constants/Product.ts b/tools/npx/scripts/clean.js similarity index 82% rename from tools/npx-thunderid/src/constants/Product.ts rename to tools/npx/scripts/clean.js index da0f7363dd..53f05e9a18 100644 --- a/tools/npx-thunderid/src/constants/Product.ts +++ b/tools/npx/scripts/clean.js @@ -16,8 +16,7 @@ * under the License. */ -const Product = { - NAME: 'ThunderID', -} as const; +const { rmSync } = require('fs'); +const { join } = require('path'); -export default Product; +rmSync(join(__dirname, '..', 'dist'), { recursive: true, force: true });