mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
## 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 -->
155 lines
4.4 KiB
JavaScript
155 lines
4.4 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
// Resilient wrapper for the demo dev command.
|
|
//
|
|
// The demo runs through the CLI because it needs development-environment
|
|
// credentials, but it must not call `pnpm -w run cli`: that route invokes Turbo
|
|
// builds, and several package builds start by removing dist/, racing with the
|
|
// package dev watchers that the root dev server is already running.
|
|
//
|
|
// Instead, run the CLI from TypeScript source and ask it to launch the dashboard
|
|
// through the dashboard package's RDE production command. The CLI still owns the
|
|
// development-environment env vars, so this stays close to the packaged path.
|
|
|
|
import { spawn } from "node:child_process";
|
|
import { watch } from "node:fs";
|
|
import { join, resolve } from "node:path";
|
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
|
|
const scriptDir = import.meta.dirname;
|
|
const demoRoot = resolve(scriptDir, "..");
|
|
const repoRoot = resolve(demoRoot, "../..");
|
|
|
|
const LOG_PREFIX = "[Hexclave dev-retry] ";
|
|
const RETRY_DEBOUNCE_MS = 2_000;
|
|
const RETRY_TIMEOUT_MS = 5_000;
|
|
const portPrefix = process.env.NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX ?? "81";
|
|
|
|
let cliChild;
|
|
let shutdownTimer;
|
|
|
|
function log(message) {
|
|
console.error(`${LOG_PREFIX}${message}`);
|
|
}
|
|
|
|
function spawnFromRepo(command, args, options = {}) {
|
|
return spawn(command, args, {
|
|
cwd: repoRoot,
|
|
stdio: "inherit",
|
|
env: process.env,
|
|
...options,
|
|
});
|
|
}
|
|
|
|
function runCliDev() {
|
|
return new Promise((resolvePromise, reject) => {
|
|
cliChild = spawnFromRepo("pnpm", [
|
|
"exec", "tsx", "packages/cli/src/index.ts",
|
|
"dev",
|
|
`--config-file=${join(demoRoot, "hexclave.config.ts")}`,
|
|
"--",
|
|
"pnpm", "--dir", "examples/demo", "run", "dev:inner",
|
|
], {
|
|
detached: process.platform !== "win32",
|
|
env: {
|
|
...process.env,
|
|
HEXCLAVE_CLI_DEV_DASHBOARD_COMMAND: "pnpm --dir apps/dashboard run dev:rde-production",
|
|
STACK_API_URL: `http://localhost:${portPrefix}02`,
|
|
STACK_DASHBOARD_URL: `http://localhost:${portPrefix}01`,
|
|
STACK_CLI_PUBLISHABLE_CLIENT_KEY: "this-publishable-client-key-is-for-local-development-only",
|
|
},
|
|
});
|
|
|
|
cliChild.on("close", (code, signal) => {
|
|
cliChild = undefined;
|
|
resolvePromise({ code: code ?? 1, signalled: signal != null });
|
|
});
|
|
cliChild.on("error", (err) => {
|
|
cliChild = undefined;
|
|
reject(err);
|
|
});
|
|
});
|
|
}
|
|
|
|
function waitForFileChanges() {
|
|
return new Promise((resolvePromise) => {
|
|
const watchDirs = [
|
|
join(repoRoot, "apps", "dashboard"),
|
|
join(repoRoot, "packages"),
|
|
];
|
|
const watchers = [];
|
|
let resolved = false;
|
|
|
|
const done = () => {
|
|
if (resolved) return;
|
|
resolved = true;
|
|
for (const w of watchers) {
|
|
try { w.close(); } catch { /* ignore */ }
|
|
}
|
|
resolvePromise();
|
|
};
|
|
|
|
for (const dir of watchDirs) {
|
|
try {
|
|
const w = watch(dir, { recursive: true }, done);
|
|
w.on("error", () => { /* ignore watch errors */ });
|
|
watchers.push(w);
|
|
} catch {
|
|
// directory might not exist yet
|
|
}
|
|
}
|
|
|
|
// Dashboard startup can complete without a source-file change after the CLI
|
|
// has already failed its first health check, so always keep a timed retry.
|
|
setTimeout(done, RETRY_TIMEOUT_MS);
|
|
});
|
|
}
|
|
|
|
async function main() {
|
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
while (true) {
|
|
const { code, signalled } = await runCliDev();
|
|
|
|
if (signalled || code === 0) {
|
|
stopChildren("SIGTERM");
|
|
process.exit(code);
|
|
}
|
|
|
|
log(`Dev command exited with code ${code}. Watching for file changes before retrying...`);
|
|
await waitForFileChanges();
|
|
log(`Retrying in ${RETRY_DEBOUNCE_MS / 1000}s...`);
|
|
await sleep(RETRY_DEBOUNCE_MS);
|
|
}
|
|
}
|
|
|
|
function stopChildren(signal) {
|
|
if (cliChild != null && !cliChild.killed) {
|
|
try {
|
|
if (cliChild.pid != null && process.platform !== "win32") {
|
|
process.kill(-cliChild.pid, signal);
|
|
} else {
|
|
cliChild.kill(signal);
|
|
}
|
|
} catch {
|
|
// best-effort
|
|
}
|
|
}
|
|
}
|
|
|
|
process.on("SIGINT", () => {
|
|
stopChildren("SIGINT");
|
|
shutdownTimer ??= setTimeout(() => process.exit(130), 5_000);
|
|
shutdownTimer.unref();
|
|
});
|
|
process.on("SIGTERM", () => {
|
|
stopChildren("SIGTERM");
|
|
shutdownTimer ??= setTimeout(() => process.exit(143), 5_000);
|
|
shutdownTimer.unref();
|
|
});
|
|
|
|
main().catch((err) => {
|
|
console.error(err);
|
|
stopChildren("SIGTERM");
|
|
process.exit(1);
|
|
});
|