diff --git a/.github/workflows/build-cli.yml b/.github/workflows/build-cli.yml new file mode 100644 index 00000000000..7892e4d94ae --- /dev/null +++ b/.github/workflows/build-cli.yml @@ -0,0 +1,79 @@ +name: Build CLI Distribution + +on: + push: + tags: ["cli-v*"] + workflow_dispatch: + +jobs: + build: + name: Build ${{ matrix.target }} + strategy: + fail-fast: false + matrix: + include: + - os: macos-14 + target: darwin-arm64 + - os: macos-13 + target: darwin-x64 + - os: ubuntu-latest + target: linux-x64 + + runs-on: ${{ matrix.os }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version-file: .bun-version + + - name: Setup Node.js (for native addon compilation) + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install dependencies + run: bun install --frozen + + - name: Build distribution + working-directory: packages/cli + env: + RELAY_URL: https://relay.superset.sh + CLOUD_API_URL: https://api.superset.sh + run: bun run build:dist --target=${{ matrix.target }} + + - name: Upload tarball + uses: actions/upload-artifact@v4 + with: + name: superset-${{ matrix.target }} + path: packages/cli/dist/superset-${{ matrix.target }}.tar.gz + if-no-files-found: error + + release: + name: Create GitHub Release + needs: build + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/cli-v') + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: release-artifacts + pattern: superset-* + merge-multiple: true + + - name: Create Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create "${{ github.ref_name }}" \ + release-artifacts/*.tar.gz \ + --title "Superset CLI ${{ github.ref_name }}" \ + --generate-notes \ + --draft diff --git a/packages/cli/package.json b/packages/cli/package.json index 2edc403e79a..9b0b4036cbb 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -13,6 +13,7 @@ "build:darwin-x64": "bun build --compile --target=bun-darwin-x64 src/bin.ts --outfile dist/superset-darwin-x64", "build:linux-x64": "bun build --compile --target=bun-linux-x64 src/bin.ts --outfile dist/superset-linux-x64", "build:all": "bun run build:darwin-arm64 && bun run build:darwin-x64 && bun run build:linux-x64", + "build:dist": "bun run scripts/build-dist.ts", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/packages/cli/scripts/build-dist.ts b/packages/cli/scripts/build-dist.ts new file mode 100644 index 00000000000..a56fdda250d --- /dev/null +++ b/packages/cli/scripts/build-dist.ts @@ -0,0 +1,291 @@ +/** + * Builds a standalone Superset CLI distribution tarball. + * + * Bundle layout (extracts into ~/superset/): + * bin/superset — Bun-compiled CLI binary + * bin/superset-host — Shell wrapper to run the host-service + * lib/node — Standalone Node.js runtime + * lib/host-service.js — Bundled host-service entry + * lib/node_modules/ — Full native addon packages (JS wrappers + bindings) + * better-sqlite3/ + * node-pty/ + * @parcel/watcher/ + * @parcel/watcher-/ + * share/migrations/ — Drizzle migration SQL files + * + * Usage: + * bun run scripts/build-dist.ts --target=darwin-arm64 + * bun run scripts/build-dist.ts --target=darwin-x64 + * bun run scripts/build-dist.ts --target=linux-x64 + */ +import { spawn } from "node:child_process"; +import { + chmodSync, + cpSync, + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; + +type Target = "darwin-arm64" | "darwin-x64" | "linux-x64"; + +const VALID_TARGETS: Target[] = ["darwin-arm64", "darwin-x64", "linux-x64"]; +const NODE_VERSION = "22.13.0"; + +/** + * Native addon packages that must be shipped alongside the bundled + * host-service because they contain .node files that can't be inlined. + */ +const NATIVE_PACKAGES = [ + "better-sqlite3", + "node-pty", + "@parcel/watcher", +] as const; + +function parseArgs(): { target: Target } { + const targetArg = process.argv.find((a) => a.startsWith("--target=")); + if (!targetArg) { + console.error("Missing required --target="); + console.error(`Valid targets: ${VALID_TARGETS.join(", ")}`); + process.exit(1); + } + const target = targetArg.slice("--target=".length) as Target; + if (!VALID_TARGETS.includes(target)) { + console.error(`Invalid target: ${target}`); + console.error(`Valid targets: ${VALID_TARGETS.join(", ")}`); + process.exit(1); + } + return { target }; +} + +function nodeArchiveName(target: Target): string { + const arch = target === "darwin-arm64" ? "arm64" : "x64"; + const platform = target.startsWith("darwin") ? "darwin" : "linux"; + return `node-v${NODE_VERSION}-${platform}-${arch}`; +} + +function nodeDownloadUrl(target: Target): string { + return `https://nodejs.org/dist/v${NODE_VERSION}/${nodeArchiveName(target)}.tar.gz`; +} + +async function exec(cmd: string, args: string[], cwd?: string): Promise { + return new Promise((res, rej) => { + const child = spawn(cmd, args, { + cwd, + stdio: "inherit", + }); + child.on("exit", (code) => { + if (code === 0) res(); + else rej(new Error(`${cmd} ${args.join(" ")} exited with ${code}`)); + }); + child.on("error", rej); + }); +} + +async function downloadAndExtractNode( + target: Target, + destDir: string, +): Promise { + const cacheDir = join(homedir(), ".superset-build-cache"); + if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true }); + + const archiveName = nodeArchiveName(target); + const archivePath = join(cacheDir, `${archiveName}.tar.gz`); + const extractedPath = join(cacheDir, archiveName); + + if (!existsSync(archivePath)) { + console.log(`[build-dist] downloading ${nodeDownloadUrl(target)}`); + await exec("curl", ["-fsSL", "-o", archivePath, nodeDownloadUrl(target)]); + } + + if (!existsSync(extractedPath)) { + console.log(`[build-dist] extracting Node.js for ${target}`); + await exec("tar", ["-xzf", archivePath, "-C", cacheDir]); + } + + const sourceBinary = join(extractedPath, "bin", "node"); + const destBinary = join(destDir, "node"); + cpSync(sourceBinary, destBinary); + chmodSync(destBinary, 0o755); + return destBinary; +} + +/** + * Read version for a package from the host-service's resolved node_modules. + * We use `npm ls` / manual lookup from `package.json` — simplest is to find the + * package in bun's `.bun/` store and parse its version from the directory name. + */ +function findPackagePath( + packageName: string, + startDir: string, + repoRoot: string, +): string | null { + const { realpathSync } = require("node:fs"); + // Walk up from startDir looking for node_modules/ + let current = startDir; + while (current.startsWith(repoRoot)) { + const candidate = join(current, "node_modules", packageName); + if (existsSync(candidate)) { + return realpathSync(candidate); + } + const parent = dirname(current); + if (parent === current) break; + current = parent; + } + // Fallback: common locations + const fallbacks = [ + join(repoRoot, "packages", "host-service", "node_modules", packageName), + join(repoRoot, "packages", "workspace-fs", "node_modules", packageName), + join(repoRoot, "node_modules", packageName), + ]; + for (const fallback of fallbacks) { + if (existsSync(fallback)) { + return realpathSync(fallback); + } + } + return null; +} + +function copyPackageWithDeps( + packageName: string, + startDir: string, + repoRoot: string, + destModules: string, + copied: Set, +): void { + if (copied.has(packageName)) return; + copied.add(packageName); + + const sourcePath = findPackagePath(packageName, startDir, repoRoot); + if (!sourcePath) { + throw new Error( + `Package not found: ${packageName}. Run 'bun install' first.`, + ); + } + + const destPath = join(destModules, packageName); + mkdirSync(dirname(destPath), { recursive: true }); + cpSync(sourcePath, destPath, { recursive: true, dereference: true }); + + // Recursively copy runtime dependencies + const packageJsonPath = join(sourcePath, "package.json"); + if (existsSync(packageJsonPath)) { + const pkg = JSON.parse(readFileSync(packageJsonPath, "utf-8")); + const deps = Object.keys(pkg.dependencies ?? {}); + for (const dep of deps) { + copyPackageWithDeps(dep, sourcePath, repoRoot, destModules, copied); + } + } +} + +function copyNativePackages(libDir: string): void { + const repoRoot = resolve(import.meta.dir, "../../.."); + const destModules = join(libDir, "node_modules"); + mkdirSync(destModules, { recursive: true }); + const copied = new Set(); + + const hostServiceDir = join(repoRoot, "packages", "host-service"); + for (const pkg of NATIVE_PACKAGES) { + console.log(`[build-dist] copying ${pkg} (+ deps)`); + copyPackageWithDeps(pkg, hostServiceDir, repoRoot, destModules, copied); + } + + // better-sqlite3, node-pty, and @parcel/watcher each load their native + // binding from build/Release/ as a fallback when the platform-specific + // npm sub-package isn't available. Since those sub-packages are optional + // and we're shipping the build output, we don't need to copy them. +} + +async function buildCli(target: Target, outputPath: string): Promise { + const relayUrl = process.env.RELAY_URL || "https://relay.superset.sh"; + const cloudApiUrl = process.env.CLOUD_API_URL || "https://api.superset.sh"; + + const cliDir = resolve(import.meta.dir, ".."); + await exec( + "bun", + [ + "build", + "--compile", + `--target=bun-${target}`, + "--define", + `process.env.RELAY_URL="${relayUrl}"`, + "--define", + `process.env.CLOUD_API_URL="${cloudApiUrl}"`, + "src/bin.ts", + "--outfile", + outputPath, + ], + cliDir, + ); +} + +async function buildHostService(): Promise { + const hostServiceDir = resolve(import.meta.dir, "../../host-service"); + await exec("bun", ["run", "build:host"], hostServiceDir); + return join(hostServiceDir, "dist", "host-service.js"); +} + +function writeHostWrapper(binDir: string): void { + const wrapper = `#!/bin/sh +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +export NODE_PATH="$SCRIPT_DIR/../lib/node_modules" +exec "$SCRIPT_DIR/../lib/node" "$SCRIPT_DIR/../lib/host-service.js" "$@" +`; + const wrapperPath = join(binDir, "superset-host"); + writeFileSync(wrapperPath, wrapper, { mode: 0o755 }); + chmodSync(wrapperPath, 0o755); +} + +async function main(): Promise { + const { target } = parseArgs(); + const cliDir = resolve(import.meta.dir, ".."); + const stagingRoot = join(cliDir, "dist", `superset-${target}`); + + if (existsSync(stagingRoot)) rmSync(stagingRoot, { recursive: true }); + mkdirSync(join(stagingRoot, "bin"), { recursive: true }); + mkdirSync(join(stagingRoot, "lib"), { recursive: true }); + mkdirSync(join(stagingRoot, "share"), { recursive: true }); + + console.log(`[build-dist] target: ${target}`); + console.log(`[build-dist] staging: ${stagingRoot}`); + + console.log("[build-dist] building CLI binary"); + await buildCli(target, join(stagingRoot, "bin", "superset")); + + console.log("[build-dist] building host-service bundle"); + const hostServiceBundle = await buildHostService(); + cpSync(hostServiceBundle, join(stagingRoot, "lib", "host-service.js")); + + console.log("[build-dist] fetching Node.js"); + await downloadAndExtractNode(target, join(stagingRoot, "lib")); + + console.log("[build-dist] copying native addon packages"); + copyNativePackages(join(stagingRoot, "lib")); + + console.log("[build-dist] copying migrations"); + const migrationsSrc = resolve(import.meta.dir, "../../host-service/drizzle"); + cpSync(migrationsSrc, join(stagingRoot, "share", "migrations"), { + recursive: true, + }); + + console.log("[build-dist] writing host wrapper"); + writeHostWrapper(join(stagingRoot, "bin")); + + const tarball = join(cliDir, "dist", `superset-${target}.tar.gz`); + console.log(`[build-dist] creating ${tarball}`); + await exec("tar", [ + "-czf", + tarball, + "-C", + dirname(stagingRoot), + `superset-${target}`, + ]); + + console.log(`[build-dist] done: ${tarball}`); +} + +await main(); diff --git a/packages/cli/src/commands/auth/login/command.ts b/packages/cli/src/commands/auth/login/command.ts index c6956c728c8..9eabd0deb0d 100644 --- a/packages/cli/src/commands/auth/login/command.ts +++ b/packages/cli/src/commands/auth/login/command.ts @@ -29,19 +29,27 @@ export default command({ s.stop("Authorized!"); - // Show who we logged in as + // The user picked an org during the OAuth consent screen — read it back + // and cache locally so `host start` and other commands know which org to use. try { const api = createApiClient(config); const user = await api.user.me.query(); const org = await api.user.myOrganization.query(); p.log.info(`${user.name} (${user.email})`); - if (org) p.log.info(`Organization: ${org.name}`); + + if (org) { + config.activeOrg = { id: org.id, name: org.name, slug: org.slug }; + writeConfig(config); + p.log.info(`Organization: ${org.name}`); + } else { + p.log.warn("No organization selected."); + } } catch { // Non-fatal — login succeeded even if whoami fails } p.outro("Logged in successfully."); - return { data: { apiUrl } }; + return { data: { apiUrl, activeOrg: config.activeOrg } }; }, }); diff --git a/packages/cli/src/commands/host/start/command.ts b/packages/cli/src/commands/host/start/command.ts index 3683e57af0a..50055064fff 100644 --- a/packages/cli/src/commands/host/start/command.ts +++ b/packages/cli/src/commands/host/start/command.ts @@ -1,20 +1,82 @@ -import { boolean, command, number } from "@superset/cli-framework"; +import * as p from "@clack/prompts"; +import { boolean, CLIError, command, number } from "@superset/cli-framework"; +import { readConfig } from "../../../lib/config"; +import { isProcessAlive, readManifest } from "../../../lib/host/manifest"; +import { spawnHostService } from "../../../lib/host/spawn"; export default command({ description: "Start the host service", options: { daemon: boolean().desc("Run in background"), - port: number().default(51741).desc("Port to listen on"), + port: number().desc("Port to listen on"), }, run: async (opts) => { - if (opts.options.daemon) { - // TODO: fork to background + const config = readConfig(); + + if (!config.auth?.accessToken) { + throw new CLIError("Not authenticated", "Run: superset auth login"); + } + + if (!config.activeOrg) { + throw new CLIError("No active organization", "Run: superset org switch"); + } + + const { id: organizationId, name: orgName } = config.activeOrg; + + // Check if already running + const existing = readManifest(organizationId); + if (existing && isProcessAlive(existing.pid)) { + return { + data: { pid: existing.pid, endpoint: existing.endpoint }, + message: `Host service already running for ${orgName} (pid ${existing.pid})`, + }; + } + + p.intro(`superset host start (${orgName})`); + const spinner = p.spinner(); + spinner.start("Starting host service..."); + + try { + const result = await spawnHostService({ + organizationId, + sessionToken: config.auth.accessToken, + port: opts.options.port, + daemon: opts.options.daemon ?? false, + }); + + spinner.stop( + `Host service running on port ${result.port} (pid ${result.pid})`, + ); + p.log.info("Connected to relay — machine is now accessible."); + + if (opts.options.daemon) { + p.outro("Running in background."); + return { + data: { + pid: result.pid, + port: result.port, + organizationId, + }, + message: `Host service started for ${orgName}`, + }; + } + + p.outro("Press Ctrl+C to stop."); + + // Foreground: wait for signal + await new Promise((resolve) => { + opts.signal.addEventListener("abort", () => resolve(), { once: true }); + }); + return { - data: { pid: 0, port: opts.options.port }, - message: "Host service started", + data: { pid: result.pid, port: result.port, organizationId }, + message: "Host service stopped", }; + } catch (error) { + spinner.stop("Failed to start host service"); + throw new CLIError( + error instanceof Error ? error.message : "Unknown error", + ); } - // TODO: foreground mode with opts.signal for cleanup - return { message: "Not implemented yet" }; }, }); diff --git a/packages/cli/src/commands/host/status/command.ts b/packages/cli/src/commands/host/status/command.ts index 16fc5cc940e..3342da14ddd 100644 --- a/packages/cli/src/commands/host/status/command.ts +++ b/packages/cli/src/commands/host/status/command.ts @@ -1,9 +1,73 @@ -import { command } from "@superset/cli-framework"; +import { CLIError, command } from "@superset/cli-framework"; +import { readConfig } from "../../../lib/config"; +import { isProcessAlive, readManifest } from "../../../lib/host/manifest"; + +async function checkHealth( + endpoint: string, + authToken: string, +): Promise { + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 2_000); + const res = await fetch(`${endpoint}/trpc/health.check`, { + signal: controller.signal, + headers: { Authorization: `Bearer ${authToken}` }, + }); + clearTimeout(timeout); + return res.ok; + } catch { + return false; + } +} export default command({ description: "Check host service status", run: async () => { - // TODO: check PID file - return { data: { running: false }, message: "Host service is not running" }; + const config = readConfig(); + + if (!config.activeOrg) { + throw new CLIError("No active organization", "Run: superset org switch"); + } + + const { id: organizationId, name: orgName } = config.activeOrg; + const manifest = readManifest(organizationId); + + if (!manifest) { + return { + data: { running: false, organizationId }, + message: `Not running for ${orgName}`, + }; + } + + const alive = isProcessAlive(manifest.pid); + if (!alive) { + return { + data: { + running: false, + stale: true, + pid: manifest.pid, + organizationId, + }, + message: `Stale manifest for ${orgName} (pid ${manifest.pid} is dead)`, + }; + } + + const healthy = await checkHealth(manifest.endpoint, manifest.authToken); + const uptimeMs = Date.now() - manifest.startedAt; + const uptimeSec = Math.floor(uptimeMs / 1000); + + return { + data: { + running: true, + healthy, + pid: manifest.pid, + endpoint: manifest.endpoint, + organizationId, + uptimeSec, + }, + message: `${orgName}: running (pid ${manifest.pid}, ${uptimeSec}s)${ + healthy ? "" : " — not responding to health check" + }`, + }; }, }); diff --git a/packages/cli/src/commands/host/stop/command.ts b/packages/cli/src/commands/host/stop/command.ts index 7712fc0f3c2..3889088b6e5 100644 --- a/packages/cli/src/commands/host/stop/command.ts +++ b/packages/cli/src/commands/host/stop/command.ts @@ -1,9 +1,62 @@ -import { command } from "@superset/cli-framework"; +import { CLIError, command } from "@superset/cli-framework"; +import { readConfig } from "../../../lib/config"; +import { + isProcessAlive, + readManifest, + removeManifest, +} from "../../../lib/host/manifest"; export default command({ description: "Stop the host service daemon", run: async () => { - // TODO: read PID file, kill process - return { message: "Not implemented yet" }; + const config = readConfig(); + + if (!config.activeOrg) { + throw new CLIError("No active organization", "Run: superset org switch"); + } + + const { id: organizationId, name: orgName } = config.activeOrg; + const manifest = readManifest(organizationId); + + if (!manifest) { + return { + data: { running: false }, + message: `No host service running for ${orgName}`, + }; + } + + if (isProcessAlive(manifest.pid)) { + try { + process.kill(manifest.pid, "SIGTERM"); + } catch (error) { + throw new CLIError( + `Failed to stop host service (pid ${manifest.pid}): ${ + error instanceof Error ? error.message : "unknown error" + }`, + ); + } + + // Wait for the process to actually exit so concurrent `host start` + // calls can't race ahead and spawn a duplicate. + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + if (!isProcessAlive(manifest.pid)) break; + await new Promise((r) => setTimeout(r, 100)); + } + + if (isProcessAlive(manifest.pid)) { + // Escalate to SIGKILL if it refuses to exit + try { + process.kill(manifest.pid, "SIGKILL"); + } catch {} + } + } + + removeManifest(organizationId); + + return { + data: { pid: manifest.pid, organizationId }, + message: `Stopped host service for ${orgName}`, + }; }, }); diff --git a/packages/cli/src/commands/org/switch/command.ts b/packages/cli/src/commands/org/switch/command.ts index 7a792dbf1d7..04d9bf95415 100644 --- a/packages/cli/src/commands/org/switch/command.ts +++ b/packages/cli/src/commands/org/switch/command.ts @@ -1,7 +1,7 @@ import * as p from "@clack/prompts"; import { CLIError, command, positional } from "@superset/cli-framework"; import type { ApiClient } from "../../../lib/api-client"; -import { getApiUrl, readConfig } from "../../../lib/config"; +import { getApiUrl, readConfig, writeConfig } from "../../../lib/config"; export default command({ description: "Switch active organization", @@ -12,7 +12,8 @@ export default command({ const api = opts.ctx.api as ApiClient; const nameOrId = opts.args.nameOrId as string | undefined; const orgs = await api.user.myOrganizations.query(); - const currentOrg = await api.user.myOrganization.query(); + const config = readConfig(); + const currentOrgId = config.activeOrg?.id; let org: (typeof orgs)[number] | undefined; @@ -38,7 +39,7 @@ export default command({ options: orgs.map((o) => ({ value: o.id, label: o.name, - hint: o.id === currentOrg?.id ? "active" : undefined, + hint: o.id === currentOrgId ? "active" : undefined, })), }); @@ -50,15 +51,18 @@ export default command({ if (!org) throw new CLIError("Organization not found"); } - if (org.id === currentOrg?.id) { + if (org.id === currentOrgId) { return { data: { id: org.id, name: org.name }, message: `Already on ${org.name}`, }; } - // Set active org via Better Auth - const config = readConfig(); + // Persist locally so host commands use this org + config.activeOrg = { id: org.id, name: org.name, slug: org.slug }; + writeConfig(config); + + // Sync server-side active org for tools that read the session const apiUrl = getApiUrl(config); const res = await fetch(`${apiUrl}/api/auth/organization/set-active`, { method: "POST", diff --git a/packages/cli/src/lib/config.ts b/packages/cli/src/lib/config.ts index 58a3c7287e2..bb5154c1223 100644 --- a/packages/cli/src/lib/config.ts +++ b/packages/cli/src/lib/config.ts @@ -2,11 +2,17 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; +export interface ActiveOrg { + id: string; + name: string; + slug: string; +} + export type SupersetConfig = { auth?: { accessToken: string; }; - activeOrg?: string; + activeOrg?: ActiveOrg; apiUrl?: string; clientIds?: Record; }; @@ -16,13 +22,13 @@ export type DeviceConfig = { deviceName: string; }; -const CONFIG_DIR = join(homedir(), ".superset"); -const CONFIG_PATH = join(CONFIG_DIR, "config.json"); -const DEVICE_PATH = join(CONFIG_DIR, "device.json"); +export const SUPERSET_HOME_DIR = join(homedir(), "superset"); +const CONFIG_PATH = join(SUPERSET_HOME_DIR, "config.json"); +const DEVICE_PATH = join(SUPERSET_HOME_DIR, "device.json"); function ensureDir() { - if (!existsSync(CONFIG_DIR)) { - mkdirSync(CONFIG_DIR, { recursive: true }); + if (!existsSync(SUPERSET_HOME_DIR)) { + mkdirSync(SUPERSET_HOME_DIR, { recursive: true, mode: 0o700 }); } } diff --git a/packages/cli/src/lib/env.ts b/packages/cli/src/lib/env.ts new file mode 100644 index 00000000000..fac8368a400 --- /dev/null +++ b/packages/cli/src/lib/env.ts @@ -0,0 +1,10 @@ +/** + * Build-time constants baked into the CLI binary via `bun build --define`. + * At runtime (when running `bun src/bin.ts` without compilation), falls back + * to actual process.env so local dev can override these. + */ + +export const env = { + RELAY_URL: process.env.RELAY_URL || "https://relay.superset.sh", + CLOUD_API_URL: process.env.CLOUD_API_URL || "https://api.superset.sh", +}; diff --git a/packages/cli/src/lib/host/manifest.ts b/packages/cli/src/lib/host/manifest.ts new file mode 100644 index 00000000000..249f20d089c --- /dev/null +++ b/packages/cli/src/lib/host/manifest.ts @@ -0,0 +1,77 @@ +import { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; +import { SUPERSET_HOME_DIR } from "../config"; + +/** + * Manifest format matches the desktop app's HostServiceManifest + * (apps/desktop/src/main/lib/host-service-manifest.ts) so both clients + * can read each other's manifests. + */ +export interface HostServiceManifest { + pid: number; + endpoint: string; + authToken: string; + startedAt: number; + organizationId: string; +} + +function manifestDir(organizationId: string): string { + return join(SUPERSET_HOME_DIR, "host", organizationId); +} + +function manifestPath(organizationId: string): string { + return join(manifestDir(organizationId), "manifest.json"); +} + +export function ensureManifestDir(organizationId: string): string { + const dir = manifestDir(organizationId); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + return dir; +} + +export function writeManifest(manifest: HostServiceManifest): void { + ensureManifestDir(manifest.organizationId); + const path = manifestPath(manifest.organizationId); + writeFileSync(path, JSON.stringify(manifest, null, 2), { mode: 0o600 }); + chmodSync(path, 0o600); +} + +export function readManifest( + organizationId: string, +): HostServiceManifest | null { + const path = manifestPath(organizationId); + if (!existsSync(path)) return null; + try { + return JSON.parse(readFileSync(path, "utf-8")) as HostServiceManifest; + } catch { + return null; + } +} + +export function removeManifest(organizationId: string): void { + const path = manifestPath(organizationId); + if (existsSync(path)) rmSync(path); +} + +export function isProcessAlive(pid: number): boolean { + if (!pid) return false; + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +export function hostDbPath(organizationId: string): string { + return join(manifestDir(organizationId), "host.db"); +} diff --git a/packages/cli/src/lib/host/spawn.ts b/packages/cli/src/lib/host/spawn.ts new file mode 100644 index 00000000000..fe07b609eab --- /dev/null +++ b/packages/cli/src/lib/host/spawn.ts @@ -0,0 +1,144 @@ +import { spawn } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { existsSync } from "node:fs"; +import { createServer } from "node:net"; +import { dirname, join } from "node:path"; +import { env } from "../env"; +import { + type HostServiceManifest, + hostDbPath, + writeManifest, +} from "./manifest"; + +const HEALTH_POLL_INTERVAL_MS = 200; +const HEALTH_POLL_TIMEOUT_MS = 10_000; + +export interface SpawnHostOptions { + organizationId: string; + sessionToken: string; + port?: number; + daemon: boolean; +} + +export interface SpawnHostResult { + pid: number; + port: number; + secret: string; +} + +async function findFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.listen(0, "127.0.0.1", () => { + const addr = server.address(); + if (addr && typeof addr === "object") { + const { port } = addr; + server.close(() => resolve(port)); + } else { + server.close(() => reject(new Error("Could not get port"))); + } + }); + server.on("error", reject); + }); +} + +async function pollHealth(port: number, secret: string): Promise { + const deadline = Date.now() + HEALTH_POLL_TIMEOUT_MS; + while (Date.now() < deadline) { + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 2_000); + const res = await fetch(`http://127.0.0.1:${port}/trpc/health.check`, { + signal: controller.signal, + headers: { Authorization: `Bearer ${secret}` }, + }); + clearTimeout(timeout); + if (res.ok) return true; + } catch { + // not ready + } + await new Promise((r) => setTimeout(r, HEALTH_POLL_INTERVAL_MS)); + } + return false; +} + +/** + * Resolve the sibling `superset-host` wrapper binary. + * + * When running as a compiled binary, it's a sibling file in the same bin/ + * directory as the current executable. When running via `bun src/bin.ts` + * in dev, allow override via SUPERSET_HOST_BIN env var. + */ +function resolveHostBinary(): string { + if (process.env.SUPERSET_HOST_BIN) return process.env.SUPERSET_HOST_BIN; + const cliBin = process.execPath; + return join(dirname(cliBin), "superset-host"); +} + +function resolveMigrationsFolder(): string { + if (process.env.HOST_MIGRATIONS_FOLDER) { + return process.env.HOST_MIGRATIONS_FOLDER; + } + // Compiled layout: /bin/superset → /share/migrations + const cliBin = process.execPath; + const bundleRoot = dirname(dirname(cliBin)); + return join(bundleRoot, "share", "migrations"); +} + +export async function spawnHostService( + options: SpawnHostOptions, +): Promise { + const hostBin = resolveHostBinary(); + if (!existsSync(hostBin)) { + throw new Error( + `superset-host binary not found at ${hostBin}. Set SUPERSET_HOST_BIN to override.`, + ); + } + + const port = options.port ?? (await findFreePort()); + const secret = randomBytes(32).toString("hex"); + const migrationsFolder = resolveMigrationsFolder(); + + const child = spawn(hostBin, [], { + stdio: options.daemon ? "ignore" : "inherit", + detached: options.daemon, + env: { + ...process.env, + ORGANIZATION_ID: options.organizationId, + AUTH_TOKEN: options.sessionToken, + CLOUD_API_URL: env.CLOUD_API_URL, + RELAY_URL: env.RELAY_URL, + HOST_SERVICE_PORT: String(port), + HOST_SERVICE_SECRET: secret, + HOST_DB_PATH: hostDbPath(options.organizationId), + HOST_MIGRATIONS_FOLDER: migrationsFolder, + }, + }); + + if (!child.pid) { + throw new Error("Failed to spawn host-service"); + } + + const healthy = await pollHealth(port, secret); + if (!healthy) { + child.kill("SIGTERM"); + throw new Error( + `Host service failed to start within ${HEALTH_POLL_TIMEOUT_MS}ms`, + ); + } + + const manifest: HostServiceManifest = { + pid: child.pid, + endpoint: `http://127.0.0.1:${port}`, + authToken: secret, + startedAt: Date.now(), + organizationId: options.organizationId, + }; + writeManifest(manifest); + + if (options.daemon) { + child.unref(); + } + + return { pid: child.pid, port, secret }; +} diff --git a/packages/host-service/build.ts b/packages/host-service/build.ts new file mode 100644 index 00000000000..7ebc6475e6a --- /dev/null +++ b/packages/host-service/build.ts @@ -0,0 +1,31 @@ +/** + * Bundles the host-service entry point into a single JS file that can be + * executed by a standalone Node.js runtime. Native addons (better-sqlite3, + * node-pty) are marked external and must be resolved at runtime from + * lib/native/ in the distribution bundle. + */ +import { existsSync, mkdirSync } from "node:fs"; + +const outdir = "dist"; +if (!existsSync(outdir)) { + mkdirSync(outdir, { recursive: true }); +} + +const result = await Bun.build({ + entrypoints: ["src/serve.ts"], + target: "node", + outdir, + naming: "host-service.js", + format: "esm", + external: ["better-sqlite3", "node-pty", "@parcel/watcher"], +}); + +if (!result.success) { + console.error("[host-service] build failed:"); + for (const log of result.logs) { + console.error(log); + } + process.exit(1); +} + +console.log(`[host-service] bundled to ${outdir}/host-service.js`); diff --git a/packages/host-service/package.json b/packages/host-service/package.json index d90ee5d4860..f3c646681e4 100644 --- a/packages/host-service/package.json +++ b/packages/host-service/package.json @@ -36,6 +36,7 @@ "scripts": { "clean": "git clean -xdf .cache .turbo dist node_modules", "dev": "bun run src/serve.ts", + "build:host": "bun run build.ts", "generate": "drizzle-kit generate", "typecheck": "tsc --noEmit --emitDeclarationOnly false" },