Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions .github/workflows/build-cli.yml
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
Comment on lines +17 to +18
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

**Verify macos-13 runner availability.**The macos-13 runner was retired by December 4th, 2025. For x86_64 (Intel) architecture, GitHub recommends using macos-15-intel or macos-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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- os: macos-13
target: darwin-x64
- os: macos-15-intel
target: darwin-x64
🧰 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
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/build-cli.yml around lines 17 - 18, The workflow uses an
unavailable runner "os: macos-13" for the job that targets "darwin-x64"; update
the runner to a supported macOS runner (e.g., replace os: macos-13 with
macos-15-intel for Intel x86_64 or macos-14-large / macos-latest-large depending
on required VM size) so the job targeting "darwin-x64" runs on a valid
GitHub-hosted macOS runner.

- 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
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
291 changes: 291 additions & 0 deletions packages/cli/scripts/build-dist.ts
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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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
Verify each finding against the current code and only fix it if needed.

In `@packages/cli/scripts/build-dist.ts` around lines 100 - 107, The script
downloads a Node archive via nodeDownloadUrl(target) into archivePath and
immediately extracts it; add a verification step that fetches the official
checksum or signature for that Node release (or uses a bundled expected checksum
lookup keyed by target), compute the archive's digest (e.g., sha256) and compare
it to the expected value, and only proceed to extracting (the existing tar
extraction for extractedPath) if the checksum/signature verification passes;
update the logic around nodeDownloadUrl(target), archivePath and extractedPath
to fail fast with a clear error when verification fails and to skip extraction
when verification is missing or invalid.

}

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);
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Native addon packaging ignores --target and copies host-resolved binaries, which can generate broken cross-target distributions.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/cli/scripts/build-dist.ts, line 194:

<comment>Native addon packaging ignores `--target` and copies host-resolved binaries, which can generate broken cross-target distributions.</comment>

<file context>
@@ -100,82 +114,90 @@ async function downloadAndExtractNode(
+	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);
 	}
+
</file context>
Fix with Cubic

}

// 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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 NODE_PATH is insufficient for loading external native addon packages at runtime

The host wrapper sets NODE_PATH=lib/native/, but that directory only contains raw .node binaries. require('better-sqlite3') resolves to the full npm package (JS entry point + native binding). Without the JS wrapper, Node will fail with "Cannot find module" on a fresh install.

Working alternatives:

  1. Copy full node_modules/better-sqlite3/ (JS + native) into the bundle and point NODE_PATH at that directory.
  2. Use module.createRequire inside host-service.js to redirect bare specifiers to explicit absolute paths.
  3. Recreate proper package.json + JS shims under lib/node_modules/ pointing at the .node binaries.

If this was verified working with the full dist tarball (not just superset host status, which does not load native addons), please add a note confirming the mechanism.

}

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();
Loading
Loading