Files
stack/packages/cli/scripts/package-dashboard-release.mjs
BilalG1andGitHub 526ce69ab9 feat(cli): fetch RDE dashboard from GitHub Releases instead of bundling (#1682)
## Summary

The RDE dashboard was bundled into the `@hexclave/cli` npm tarball
(~165MB). That made the `npx @latest` auto-update re-exec pull a huge
tarball, which tripped download firewalls (Socket Firewall
false-positives on Replit → `Lock compromised`).

This decouples the dashboard from the CLI: it's published as a **GitHub
Release artifact** (a zip + `manifest.json` on a floating
`dashboard-latest` release) and fetched at runtime, cached on disk. The
dashboard now rolls forward **independently of the CLI version**
(assuming newest dashboards stay compatible with older CLIs — no compat
gate yet, by design for now).

## How it works

- **Runtime** (`packages/cli/src/lib/dashboard-release.ts`): on
`hexclave dev`, fetch `manifest.json` (`{version, sha256, url}`) from a
stable, no-API GitHub URL → cache under
`<dirname(devEnvStatePath())>/dashboards/<version>/` → streamed
download, **sha256 verify**, extract via the existing `extract-zip` dep,
atomic publish. Offline → fall back to the newest cached build.
- **Wiring** (`dev.ts`): `startDashboardIfNeeded` resolves/launches the
cached release; the restart decision now compares the **dashboard
release** version (not the CLI version). `HEXCLAVE_DASHBOARD_DIR` runs a
local build with zero network; a custom dev dashboard command bypasses
releases entirely.
- **No more npx self-update**: the `npx @latest` re-exec in `hexclave
dev` is removed entirely (along with `self-update.ts`, the
`--no-auto-update` flag, and the binName plumbing that only built the
npx call). It existed to keep the bundled dashboard fresh; now the
dashboard self-updates from GitHub, so the re-exec only added an npx
download on every run — the firewall surface. Users update the CLI via
npm/npx themselves.
- **Publishing** (`scripts/package-dashboard-release.mjs` +
`.github/workflows/dashboard-release.yaml`): build standalone → zip →
write manifest → publish immutable `dashboard-v<version>` release +
clobber the `dashboard-latest` manifest. **Fails loudly** if an existing
tag's asset sha differs from a fresh build (dashboard changed without a
version bump → would otherwise advertise a hash the served zip doesn't
match).
- **Build**: CLI build is now `tsdown`-only (no bundled dashboard);
`turbo.json` drops the dashboard standalone build from the CLI build
graph.

## Testing

- **Unit**: 135 CLI tests pass (manifest parsing incl. path-safe version
validation, URL resolution, version picking, dir override).
- **End-to-end (no GitHub)**: a localhost HTTP server + fixture zip
exercised the full fetch path — download, sha256 verify, cache-hit,
roll-forward, **corrupt-sha rejection**, offline→cache fallback (9/9).
- **Real dashboard, no GitHub**: built the standalone, served the real
76MB zip over `python -m http.server`, and confirmed `hexclave dev`
downloads → verifies → extracts → **boots** the dashboard and registers
an RDE session against the hosted backend. Also validated
`HEXCLAVE_DASHBOARD_DIR` boot.

## Review

Three review agents (runtime correctness, packaging/workflow, code
quality) ran; valid findings fixed: path-traversal hardening on
`version`, a concurrent cache rm/rename race, the workflow
sha-mismatch/atomicity gap, plus `errorMessage` dedup and added tests.

## Notes / follow-ups

- No CLI↔dashboard compatibility gate yet (assumes always-compatible);
easy to add later via a min-CLI field in the manifest. This matters more
now that the CLI no longer auto-updates.
- The publish workflow has not run yet, so the first push to `main` will
create the `dashboard-latest` release.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added automated, immutable versioned releases for the standalone
dashboard plus a continuously updated “latest” manifest.
* CLI now fetches, verifies, caches, and supports offline fallback for
the dashboard runtime.
* **Bug Fixes**
* Improved dashboard restart behavior to update only when a strictly
newer published version is available.
* **Chores**
* Removed the CLI’s automatic update/re-launch behavior for more
predictable startup.
* **Tests**
* Added unit tests for dashboard manifest validation and latest-version
selection.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-30 16:12:13 -07:00

