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 @@
-
-
-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
+
+
+
+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 });