-
Notifications
You must be signed in to change notification settings - Fork 970
feat(cli): standalone distribution with embedded host-service #3298
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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-<target>/ | ||
| * 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=<platform-arch>"); | ||
| 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<void> { | ||
| 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<string> { | ||
| 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]); | ||
|
Comment on lines
+100
to
+107
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Verify the downloaded Node runtime before extracting it. This immediately packages an executable fetched over the network. Without a checksum or signature check, a bad mirror, proxy, or partial cache entry can poison the release artifact. 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| 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/<packageName> | ||
| 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<string>, | ||
| ): 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<string>(); | ||
|
|
||
| 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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: Native addon packaging ignores Prompt for AI agents |
||
| } | ||
|
|
||
| // 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<void> { | ||
| 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<string> { | ||
| 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); | ||
|
Comment on lines
+232
to
+240
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The host wrapper sets Working alternatives:
If this was verified working with the full dist tarball (not just |
||
| } | ||
|
|
||
| async function main(): Promise<void> { | ||
| 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(); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
**Verify
macos-13runner availability.**Themacos-13runner was retired by December 4th, 2025. For x86_64 (Intel) architecture, GitHub recommends usingmacos-15-intelormacos-14-large/macos-latest-large.🔧 Proposed fix
include: - os: macos-14 target: darwin-arm64 - - os: macos-13 + - os: macos-15-intel target: darwin-x64 - os: ubuntu-latest target: linux-x64📝 Committable suggestion
🧰 Tools
🪛 actionlint (1.7.12)
[error] 17-17: label "macos-13" is unknown. available labels are "windows-latest", "windows-latest-8-cores", "windows-2025", "windows-2025-vs2026", "windows-2022", "windows-11-arm", "ubuntu-slim", "ubuntu-latest", "ubuntu-latest-4-cores", "ubuntu-latest-8-cores", "ubuntu-latest-16-cores", "ubuntu-24.04", "ubuntu-24.04-arm", "ubuntu-22.04", "ubuntu-22.04-arm", "macos-latest", "macos-latest-xlarge", "macos-latest-large", "macos-26-intel", "macos-26-xlarge", "macos-26-large", "macos-26", "macos-15-intel", "macos-15-xlarge", "macos-15-large", "macos-15", "macos-14-xlarge", "macos-14-large", "macos-14", "self-hosted", "x64", "arm", "arm64", "linux", "macos", "windows". if it is a custom label for self-hosted runner, set list of labels in actionlint.yaml config file
(runner-label)
🤖 Prompt for AI Agents