diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000000..866c8c74d2 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,108 @@ +name: release + +on: + push: + tags: + - "v*" + workflow_dispatch: + +permissions: + contents: write + +jobs: + build-web: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: "9" + - uses: actions/setup-node@v6 + with: + node-version: "24" + - name: Install dependencies + run: pnpm -C apps install --frozen-lockfile + - name: Build web (standalone) + run: pnpm -C apps --filter @kandev/web build + - name: Package web bundle + run: bash scripts/release/package-web.sh + - uses: actions/upload-artifact@v4 + with: + name: web-bundle + path: dist/web + + build-bundles: + needs: build-web + strategy: + matrix: + include: + - os: ubuntu-latest + platform: linux-x64 + - os: macos-13 + platform: macos-x64 + - os: macos-14 + platform: macos-arm64 + - os: windows-latest + platform: windows-x64 + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v6 + with: + go-version-file: apps/backend/go.mod + cache-dependency-path: apps/backend/go.sum + - uses: actions/download-artifact@v4 + with: + name: web-bundle + path: dist/web + + - name: Build backend binaries (unix) + if: runner.os != 'Windows' + run: | + cd apps/backend + mkdir -p bin + go build -o bin/kandev ./cmd/kandev + go build -o bin/agentctl ./cmd/agentctl + + - name: Build backend binaries (windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + Set-Location apps/backend + New-Item -ItemType Directory -Force -Path bin | Out-Null + go build -o bin/kandev.exe ./cmd/kandev + go build -o bin/agentctl.exe ./cmd/agentctl + + - name: Package bundle (unix) + if: runner.os != 'Windows' + run: | + rm -rf dist/kandev + mkdir -p dist/kandev/bin + cp apps/backend/bin/kandev dist/kandev/bin/ + cp apps/backend/bin/agentctl dist/kandev/bin/ + mkdir -p dist/kandev/web + cp -R dist/web/. dist/kandev/web/ + cd dist + zip -r "kandev-${{ matrix.platform }}.zip" kandev + shasum -a 256 "kandev-${{ matrix.platform }}.zip" > "kandev-${{ matrix.platform }}.zip.sha256" + + - name: Package bundle (windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + Remove-Item -Recurse -Force dist/kandev -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Force -Path dist/kandev/bin | Out-Null + Copy-Item apps/backend/bin/kandev.exe dist/kandev/bin/ + Copy-Item apps/backend/bin/agentctl.exe dist/kandev/bin/ + New-Item -ItemType Directory -Force -Path dist/kandev/web | Out-Null + Copy-Item dist/web/* dist/kandev/web/ -Recurse -Force + Compress-Archive -Path dist/kandev -DestinationPath dist/kandev-${{ matrix.platform }}.zip -Force + $hash = Get-FileHash dist/kandev-${{ matrix.platform }}.zip -Algorithm SHA256 + "$($hash.Hash) kandev-${{ matrix.platform }}.zip" | Out-File -FilePath dist/kandev-${{ matrix.platform }}.zip.sha256 -Encoding ascii + + - name: Upload release assets + uses: softprops/action-gh-release@v2 + with: + files: | + dist/kandev-${{ matrix.platform }}.zip + dist/kandev-${{ matrix.platform }}.zip.sha256 diff --git a/Makefile b/Makefile index f07b524b44..f9030c2cca 100644 --- a/Makefile +++ b/Makefile @@ -23,10 +23,11 @@ help: @echo "Kandev - AI Agent Kanban Board" @echo "" @echo "Development Commands:" + @echo " dev Run backend + web via CLI (auto ports)" @echo " dev-backend Run backend in development mode (port 8080)" @echo " dev-web Run web app in development mode (port 3000)" @echo " dev-landing Run landing page in development mode (port 3001)" - @echo " dev Note: Run dev-backend and dev-web in separate terminals" + @echo " dev Note: Uses apps/cli launcher (auto ports)" @echo "" @echo "Build Commands:" @echo " build Build backend, web app, and landing page" @@ -64,36 +65,8 @@ help: .PHONY: dev dev: - @echo "╔════════════════════════════════════════════════════════════════╗" - @echo "║ TIP: For better log visibility, run in separate terminals: ║" - @echo "║ ║" - @echo "║ Terminal 1: make dev-backend ║" - @echo "║ Terminal 2: make dev-web ║" - @echo "╚════════════════════════════════════════════════════════════════╝" - @echo "" - @echo "Starting backend and web app..." - @echo "Backend: http://localhost:8080" - @echo "Web app: http://localhost:3000" - @echo "" - @trap 'stty sane 2>/dev/null || true' EXIT INT TERM; \ - bash -c ' \ - cleanup_called=0; \ - cleanup() { \ - if [ "$$cleanup_called" -eq 1 ]; then \ - return; \ - fi; \ - cleanup_called=1; \ - echo ""; \ - echo "Shutting down..."; \ - trap - EXIT INT TERM; \ - kill 0 2>/dev/null || true; \ - stty sane 2>/dev/null || true; \ - exit 0; \ - }; \ - trap cleanup EXIT INT TERM; \ - $(MAKE) -C $(BACKEND_DIR) run & \ - cd $(APPS_DIR) && $(PNPM) --filter @kandev/web dev & \ - wait' + @echo "Launching via CLI (auto ports)..." + @cd $(APPS_DIR) && $(PNPM) -C cli dev -- dev .PHONY: dev-backend dev-backend: diff --git a/README.md b/README.md index 09b6687ad6..9d2f2b0716 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,16 @@ See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for detailed architecture docum ### Build and Run +#### NPX Launcher (recommended) + +```bash +npx kandev +``` + +This downloads prebuilt backend + frontend bundles and starts them locally. + +#### From Source + ```bash cd apps/backend diff --git a/apps/cli/README.md b/apps/cli/README.md new file mode 100644 index 0000000000..912b6c28f6 --- /dev/null +++ b/apps/cli/README.md @@ -0,0 +1,66 @@ +# Kandev Launcher (npx) + +This package powers `npx kandev` by downloading prebuilt release bundles from GitHub Releases and running them locally. It: +- Detects OS/arch and fetches the matching bundle ZIP. +- Verifies the SHA256 checksum when available. +- Extracts the bundle into `~/.kandev/bin///`. +- Starts the backend binary and waits for the `/health` endpoint. +- Starts the Next.js standalone server with runtime `KANDEV_API_BASE_URL`. +- Uses the latest GitHub Release by default, so runtime bundles update automatically. +It also supports local dev runs from a repo checkout. + +## Updates + +On `run`, the launcher checks npm for the latest `kandev` CLI version and prompts: + +``` +Update available: -> . Update now? [y/N] +``` + +If you accept, it re-runs `npx kandev@latest` with the same arguments. +This check is skipped in `dev` mode. + +Note: the runtime bundles are pulled from the latest GitHub Release by default, even if the CLI version is unchanged. So: +- **New runtime release without CLI publish**: users get new runtime automatically, but no update prompt. +- **New CLI publish**: users get an update prompt and then re-run with the new CLI. + +You can disable the prompt with: + +```bash +KANDEV_NO_UPDATE_PROMPT=1 npx kandev +``` + +## Usage + +```bash +# Run the latest release bundle +npx kandev + +# Run a specific release tag +npx kandev run --version v0.1.0 + +# Local dev (from repo root) +npx kandev dev +``` + +## Local Development + +```bash +pnpm -C apps/cli dev +``` + +## Build / Publish + +```bash +pnpm -C apps/cli build +npm publish --access public +``` + +The published package name is `kandev`, with a bin entry `kandev`. + +## Environment Overrides + +- `KANDEV_GITHUB_OWNER`, `KANDEV_GITHUB_REPO`: Override the GitHub repo to fetch releases from. +- `KANDEV_GITHUB_TOKEN`: Optional token for GitHub API rate limits. +- `KANDEV_NO_UPDATE_PROMPT=1`: Disable the update prompt. +- `KANDEV_SKIP_UPDATE=1`: Internal guard to avoid update loops. diff --git a/apps/cli/bin/cli.js b/apps/cli/bin/cli.js new file mode 100644 index 0000000000..b47d6e1e66 --- /dev/null +++ b/apps/cli/bin/cli.js @@ -0,0 +1,3 @@ +#!/usr/bin/env node + +require("../dist/cli.js"); diff --git a/apps/cli/package.json b/apps/cli/package.json new file mode 100644 index 0000000000..dfe88f785e --- /dev/null +++ b/apps/cli/package.json @@ -0,0 +1,30 @@ +{ + "name": "kandev", + "version": "0.1.0", + "private": false, + "description": "NPX launcher for Kandev", + "license": "UNLICENSED", + "type": "commonjs", + "bin": { + "kandev": "bin/cli.js" + }, + "main": "dist/cli.js", + "files": [ + "bin", + "dist" + ], + "dependencies": { + "adm-zip": "^0.5.16", + "tree-kill": "^1.2.2" + }, + "devDependencies": { + "@types/node": "^20", + "tsx": "^4.15.7", + "typescript": "^5" + }, + "scripts": { + "dev": "tsx src/cli.ts", + "build": "tsc -p tsconfig.json", + "prepublishOnly": "pnpm build" + } +} diff --git a/apps/cli/src/bundle.ts b/apps/cli/src/bundle.ts new file mode 100644 index 0000000000..fe3dc13032 --- /dev/null +++ b/apps/cli/src/bundle.ts @@ -0,0 +1,37 @@ +import AdmZip from "adm-zip"; +import fs from "node:fs"; +import path from "node:path"; + +export function extractZip(zipPath: string, destDir: string): void { + const zip = new AdmZip(zipPath); + zip.extractAllTo(destDir, true); +} + +export function ensureExtracted(zipPath: string, destDir: string): void { + const marker = path.join(destDir, ".extracted"); + if (fs.existsSync(marker)) { + return; + } + extractZip(zipPath, destDir); + fs.writeFileSync(marker, ""); +} + +export function findBundleRoot(cacheDir: string): string { + const candidate = path.join(cacheDir, "kandev"); + if (fs.existsSync(candidate)) { + return candidate; + } + return cacheDir; +} + +export function resolveWebServerPath(bundleDir: string): string | null { + const direct = path.join(bundleDir, "web", "server.js"); + if (fs.existsSync(direct)) { + return direct; + } + const nested = path.join(bundleDir, "web", "apps", "web", "server.js"); + if (fs.existsSync(nested)) { + return nested; + } + return null; +} diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts new file mode 100644 index 0000000000..df49c31a74 --- /dev/null +++ b/apps/cli/src/cli.ts @@ -0,0 +1,131 @@ +import path from "node:path"; +import fs from "node:fs"; + +import pkg from "../package.json"; +import { runDev } from "./dev"; +import { runRelease } from "./run"; +import { ensureValidPort } from "./ports"; +import { maybePromptForUpdate } from "./update"; + +type Command = "run" | "dev"; + +type CliOptions = { + command: Command; + version?: string; + backendPort?: number; + webPort?: number; +}; + +function printHelp() { + console.log(`kandev launcher + +Usage: + kandev run [--version ] [--backend-port ] [--web-port ] + kandev dev [--backend-port ] [--web-port ] + kandev [--version ] [--backend-port ] [--web-port ] + kandev --dev [--backend-port ] [--web-port ] + +Examples: + kandev + kandev run + kandev --dev + kandev dev + kandev --version v0.1.0 + kandev --backend-port 18080 --web-port 13000 + +Options: + dev Use local repo for dev (make dev + next dev) if available. + run Use release bundles (default). + --dev Alias for "dev". + --version Release tag to install (default: latest). + --backend-port Override backend port. + --web-port Override web port. + --help, -h Show help. +`); +} + +function parseArgs(argv: string[]): CliOptions { + const opts: CliOptions = { command: "run" }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === "--help" || arg === "-h") { + printHelp(); + process.exit(0); + } + if (arg === "dev" || arg === "run") { + opts.command = arg; + continue; + } + if (arg === "--version") { + opts.version = argv[i + 1]; + i += 1; + continue; + } + if (arg.startsWith("--version=")) { + opts.version = arg.split("=")[1]; + continue; + } + if (arg === "--dev") { + opts.command = "dev"; + continue; + } + if (arg === "--backend-port") { + opts.backendPort = Number(argv[i + 1]); + i += 1; + continue; + } + if (arg.startsWith("--backend-port=")) { + opts.backendPort = Number(arg.split("=")[1]); + continue; + } + if (arg === "--web-port") { + opts.webPort = Number(argv[i + 1]); + i += 1; + continue; + } + if (arg.startsWith("--web-port=")) { + opts.webPort = Number(arg.split("=")[1]); + continue; + } + } + return opts; +} + +function findRepoRoot(startDir: string): string | null { + let current = path.resolve(startDir); + while (true) { + const backendDir = path.join(current, "apps", "backend"); + const webDir = path.join(current, "apps", "web"); + if (fs.existsSync(backendDir) && fs.existsSync(webDir)) { + return current; + } + const parent = path.dirname(current); + if (parent === current) { + return null; + } + current = parent; + } +} + +async function main(): Promise { + const raw = parseArgs(process.argv.slice(2)); + const backendPort = ensureValidPort(raw.backendPort, "backend port"); + const webPort = ensureValidPort(raw.webPort, "web port"); + + if (raw.command === "dev") { + const repoRoot = findRepoRoot(process.cwd()); + if (!repoRoot) { + throw new Error("Unable to locate repo root for dev. Run from the repo."); + } + await runDev({ repoRoot, backendPort, webPort }); + return; + } + + await maybePromptForUpdate(pkg.version, process.argv.slice(2)); + await runRelease({ version: raw.version, backendPort, webPort }); +} + +main().catch((err) => { + console.error(`[kandev] ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); +}); diff --git a/apps/cli/src/constants.ts b/apps/cli/src/constants.ts new file mode 100644 index 0000000000..ad72f9af6f --- /dev/null +++ b/apps/cli/src/constants.ts @@ -0,0 +1,19 @@ +import os from "node:os"; +import path from "node:path"; + +// Default service ports (will auto-fallback if busy). +export const DEFAULT_BACKEND_PORT = 8080; +export const DEFAULT_WEB_PORT = 3000; +export const DEFAULT_AGENTCTL_PORT = 9999; + +// Random fallback range for port selection. +export const RANDOM_PORT_MIN = 10000; +export const RANDOM_PORT_MAX = 60000; +export const RANDOM_PORT_RETRIES = 10; + +// Backend healthcheck timeout during startup. +export const HEALTH_TIMEOUT_MS = 15000; + +// Local user cache/data directories for release bundles and DB. +export const CACHE_DIR = path.join(os.homedir(), ".kandev", "bin"); +export const DATA_DIR = path.join(os.homedir(), ".kandev", "data"); diff --git a/apps/cli/src/dev.ts b/apps/cli/src/dev.ts new file mode 100644 index 0000000000..0fda42645c --- /dev/null +++ b/apps/cli/src/dev.ts @@ -0,0 +1,75 @@ +import { spawn } from "node:child_process"; +import path from "node:path"; + +import { + DEFAULT_AGENTCTL_PORT, + DEFAULT_BACKEND_PORT, + DEFAULT_WEB_PORT, + HEALTH_TIMEOUT_MS, +} from "./constants"; +import { waitForHealth } from "./health"; +import { pickAvailablePort } from "./ports"; +import { createProcessSupervisor } from "./process"; +import { launchWebApp } from "./web"; + +export type DevOptions = { + repoRoot: string; + backendPort?: number; + webPort?: number; +}; + +export async function runDev({ repoRoot, backendPort, webPort }: DevOptions): Promise { + const actualBackendPort = backendPort ?? (await pickAvailablePort(DEFAULT_BACKEND_PORT)); + const actualWebPort = webPort ?? (await pickAvailablePort(DEFAULT_WEB_PORT)); + const agentctlPort = await pickAvailablePort(DEFAULT_AGENTCTL_PORT); + const backendUrl = `http://localhost:${actualBackendPort}`; + + const backendEnv = { + ...process.env, + KANDEV_SERVER_PORT: String(actualBackendPort), + KANDEV_AGENT_STANDALONE_PORT: String(agentctlPort), + }; + const webEnv = { + ...process.env, + KANDEV_API_BASE_URL: backendUrl, + NEXT_PUBLIC_KANDEV_API_BASE_URL: backendUrl, + PORT: String(actualWebPort), + NEXT_PUBLIC_KANDEV_DEBUG: "true", + }; + + console.log("[kandev] dev mode: using local repo"); + console.log("[kandev] backend port:", actualBackendPort); + console.log("[kandev] web port:", actualWebPort); + console.log("[kandev] agentctl port:", agentctlPort); + + const supervisor = createProcessSupervisor(); + supervisor.attachSignalHandlers(); + + const backendProc = spawn( + "make", + ["-C", path.join("apps", "backend"), "dev"], + { cwd: repoRoot, env: backendEnv, stdio: "inherit" }, + ); + supervisor.children.push(backendProc); + + backendProc.on("exit", (code, signal) => { + console.error(`[kandev] backend exited (code=${code}, signal=${signal})`); + const exitCode = signal ? 0 : code ?? 1; + void supervisor.shutdown("backend exit").then(() => process.exit(exitCode)); + }); + + await waitForHealth(backendUrl, backendProc, HEALTH_TIMEOUT_MS); + console.log(`[kandev] backend ready at ${backendUrl}`); + + const webUrl = `http://localhost:${actualWebPort}`; + launchWebApp({ + command: "pnpm", + args: ["-C", "apps", "--filter", "@kandev/web", "dev"], + cwd: repoRoot, + env: webEnv, + url: webUrl, + supervisor, + label: "web", + }); + console.log(`[kandev] web ready at ${webUrl}`); +} diff --git a/apps/cli/src/github.ts b/apps/cli/src/github.ts new file mode 100644 index 0000000000..256f47b867 --- /dev/null +++ b/apps/cli/src/github.ts @@ -0,0 +1,189 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import https from "node:https"; +import path from "node:path"; + +import { CACHE_DIR } from "./constants"; + +// Allow overriding the GitHub repo for forks/testing. +const OWNER = process.env.KANDEV_GITHUB_OWNER || "kandev"; +const REPO = process.env.KANDEV_GITHUB_REPO || "kandev"; +const API_BASE = `https://api.github.com/repos/${OWNER}/${REPO}`; + +type ReleaseAsset = { + name: string; + browser_download_url: string; +}; + +type ReleaseResponse = { + tag_name?: string; + assets?: ReleaseAsset[]; +}; + +function requestJson(url: string): Promise { + return new Promise((resolve, reject) => { + const req = https.get( + url, + { + headers: { + "User-Agent": "kandev-npx", + Accept: "application/vnd.github+json", + ...(process.env.KANDEV_GITHUB_TOKEN + ? { Authorization: `Bearer ${process.env.KANDEV_GITHUB_TOKEN}` } + : {}), + }, + }, + (res) => { + if (res.statusCode !== 200) { + return reject(new Error(`HTTP ${res.statusCode} fetching ${url}`)); + } + let body = ""; + res.on("data", (chunk) => (body += chunk)); + res.on("end", () => { + try { + resolve(JSON.parse(body) as T); + } catch { + reject(new Error(`Failed to parse JSON from ${url}`)); + } + }); + }, + ); + req.setTimeout(5000, () => { + req.destroy(new Error(`Request timed out fetching ${url}`)); + }); + req.on("error", reject); + }); +} + +function downloadFile( + url: string, + destPath: string, + expectedSha256?: string | null, + onProgress?: (downloaded: number, total: number) => void, +): Promise { + const tempPath = `${destPath}.tmp`; + return new Promise((resolve, reject) => { + const file = fs.createWriteStream(tempPath); + const hash = crypto.createHash("sha256"); + + const cleanup = () => { + try { + fs.unlinkSync(tempPath); + } catch {} + }; + + const req = https.get( + url, + { + headers: { + "User-Agent": "kandev-npx", + Accept: "application/octet-stream", + ...(process.env.KANDEV_GITHUB_TOKEN + ? { Authorization: `Bearer ${process.env.KANDEV_GITHUB_TOKEN}` } + : {}), + }, + }, + (res) => { + if (res.statusCode !== 200) { + file.close(); + cleanup(); + return reject(new Error(`HTTP ${res.statusCode} downloading ${url}`)); + } + const totalSize = parseInt(res.headers["content-length"] || "0", 10); + let downloadedSize = 0; + res.on("data", (chunk) => { + downloadedSize += chunk.length; + hash.update(chunk); + if (onProgress) onProgress(downloadedSize, totalSize); + }); + res.pipe(file); + file.on("finish", () => { + file.close(); + const actualSha256 = hash.digest("hex"); + if (expectedSha256 && actualSha256 !== expectedSha256) { + cleanup(); + return reject( + new Error(`Checksum mismatch: expected ${expectedSha256}, got ${actualSha256}`), + ); + } + try { + fs.renameSync(tempPath, destPath); + resolve(destPath); + } catch (err) { + cleanup(); + reject(err); + } + }); + }, + ); + req.setTimeout(5000, () => { + req.destroy(new Error(`Request timed out downloading ${url}`)); + }); + req.on("error", (err) => { + file.close(); + cleanup(); + reject(err); + }); + }); +} + +function findAsset(release: ReleaseResponse, name: string): ReleaseAsset | undefined { + return release.assets?.find((asset) => asset.name === name); +} + +function readSha256(pathToSha: string): string | null { + if (!fs.existsSync(pathToSha)) { + return null; + } + const content = fs.readFileSync(pathToSha, "utf8").trim(); + const first = content.split(/\s+/)[0]; + return first || null; +} + +export async function getRelease(version?: string): Promise { + if (version) { + return requestJson(`${API_BASE}/releases/tags/${version}`); + } + return requestJson(`${API_BASE}/releases/latest`); +} + +export async function ensureAsset( + release: ReleaseResponse, + assetName: string, + cacheDir: string, + onProgress?: (downloaded: number, total: number) => void, +): Promise { + const asset = findAsset(release, assetName); + if (!asset) { + throw new Error(`Release asset not found: ${assetName}`); + } + + fs.mkdirSync(cacheDir, { recursive: true }); + const destPath = path.join(cacheDir, assetName); + const shaPath = `${destPath}.sha256`; + + let expectedSha = readSha256(shaPath); + if (!expectedSha) { + const shaAsset = findAsset(release, `${assetName}.sha256`); + if (shaAsset) { + await downloadFile(shaAsset.browser_download_url, shaPath); + expectedSha = readSha256(shaPath); + } + } + + if (fs.existsSync(destPath)) { + if (!expectedSha) { + return destPath; + } + const hash = crypto.createHash("sha256"); + const file = fs.readFileSync(destPath); + hash.update(file); + if (hash.digest("hex") === expectedSha) { + return destPath; + } + fs.unlinkSync(destPath); + } + + await downloadFile(asset.browser_download_url, destPath, expectedSha, onProgress); + return destPath; +} diff --git a/apps/cli/src/health.ts b/apps/cli/src/health.ts new file mode 100644 index 0000000000..2672ada70c --- /dev/null +++ b/apps/cli/src/health.ts @@ -0,0 +1,27 @@ +export function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function waitForHealth( + baseUrl: string, + proc: { exitCode: number | null }, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + const healthUrl = `${baseUrl}/health`; + while (Date.now() < deadline) { + if (proc.exitCode !== null) { + throw new Error("Backend exited before healthcheck passed"); + } + try { + const res = await fetch(healthUrl); + if (res.ok) { + return; + } + } catch { + // ignore until timeout + } + await delay(300); + } + throw new Error(`Backend healthcheck timed out after ${timeoutMs}ms`); +} diff --git a/apps/cli/src/platform.ts b/apps/cli/src/platform.ts new file mode 100644 index 0000000000..fb7740bac8 --- /dev/null +++ b/apps/cli/src/platform.ts @@ -0,0 +1,48 @@ +import { execSync } from "node:child_process"; + +const isWindows = process.platform === "win32"; + +export type PlatformArch = "x64" | "arm64"; +export type PlatformDir = + | "linux-x64" + | "macos-x64" + | "macos-arm64" + | "windows-x64" + | "windows-arm64"; + +export function getBinaryName(base: string): string { + return isWindows ? `${base}.exe` : base; +} + +function getEffectiveArch(): PlatformArch { + const platform = process.platform; + const nodeArch = process.arch; + if (platform === "darwin") { + if (nodeArch === "arm64") return "arm64"; + try { + const translated = execSync("sysctl -in sysctl.proc_translated", { + encoding: "utf8", + }).trim(); + if (translated === "1") return "arm64"; + } catch {} + return "x64"; + } + if (/arm/i.test(nodeArch)) return "arm64"; + if (platform === "win32") { + const pa = process.env.PROCESSOR_ARCHITECTURE || ""; + const paw = process.env.PROCESSOR_ARCHITEW6432 || ""; + if (/arm/i.test(pa) || /arm/i.test(paw)) return "arm64"; + } + return "x64"; +} + +export function getPlatformDir(): PlatformDir { + const platform = process.platform; + const arch = getEffectiveArch(); + if (platform === "linux" && arch === "x64") return "linux-x64"; + if (platform === "darwin" && arch === "x64") return "macos-x64"; + if (platform === "darwin" && arch === "arm64") return "macos-arm64"; + if (platform === "win32" && arch === "x64") return "windows-x64"; + if (platform === "win32" && arch === "arm64") return "windows-arm64"; + throw new Error(`Unsupported platform: ${platform}-${arch}`); +} diff --git a/apps/cli/src/ports.ts b/apps/cli/src/ports.ts new file mode 100644 index 0000000000..3ca5582876 --- /dev/null +++ b/apps/cli/src/ports.ts @@ -0,0 +1,81 @@ +import crypto from "node:crypto"; +import net from "node:net"; + +import { RANDOM_PORT_MAX, RANDOM_PORT_MIN, RANDOM_PORT_RETRIES } from "./constants"; + +export function ensureValidPort(port: number | undefined, name: string): number | undefined { + if (port === undefined) { + return undefined; + } + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + throw new Error(`${name} must be an integer between 1 and 65535`); + } + return port; +} + +function isPortAvailable(port: number, host = "0.0.0.0"): Promise { + return new Promise((resolve) => { + const server = net.createServer(); + server.unref(); + server.on("error", (err: NodeJS.ErrnoException) => { + if (err.code === "EADDRINUSE" || err.code === "EACCES") { + resolve(false); + } else { + resolve(false); + } + }); + server.listen(port, host, () => { + server.close(() => resolve(true)); + }); + }); +} + +async function reserveSpecificPort(port: number, host = "127.0.0.1"): Promise { + return new Promise((resolve) => { + const server = net.createServer(); + server.on("error", () => resolve(null)); + server.listen(port, host, () => resolve(server)); + }); +} + +export async function pickAvailablePort( + preferred: number, + retries = RANDOM_PORT_RETRIES, +): Promise { + if (await isPortAvailable(preferred)) { + return preferred; + } + for (let i = 0; i < retries; i += 1) { + const candidate = crypto.randomInt(RANDOM_PORT_MIN, RANDOM_PORT_MAX + 1); + if (await isPortAvailable(candidate)) { + return candidate; + } + } + throw new Error(`Unable to find a free port after ${retries + 1} attempts`); +} + +export async function pickAndReservePort( + preferred: number, + retries = RANDOM_PORT_RETRIES, +): Promise<{ port: number; release: () => Promise }> { + const reservedPreferred = await reserveSpecificPort(preferred); + if (reservedPreferred) { + return { + port: preferred, + release: () => new Promise((resolve) => reservedPreferred.close(() => resolve())), + }; + } + + for (let i = 0; i < retries; i += 1) { + const candidate = crypto.randomInt(RANDOM_PORT_MIN, RANDOM_PORT_MAX + 1); + const reserved = await reserveSpecificPort(candidate); + if (reserved) { + return { + port: candidate, + release: () => new Promise((resolve) => reserved.close(() => resolve())), + }; + } + } + + throw new Error(`Unable to reserve a free port after ${retries + 1} attempts`); +} diff --git a/apps/cli/src/process.ts b/apps/cli/src/process.ts new file mode 100644 index 0000000000..f0d3ba4eaa --- /dev/null +++ b/apps/cli/src/process.ts @@ -0,0 +1,34 @@ +import kill from "tree-kill"; + +export type ChildLike = { pid?: number | undefined }; + +export function createProcessSupervisor(): { + children: ChildLike[]; + shutdown: (reason: string) => Promise; + attachSignalHandlers: () => void; +} { + let shuttingDown = false; + const children: ChildLike[] = []; + + const shutdown = async (reason: string) => { + if (shuttingDown) return; + shuttingDown = true; + console.log(`[kandev] shutting down (${reason})...`); + await Promise.all( + children + .filter((child) => child.pid) + .map((child) => new Promise((resolve) => kill(child.pid as number, "SIGTERM", () => resolve()))), + ); + }; + + const onSignal = (signal: NodeJS.Signals) => { + void shutdown(`signal ${signal}`).then(() => process.exit(0)); + }; + + const attachSignalHandlers = () => { + process.on("SIGINT", onSignal); + process.on("SIGTERM", onSignal); + }; + + return { children, shutdown, attachSignalHandlers }; +} diff --git a/apps/cli/src/run.ts b/apps/cli/src/run.ts new file mode 100644 index 0000000000..e00c9bbfad --- /dev/null +++ b/apps/cli/src/run.ts @@ -0,0 +1,152 @@ +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +import { ensureExtracted, findBundleRoot, resolveWebServerPath } from "./bundle"; +import { + DATA_DIR, + DEFAULT_AGENTCTL_PORT, + DEFAULT_BACKEND_PORT, + DEFAULT_WEB_PORT, + HEALTH_TIMEOUT_MS, +} from "./constants"; +import { CACHE_DIR, ensureAsset, getRelease } from "./github"; +import { waitForHealth } from "./health"; +import { getBinaryName, getPlatformDir } from "./platform"; +import { pickAvailablePort } from "./ports"; +import { createProcessSupervisor } from "./process"; +import { launchWebApp } from "./web"; + +export type RunOptions = { + version?: string; + backendPort?: number; + webPort?: number; +}; + +type PreparedRelease = { + bundleDir: string; + backendBin: string; + backendUrl: string; + backendEnv: NodeJS.ProcessEnv; + webEnv: NodeJS.ProcessEnv; + webPort: number; + agentctlPort: number; +}; + +async function prepareReleaseBundle({ + version, + backendPort, + webPort, +}: RunOptions): Promise { + const platformDir = getPlatformDir(); + const release = await getRelease(version); + const tag = release.tag_name || "latest"; + const assetName = `kandev-${platformDir}.zip`; + const cacheDir = path.join(CACHE_DIR, tag, platformDir); + + const zipPath = await ensureAsset(release, assetName, cacheDir, (downloaded, total) => { + const percent = total ? Math.round((downloaded / total) * 100) : 0; + const mb = (downloaded / (1024 * 1024)).toFixed(1); + const totalMb = total ? (total / (1024 * 1024)).toFixed(1) : "?"; + process.stderr.write(`\r Downloading: ${mb}MB / ${totalMb}MB (${percent}%)`); + }); + process.stderr.write("\n"); + + ensureExtracted(zipPath, cacheDir); + const bundleDir = findBundleRoot(cacheDir); + + const backendBin = path.join(bundleDir, "bin", getBinaryName("kandev")); + if (!fs.existsSync(backendBin)) { + throw new Error(`Backend binary not found at ${backendBin}`); + } + + const agentctlBin = path.join(bundleDir, "bin", getBinaryName("agentctl")); + if (!fs.existsSync(agentctlBin)) { + throw new Error(`agentctl binary not found at ${agentctlBin}`); + } + + const actualBackendPort = backendPort ?? (await pickAvailablePort(DEFAULT_BACKEND_PORT)); + const actualWebPort = webPort ?? (await pickAvailablePort(DEFAULT_WEB_PORT)); + const agentctlPort = await pickAvailablePort(DEFAULT_AGENTCTL_PORT); + const backendUrl = `http://localhost:${actualBackendPort}`; + + fs.mkdirSync(DATA_DIR, { recursive: true }); + const dbPath = path.join(DATA_DIR, "kandev.db"); + + const backendEnv = { + ...process.env, + KANDEV_SERVER_PORT: String(actualBackendPort), + KANDEV_AGENT_STANDALONE_PORT: String(agentctlPort), + KANDEV_DB_PATH: dbPath, + }; + + const webEnv = { + ...process.env, + KANDEV_API_BASE_URL: backendUrl, + NEXT_PUBLIC_KANDEV_API_BASE_URL: backendUrl, + PORT: String(actualWebPort), + NODE_ENV: "production", + }; + + return { + bundleDir, + backendBin, + backendUrl, + backendEnv, + webEnv, + webPort: actualWebPort, + agentctlPort, + }; +} + +function launchReleaseApps(prepared: PreparedRelease): { + supervisor: ReturnType; + backendProc: ReturnType; + webServerPath: string; +} { + console.log("[kandev] backend port:", prepared.backendEnv.KANDEV_SERVER_PORT); + console.log("[kandev] web port:", prepared.webPort); + console.log("[kandev] agentctl port:", prepared.agentctlPort); + + const supervisor = createProcessSupervisor(); + supervisor.attachSignalHandlers(); + + const backendProc = spawn(prepared.backendBin, [], { + cwd: path.dirname(prepared.backendBin), + env: prepared.backendEnv, + stdio: "inherit", + }); + supervisor.children.push(backendProc); + + backendProc.on("exit", (code, signal) => { + console.error(`[kandev] backend exited (code=${code}, signal=${signal})`); + void supervisor.shutdown("backend exit").then(() => process.exit(code || 1)); + }); + + const webServerPath = resolveWebServerPath(prepared.bundleDir); + if (!webServerPath) { + throw new Error("Web server entry (server.js) not found in bundle"); + } + + const webUrl = `http://localhost:${prepared.webPort}`; + const webProc = launchWebApp({ + command: "node", + args: [webServerPath], + cwd: path.dirname(webServerPath), + env: prepared.webEnv, + url: webUrl, + supervisor, + label: "web", + }); + + return { supervisor, backendProc, webServerPath, webProc }; +} + +export async function runRelease({ version, backendPort, webPort }: RunOptions) { + const prepared = await prepareReleaseBundle({ version, backendPort, webPort }); + const { backendProc } = launchReleaseApps(prepared); + // Wait for backend before announcing the web URL. + await waitForHealth(prepared.backendUrl, backendProc, HEALTH_TIMEOUT_MS); + console.log(`[kandev] backend ready at ${prepared.backendUrl}`); + console.log(`[kandev] web ready at http://localhost:${prepared.webPort}`); +} diff --git a/apps/cli/src/update.ts b/apps/cli/src/update.ts new file mode 100644 index 0000000000..af85436d95 --- /dev/null +++ b/apps/cli/src/update.ts @@ -0,0 +1,98 @@ +import { spawn } from "node:child_process"; +import https from "node:https"; +import readline from "node:readline"; + +function requestJson(url: string): Promise { + return new Promise((resolve, reject) => { + const req = https.get( + url, + { headers: { "User-Agent": "kandev-npx" } }, + (res) => { + if (res.statusCode !== 200) { + return reject(new Error(`HTTP ${res.statusCode} fetching ${url}`)); + } + let body = ""; + res.on("data", (chunk) => (body += chunk)); + res.on("end", () => { + try { + resolve(JSON.parse(body) as T); + } catch { + reject(new Error(`Failed to parse JSON from ${url}`)); + } + }); + }, + ); + req.setTimeout(5000, () => { + req.destroy(new Error(`Request timed out fetching ${url}`)); + }); + req.on("error", reject); + }); +} + +async function getLatestNpmVersion(): Promise { + const data = await requestJson<{ "dist-tags"?: { latest?: string } }>( + "https://registry.npmjs.org/kandev", + ); + return data?.["dist-tags"]?.latest; +} + +function compareVersions(a: string, b: string): number { + const pa = String(a).replace(/^v/, "").split(".").map(Number); + const pb = String(b).replace(/^v/, "").split(".").map(Number); + for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) { + const av = pa[i] ?? 0; + const bv = pb[i] ?? 0; + if (av > bv) return 1; + if (av < bv) return -1; + } + return 0; +} + +function promptYesNo(question: string, defaultYes = false): Promise { + return new Promise((resolve) => { + if (!process.stdin.isTTY) { + resolve(false); + return; + } + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + const suffix = defaultYes ? "[Y/n]" : "[y/N]"; + rl.question(`${question} ${suffix} `, (answer: string) => { + rl.close(); + const normalized = String(answer || "").trim().toLowerCase(); + if (!normalized) { + resolve(Boolean(defaultYes)); + return; + } + resolve(normalized === "y" || normalized === "yes"); + }); + }); +} + +export async function maybePromptForUpdate(currentVersion: string, args: string[]) { + // Allow disabling update checks for CI or automation. + if (process.env.KANDEV_SKIP_UPDATE === "1" || process.env.KANDEV_NO_UPDATE_PROMPT === "1") { + return; + } + try { + const latest = await getLatestNpmVersion(); + if (!latest) return; + if (compareVersions(latest, currentVersion) <= 0) return; + const wantsUpdate = await promptYesNo( + `Update available: ${currentVersion} -> ${latest}. Update now?`, + false, + ); + if (!wantsUpdate) return; + const env = { ...process.env, KANDEV_SKIP_UPDATE: "1" }; + const child = spawn("npx", ["kandev@latest", ...args], { + stdio: "inherit", + env, + }); + child.on("exit", (code) => process.exit(code || 0)); + await new Promise(() => {}); + } catch { + // ignore update errors + } +} diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts new file mode 100644 index 0000000000..eff7b4033a --- /dev/null +++ b/apps/cli/src/web.ts @@ -0,0 +1,57 @@ +import { spawn } from "node:child_process"; + +import { createProcessSupervisor } from "./process"; + +export type WebLaunchOptions = { + command: string; + args: string[]; + cwd: string; + env: NodeJS.ProcessEnv; + url: string; + supervisor: ReturnType; + label: string; +}; + +export function openBrowser(url: string) { + if (process.env.KANDEV_NO_BROWSER === "1") { + return; + } + const opener = + process.platform === "darwin" + ? "open" + : process.platform === "win32" + ? "cmd" + : "xdg-open"; + const args = + process.platform === "win32" + ? ["/c", "start", "", url] + : [url]; + try { + const child = spawn(opener, args, { stdio: "ignore", detached: true }); + child.unref(); + } catch { + // ignore browser launch errors + } +} + +export function launchWebApp({ + command, + args, + cwd, + env, + url, + supervisor, + label, +}: WebLaunchOptions) { + const proc = spawn(command, args, { cwd, env, stdio: "inherit" }); + supervisor.children.push(proc); + + proc.on("exit", (code, signal) => { + console.error(`[kandev] ${label} exited (code=${code}, signal=${signal})`); + const exitCode = signal ? 0 : code ?? 1; + void supervisor.shutdown(`${label} exit`).then(() => process.exit(exitCode)); + }); + + openBrowser(url); + return proc; +} diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json new file mode 100644 index 0000000000..9d796efd9f --- /dev/null +++ b/apps/cli/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "rootDir": "src", + "outDir": "dist", + "esModuleInterop": true, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/apps/pnpm-lock.yaml b/apps/pnpm-lock.yaml index 03de9600f0..a44c108ba6 100644 --- a/apps/pnpm-lock.yaml +++ b/apps/pnpm-lock.yaml @@ -15,6 +15,25 @@ importers: specifier: ^5 version: 5.9.3 + cli: + dependencies: + adm-zip: + specifier: ^0.5.16 + version: 0.5.16 + tree-kill: + specifier: ^1.2.2 + version: 1.2.2 + devDependencies: + '@types/node': + specifier: ^20 + version: 20.19.28 + tsx: + specifier: ^4.15.7 + version: 4.21.0 + typescript: + specifier: ^5 + version: 5.9.3 + landing: dependencies: '@kandev/theme': @@ -664,138 +683,294 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.27.2': + resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.21.5': resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} engines: {node: '>=12'} cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.27.2': + resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.21.5': resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} engines: {node: '>=12'} cpu: [arm] os: [android] + '@esbuild/android-arm@0.27.2': + resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.21.5': resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} engines: {node: '>=12'} cpu: [x64] os: [android] + '@esbuild/android-x64@0.27.2': + resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.21.5': resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} engines: {node: '>=12'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.27.2': + resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.21.5': resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} engines: {node: '>=12'} cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.27.2': + resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.21.5': resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} engines: {node: '>=12'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.27.2': + resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.21.5': resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} engines: {node: '>=12'} cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.27.2': + resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.21.5': resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} engines: {node: '>=12'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.27.2': + resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.21.5': resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} engines: {node: '>=12'} cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.27.2': + resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.21.5': resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} engines: {node: '>=12'} cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.27.2': + resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.21.5': resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} engines: {node: '>=12'} cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.27.2': + resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.21.5': resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} engines: {node: '>=12'} cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.27.2': + resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.21.5': resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} engines: {node: '>=12'} cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.27.2': + resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.21.5': resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} engines: {node: '>=12'} cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.27.2': + resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.21.5': resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} engines: {node: '>=12'} cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.27.2': + resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.21.5': resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} engines: {node: '>=12'} cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.27.2': + resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.2': + resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.21.5': resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} engines: {node: '>=12'} cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.27.2': + resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.2': + resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.21.5': resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} engines: {node: '>=12'} cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.27.2': + resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.2': + resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.21.5': resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} engines: {node: '>=12'} cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.27.2': + resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.21.5': resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} engines: {node: '>=12'} cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.27.2': + resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.21.5': resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} engines: {node: '>=12'} cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.27.2': + resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.21.5': resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} engines: {node: '>=12'} cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.27.2': + resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -2552,6 +2727,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + adm-zip@0.5.16: + resolution: {integrity: sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==} + engines: {node: '>=12.0'} + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -3134,6 +3313,11 @@ packages: engines: {node: '>=12'} hasBin: true + esbuild@0.27.2: + resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -5111,6 +5295,10 @@ packages: resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} engines: {node: '>=16'} + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -5136,6 +5324,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + tw-animate-css@1.4.0: resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} @@ -5937,72 +6130,150 @@ snapshots: '@esbuild/aix-ppc64@0.21.5': optional: true + '@esbuild/aix-ppc64@0.27.2': + optional: true + '@esbuild/android-arm64@0.21.5': optional: true + '@esbuild/android-arm64@0.27.2': + optional: true + '@esbuild/android-arm@0.21.5': optional: true + '@esbuild/android-arm@0.27.2': + optional: true + '@esbuild/android-x64@0.21.5': optional: true + '@esbuild/android-x64@0.27.2': + optional: true + '@esbuild/darwin-arm64@0.21.5': optional: true + '@esbuild/darwin-arm64@0.27.2': + optional: true + '@esbuild/darwin-x64@0.21.5': optional: true + '@esbuild/darwin-x64@0.27.2': + optional: true + '@esbuild/freebsd-arm64@0.21.5': optional: true + '@esbuild/freebsd-arm64@0.27.2': + optional: true + '@esbuild/freebsd-x64@0.21.5': optional: true + '@esbuild/freebsd-x64@0.27.2': + optional: true + '@esbuild/linux-arm64@0.21.5': optional: true + '@esbuild/linux-arm64@0.27.2': + optional: true + '@esbuild/linux-arm@0.21.5': optional: true + '@esbuild/linux-arm@0.27.2': + optional: true + '@esbuild/linux-ia32@0.21.5': optional: true + '@esbuild/linux-ia32@0.27.2': + optional: true + '@esbuild/linux-loong64@0.21.5': optional: true + '@esbuild/linux-loong64@0.27.2': + optional: true + '@esbuild/linux-mips64el@0.21.5': optional: true + '@esbuild/linux-mips64el@0.27.2': + optional: true + '@esbuild/linux-ppc64@0.21.5': optional: true + '@esbuild/linux-ppc64@0.27.2': + optional: true + '@esbuild/linux-riscv64@0.21.5': optional: true + '@esbuild/linux-riscv64@0.27.2': + optional: true + '@esbuild/linux-s390x@0.21.5': optional: true + '@esbuild/linux-s390x@0.27.2': + optional: true + '@esbuild/linux-x64@0.21.5': optional: true + '@esbuild/linux-x64@0.27.2': + optional: true + + '@esbuild/netbsd-arm64@0.27.2': + optional: true + '@esbuild/netbsd-x64@0.21.5': optional: true + '@esbuild/netbsd-x64@0.27.2': + optional: true + + '@esbuild/openbsd-arm64@0.27.2': + optional: true + '@esbuild/openbsd-x64@0.21.5': optional: true + '@esbuild/openbsd-x64@0.27.2': + optional: true + + '@esbuild/openharmony-arm64@0.27.2': + optional: true + '@esbuild/sunos-x64@0.21.5': optional: true + '@esbuild/sunos-x64@0.27.2': + optional: true + '@esbuild/win32-arm64@0.21.5': optional: true + '@esbuild/win32-arm64@0.27.2': + optional: true + '@esbuild/win32-ia32@0.21.5': optional: true + '@esbuild/win32-ia32@0.27.2': + optional: true + '@esbuild/win32-x64@0.21.5': optional: true + '@esbuild/win32-x64@0.27.2': + optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.6.1))': dependencies: eslint: 9.39.2(jiti@2.6.1) @@ -7758,6 +8029,8 @@ snapshots: acorn@8.15.0: {} + adm-zip@0.5.16: {} + agent-base@7.1.4: {} ajv-formats@3.0.1(ajv@8.17.1): @@ -8417,6 +8690,35 @@ snapshots: '@esbuild/win32-ia32': 0.21.5 '@esbuild/win32-x64': 0.21.5 + esbuild@0.27.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.2 + '@esbuild/android-arm': 0.27.2 + '@esbuild/android-arm64': 0.27.2 + '@esbuild/android-x64': 0.27.2 + '@esbuild/darwin-arm64': 0.27.2 + '@esbuild/darwin-x64': 0.27.2 + '@esbuild/freebsd-arm64': 0.27.2 + '@esbuild/freebsd-x64': 0.27.2 + '@esbuild/linux-arm': 0.27.2 + '@esbuild/linux-arm64': 0.27.2 + '@esbuild/linux-ia32': 0.27.2 + '@esbuild/linux-loong64': 0.27.2 + '@esbuild/linux-mips64el': 0.27.2 + '@esbuild/linux-ppc64': 0.27.2 + '@esbuild/linux-riscv64': 0.27.2 + '@esbuild/linux-s390x': 0.27.2 + '@esbuild/linux-x64': 0.27.2 + '@esbuild/netbsd-arm64': 0.27.2 + '@esbuild/netbsd-x64': 0.27.2 + '@esbuild/openbsd-arm64': 0.27.2 + '@esbuild/openbsd-x64': 0.27.2 + '@esbuild/openharmony-arm64': 0.27.2 + '@esbuild/sunos-x64': 0.27.2 + '@esbuild/win32-arm64': 0.27.2 + '@esbuild/win32-ia32': 0.27.2 + '@esbuild/win32-x64': 0.27.2 + escalade@3.2.0: {} escape-html@1.0.3: {} @@ -11073,6 +11375,8 @@ snapshots: dependencies: tldts: 7.0.19 + tree-kill@1.2.2: {} + trim-lines@3.0.1: {} trough@2.2.0: {} @@ -11101,6 +11405,13 @@ snapshots: tslib@2.8.1: {} + tsx@4.21.0: + dependencies: + esbuild: 0.27.2 + get-tsconfig: 4.13.0 + optionalDependencies: + fsevents: 2.3.3 + tw-animate-css@1.4.0: {} type-check@0.4.0: diff --git a/apps/pnpm-workspace.yaml b/apps/pnpm-workspace.yaml index e9a0c45593..3a4a5217bd 100644 --- a/apps/pnpm-workspace.yaml +++ b/apps/pnpm-workspace.yaml @@ -1,4 +1,5 @@ packages: + - 'cli' - 'web' - 'landing' - 'packages/*' diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 5ec7f74d33..06f90fcc53 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -16,9 +16,18 @@ export default function RootLayout({ }: Readonly<{ children: React.ReactNode; }>) { + const runtimeApiBaseUrl = process.env.KANDEV_API_BASE_URL ?? ""; return ( + {runtimeApiBaseUrl ? ( + // Inject runtime API base URL for production bundles where ports are chosen at launch. +