80 lines
3.6 KiB
JavaScript

#!/usr/bin/env node
// Packages the standalone RDE dashboard into a GitHub Release artifact: a
// dashboard-<version>.zip plus a manifest.json ({ version, sha256, url }) that
// dashboard-release.ts fetches at runtime. Run by dashboard-release.yaml;
// requires the `zip` CLI (present on ubuntu runners).
import { execFileSync } from "child_process";
import { createHash } from "crypto";
import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
import { dirname, join, resolve } from "path";
import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const packageRoot = resolve(__dirname, "..");
const repoRoot = resolve(packageRoot, "../..");
// Overridable so forks / mirrors can host their own releases.
const repository = process.env.DASHBOARD_RELEASE_REPO ?? "hexclave/hexclave";
// For local testing: point the manifest's asset URL at a static server
// (e.g. http://127.0.0.1:8000) instead of GitHub.
const baseUrlOverride = process.env.DASHBOARD_RELEASE_BASE_URL?.replace(/\/+$/, "");
// Must mirror SAFE_VERSION_REGEX in packages/cli/src/lib/dashboard-release.ts:
// the CLI rejects any manifest whose version fails this pattern, and the version
// becomes a release tag and zip filename, so fail loudly here before publishing
// an artifact every CLI would ignore.
const SAFE_VERSION_REGEX = /^v?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)*$/;
const dashboardPackageJson = JSON.parse(readFileSync(join(repoRoot, "apps/dashboard/package.json"), "utf-8"));
const version = dashboardPackageJson.version;
if (typeof version !== "string" || !SAFE_VERSION_REGEX.test(version)) {
throw new Error(`apps/dashboard/package.json has an invalid version ${JSON.stringify(version)}; expected a path-safe semver matching ${SAFE_VERSION_REGEX}.`);
}
const dashboardDist = join(packageRoot, "dist", "dashboard");
const serverEntry = join(dashboardDist, "apps", "dashboard", "server.js");
const outDir = join(packageRoot, "dashboard-release");
const zipName = `dashboard-${version}.zip`;
const zipPath = join(outDir, zipName);
const manifestPath = join(outDir, "manifest.json");
const tag = `dashboard-v${version}`;
const assetUrl = baseUrlOverride != null && baseUrlOverride.length > 0
? `${baseUrlOverride}/${zipName}`
: `https://github.com/${repository}/releases/download/${tag}/${zipName}`;
// 1. Stage the standalone dashboard runtime into dist/dashboard.
execFileSync(process.execPath, [join(__dirname, "copy-runtime-assets.mjs")], { stdio: "inherit" });
if (!existsSync(serverEntry)) {
throw new Error(`Expected a staged dashboard server at ${serverEntry}. Did build:rde-standalone run?`);
}
// 2. Zip the staged runtime so the archive root holds apps/ and node_modules/.
rmSync(outDir, { recursive: true, force: true });
mkdirSync(outDir, { recursive: true });
execFileSync("zip", ["-q", "-r", "-X", zipPath, "."], { cwd: dashboardDist, stdio: "inherit" });
// 3. Hash the archive and write the manifest the CLI fetches at runtime.
const sha256 = createHash("sha256").update(readFileSync(zipPath)).digest("hex");
writeFileSync(manifestPath, `${JSON.stringify({ version, sha256, url: assetUrl }, null, 2)}\n`);
console.log(`Packaged dashboard ${version}`);
console.log(` zip: ${zipPath}`);
console.log(` sha256: ${sha256}`);
console.log(` url: ${assetUrl}`);
console.log(` manifest: ${manifestPath}`);
// Expose values to the release workflow.
if (process.env.GITHUB_OUTPUT) {
appendFileSync(
process.env.GITHUB_OUTPUT,
[
`version=${version}`,
`tag=${tag}`,
`zip=${zipPath}`,
`zip_name=${zipName}`,
`sha256=${sha256}`,
`manifest=${manifestPath}`,
"",
].join("\n"),
);
}