diff --git a/.github/workflows/dashboard-release.yaml b/.github/workflows/dashboard-release.yaml new file mode 100644 index 000000000..ad9687f54 --- /dev/null +++ b/.github/workflows/dashboard-release.yaml @@ -0,0 +1,144 @@ +name: Publish RDE dashboard release + +# Builds the standalone RDE dashboard and publishes it as a GitHub Release +# artifact (instead of bundling it into the @hexclave/cli npm tarball). The CLI +# fetches the newest build at runtime via the `dashboard-latest` manifest. See +# packages/cli/src/lib/dashboard-release.ts. + +on: + push: + branches: + - main + # Only rebuild/publish when the dashboard or its packaging actually changes. + # A new release is keyed by the version in apps/dashboard/package.json, so + # changes outside these paths can't produce a new dashboard build anyway. + paths: + - 'apps/dashboard/**' + - 'packages/cli/scripts/package-dashboard-release.mjs' + - 'packages/cli/scripts/copy-runtime-assets.mjs' + - '.github/workflows/dashboard-release.yaml' + +permissions: + contents: write # create releases and upload assets + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false # don't interrupt a publish in progress + +jobs: + publish-dashboard: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + # Don't leave the contents:write token in .git/config for the + # install/build/package steps; the gh steps below pass GH_TOKEN directly. + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: '22.x' + + - name: Setup pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build dashboard standalone + run: pnpm exec turbo run build:rde-standalone --filter=@hexclave/dashboard + + - name: Package dashboard release + id: package + env: + DASHBOARD_RELEASE_REPO: ${{ github.repository }} + run: pnpm --filter @hexclave/cli run package-dashboard-release + + - name: Publish versioned release (immutable) + id: publish + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.package.outputs.tag }} + ZIP: ${{ steps.package.outputs.zip }} + ZIP_NAME: ${{ steps.package.outputs.zip_name }} + VERSION: ${{ steps.package.outputs.version }} + EXPECTED_SHA: ${{ steps.package.outputs.sha256 }} + run: | + set -euo pipefail + # A published dashboard-v asset is immutable, so never replace + # it; advertise the sha of whatever asset is live. (The rebuild isn't + # byte-reproducible — staged files get fresh mtimes — so we can't just + # trust EXPECTED_SHA when reusing an existing asset.) + live_sha="$EXPECTED_SHA" + if gh release view "$TAG" >/dev/null 2>&1; then + if gh release view "$TAG" --json assets -q '.assets[].name' | grep -qx "$ZIP_NAME"; then + tmp="$(mktemp -d)" + gh release download "$TAG" -p "$ZIP_NAME" -D "$tmp" + live_sha="$(sha256sum "$tmp/$ZIP_NAME" | cut -d' ' -f1)" + if [ "$live_sha" != "$EXPECTED_SHA" ]; then + echo "::warning::$TAG already has a published asset whose sha256 ($live_sha) differs from this rebuild ($EXPECTED_SHA). Releases are immutable, so the existing asset is kept and advertised. A differing hash is expected for nondeterministic rebuilds of identical content; if you intended to ship NEW dashboard content, bump the version in apps/dashboard/package.json." + else + echo "Existing $TAG asset matches the build; reusing it." + fi + else + echo "$TAG exists without its asset; uploading." + gh release upload "$TAG" "$ZIP" --clobber + fi + else + gh release create "$TAG" "$ZIP" \ + --title "RDE dashboard $VERSION" \ + --notes "Standalone RDE dashboard build for \`hexclave dev\` (version $VERSION)." + fi + echo "live_sha=$live_sha" >> "$GITHUB_OUTPUT" + + - name: Update floating latest manifest + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + MANIFEST: ${{ steps.package.outputs.manifest }} + VERSION: ${{ steps.package.outputs.version }} + LIVE_SHA: ${{ steps.publish.outputs.live_sha }} + run: | + set -euo pipefail + # Advertise the live asset's sha, which may differ from the rebuild's. + tmp="$(mktemp)" + jq --arg s "$LIVE_SHA" '.sha256 = $s' "$MANIFEST" > "$tmp" + mv "$tmp" "$MANIFEST" + # dashboard-latest is only ever a plain X.Y.Z release. Refuse to advance + # it to a prerelease/build-metadata version rather than guess SemVer + # prerelease ordering in bash (`sort -V` is not a SemVer comparator). + # The dashboard always ships plain releases, so this should never fire. + new="${VERSION#v}" + case "$new" in + *[-+]*) + echo "::error::Refusing to publish prerelease/build version $VERSION as dashboard-latest; it must be a plain X.Y.Z release." + exit 1 + ;; + esac + if gh release view dashboard-latest >/dev/null 2>&1; then + # dashboard-latest must only ever move forward. Re-running on an older + # commit must not roll clients back to a stale dashboard. Re-publishing + # the same version is allowed (it refreshes the advertised sha). + published_dir="$(mktemp -d)" + if gh release download dashboard-latest -p manifest.json -D "$published_dir" 2>/dev/null; then + published_version="$(jq -r '.version // ""' "$published_dir/manifest.json")" + # Both sides are plain releases (the published one was gated the same + # way), so a leading-'v'-stripped, numeric-aware `sort -V` is exact. + pub="${published_version#v}" + if [ -n "$pub" ] && [ "$pub" != "$new" ]; then + newest="$(printf '%s\n%s\n' "$pub" "$new" | sort -V | tail -n1)" + if [ "$newest" = "$pub" ]; then + echo "::warning::dashboard-latest already points at $published_version, which is newer than $VERSION; refusing to roll it back." + exit 0 + fi + fi + fi + else + gh release create dashboard-latest \ + --title "RDE dashboard (latest)" \ + --notes "Always points at the newest published RDE dashboard. The manifest.json asset is consumed by the Hexclave CLI at runtime." \ + --latest=false + fi + gh release upload dashboard-latest "$MANIFEST" --clobber + echo "dashboard-latest now points at $VERSION (sha256 $LIVE_SHA)." diff --git a/examples/demo/scripts/dev-with-retry.mjs b/examples/demo/scripts/dev-with-retry.mjs index 09331922e..5ffb4707c 100644 --- a/examples/demo/scripts/dev-with-retry.mjs +++ b/examples/demo/scripts/dev-with-retry.mjs @@ -46,7 +46,6 @@ function runCliDev() { cliChild = spawnFromRepo("pnpm", [ "exec", "tsx", "packages/cli/src/index.ts", "dev", - "--no-auto-update", `--config-file=${join(demoRoot, "hexclave.config.ts")}`, "--", "pnpm", "--dir", "examples/demo", "run", "dev:inner", @@ -58,7 +57,6 @@ function runCliDev() { 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", - STACK_CLI_NO_AUTO_UPDATE: "1", }, }); diff --git a/packages/cli/.gitignore b/packages/cli/.gitignore new file mode 100644 index 000000000..cef846f75 --- /dev/null +++ b/packages/cli/.gitignore @@ -0,0 +1 @@ +dashboard-release/ diff --git a/packages/cli/package.json b/packages/cli/package.json index 927f00498..3536713af 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -10,7 +10,8 @@ }, "scripts": { "clean": "rimraf node_modules && rimraf dist", - "build": "tsdown && node scripts/copy-runtime-assets.mjs", + "build": "tsdown", + "package-dashboard-release": "node scripts/package-dashboard-release.mjs", "lint": "eslint --ext .tsx,.ts .", "typecheck": "tsc --noEmit", "test": "vitest run" @@ -33,6 +34,7 @@ "@hexclave/js": "workspace:*", "@hexclave/shared": "workspace:*", "commander": "^13.1.0", + "extract-zip": "^2.0.1", "jiti": "^2.4.2" }, "devDependencies": { diff --git a/packages/cli/scripts/package-dashboard-release.mjs b/packages/cli/scripts/package-dashboard-release.mjs new file mode 100644 index 000000000..ef0b229c0 --- /dev/null +++ b/packages/cli/scripts/package-dashboard-release.mjs @@ -0,0 +1,79 @@ +#!/usr/bin/env node +// Packages the standalone RDE dashboard into a GitHub Release artifact: a +// dashboard-.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"), + ); +} diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index e59facde2..bdcf20605 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -2,15 +2,13 @@ import { execFileSync, spawn, type ChildProcess } from "child_process"; import { Command } from "commander"; import { chmodSync, closeSync, cpSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "fs"; import { dirname, join, resolve } from "path"; -import { fileURLToPath } from "url"; import { DEFAULT_API_URL, DEFAULT_PUBLISHABLE_CLIENT_KEY, resolveLoginConfig } from "../lib/auth.js"; import { forwardSignals } from "../lib/child-process.js"; import { resolveConfigFilePathOption } from "../lib/config-file-path.js"; +import { DASHBOARD_SERVER_RELATIVE_PATH, dashboardDirOverride, fetchDashboardManifest, resolveDashboardRuntime, type DashboardManifest } from "../lib/dashboard-release.js"; import { devEnvStatePath, ensureLocalDashboardSecret, readDevEnvState, recordLocalDashboardProcess } from "../lib/dev-env-state.js"; -import { CliError } from "../lib/errors.js"; +import { CliError, errorMessage } from "../lib/errors.js"; import { DASHBOARD_PORT_ENV_VAR, dashboardPort, dashboardRequest, dashboardUrl, createRemoteDevelopmentEnvironmentSession, type DashboardSessionResponse } from "../lib/local-dashboard.js"; -import { cliVersion } from "../lib/own-package.js"; -import { maybeReexecToLatest, REEXEC_MARKER_ENV } from "../lib/self-update.js"; type ChildCommand = { command: string, @@ -19,7 +17,6 @@ type ChildCommand = { type DevOptions = { configFile?: string, - autoUpdate?: boolean, }; type ConfigSyncEventBase = { @@ -51,8 +48,6 @@ const DASHBOARD_HEALTH_PATH = "/api/development-environment/health"; const DEV_DASHBOARD_COMMAND_ENV_VAR = "HEXCLAVE_CLI_DEV_DASHBOARD_COMMAND"; const DEV_DASHBOARD_DIST_DIR_ENV_VAR = "HEXCLAVE_DASHBOARD_NEXT_DIST_DIR"; const RDE_DASHBOARD_LOG_PATH_ENV_VAR = "HEXCLAVE_RDE_DASHBOARD_LOG_PATH"; -const BUNDLED_DASHBOARD_DIR_NAME = "dashboard"; -const BUNDLED_DASHBOARD_SERVER_PATH = join("apps", "dashboard", "server.js"); const DASHBOARD_RUNTIME_DIR_NAME = "rde-dashboard-runtime"; const SENTINEL_PREFIX = "STACK_ENV_VAR_SENTINEL_"; const USE_INLINE_ENV_VARS_SENTINEL = "STACK_ENV_VAR_SENTINEL_USE_INLINE_ENV_VARS"; @@ -85,10 +80,6 @@ function wait(ms: number): Promise { return new Promise((resolvePromise) => setTimeout(resolvePromise, ms)); } -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - function splitDevCommandArgs(commandArgs: string[]): ChildCommand { if (commandArgs.length === 0) { throw new CliError("Missing command. Usage: hexclave dev --config-file -- [args...]"); @@ -188,20 +179,6 @@ function startProgressLog(message: string): ProgressLogger { }; } -function bundledDashboardRoot(): string { - return join(dirname(fileURLToPath(import.meta.url)), BUNDLED_DASHBOARD_DIR_NAME); -} - -function assertBundledDashboardExists(): void { - const serverPath = join(bundledDashboardRoot(), BUNDLED_DASHBOARD_SERVER_PATH); - if (!existsSync(serverPath)) { - throw new CliError([ - "This stack-cli build does not include the bundled development-environment dashboard.", - "Build the CLI package with the dashboard standalone assets before running `hexclave dev`.", - ].join(" ")); - } -} - function dashboardRuntimeRoot(port: number): string { return join(dirname(devEnvStatePath()), `${DASHBOARD_RUNTIME_DIR_NAME}-${port}`); } @@ -253,17 +230,19 @@ function dashboardRuntimeLockPath(port: number): string { return `${dashboardRuntimeRoot(port)}.lock`; } -function prepareDashboardRuntime(env: NodeJS.ProcessEnv, port: number): string { - assertBundledDashboardExists(); +function prepareDashboardRuntime(env: NodeJS.ProcessEnv, port: number, dashboardRoot: string): string { + if (!existsSync(join(dashboardRoot, DASHBOARD_SERVER_RELATIVE_PATH))) { + throw new CliError("The Hexclave development-environment dashboard is missing its server entrypoint."); + } const runtimeRoot = dashboardRuntimeRoot(port); mkdirSync(dirname(runtimeRoot), { recursive: true }); rmSync(runtimeRoot, { recursive: true, force: true }); - cpSync(bundledDashboardRoot(), runtimeRoot, { recursive: true }); + cpSync(dashboardRoot, runtimeRoot, { recursive: true }); replaceDashboardRuntimeSentinels(runtimeRoot, env); - const runtimeServerPath = join(runtimeRoot, BUNDLED_DASHBOARD_SERVER_PATH); + const runtimeServerPath = join(runtimeRoot, DASHBOARD_SERVER_RELATIVE_PATH); if (!existsSync(runtimeServerPath)) { - throw new CliError("The bundled development-environment dashboard is missing its server entrypoint."); + throw new CliError("The Hexclave development-environment dashboard is missing its server entrypoint."); } return runtimeServerPath; } @@ -315,9 +294,8 @@ function parseVersionCore(version: string): ParsedVersion | null { // Returns true only when `candidate` is strictly newer than `current`. Unknown // or unparseable versions return false so we never act on a version we can't // reason about (and never downgrade). Prerelease identifiers beyond the -// "release beats same-core prerelease" rule are intentionally not ordered. Only -// the dashboard restart check below needs this; the CLI re-exec just always runs -// `@latest`. Exported for unit testing. +// "release beats same-core prerelease" rule are intentionally not ordered. Used +// by the dashboard restart check below. Exported for unit testing. export function isVersionNewer(candidate: string, current: string): boolean { const a = parseVersionCore(candidate); const b = parseVersionCore(current); @@ -331,12 +309,12 @@ export function isVersionNewer(candidate: string, current: string): boolean { return !a.hasPrerelease && b.hasPrerelease; } -// Restart the running dashboard only when ours is strictly newer; this is how a -// re-exec'd `npx @latest` rolls out a fresh dashboard without a reinstall. -// Equal/older/unknown versions (e.g. a dashboard recorded by a pre-feature CLI -// with no version field) are reused as-is. Exported for unit testing. -export function shouldRestartDashboard(currentVersion: string | undefined, runningVersion: string | undefined): boolean { - return currentVersion != null && runningVersion != null && isVersionNewer(currentVersion, runningVersion); +// Restart the running dashboard only when the latest published release is +// strictly newer than the one serving the port. Equal/older/unknown versions (a +// pre-feature CLI's record, or an unreachable manifest) are reused as-is. +// Exported for unit testing. +export function shouldRestartDashboard(latestVersion: string | undefined, runningVersion: string | undefined): boolean { + return latestVersion != null && runningVersion != null && isVersionNewer(latestVersion, runningVersion); } // Whether `pid` refers to a live process. EPERM means it exists but is owned by @@ -369,6 +347,7 @@ function startDashboardProcess(options: { dashboardEnv: NodeJS.ProcessEnv, logFd: number, port: number, + dashboardRoot?: string, }): ChildProcess { const devDashboardCommand = devDashboardCommandFromEnv(process.env); if (devDashboardCommand != null) { @@ -382,7 +361,10 @@ function startDashboardProcess(options: { }); } - const dashboardServerPath = prepareDashboardRuntime(options.dashboardEnv, options.port); + if (options.dashboardRoot == null) { + throw new CliError("Internal error: the Hexclave dashboard build was not resolved before starting."); + } + const dashboardServerPath = prepareDashboardRuntime(options.dashboardEnv, options.port, options.dashboardRoot); return spawn(process.execPath, [dashboardServerPath], { cwd: resolve(dirname(dashboardServerPath), "../.."), detached: true, @@ -438,12 +420,33 @@ export async function killLocalDashboard(url: string, port: number): Promise { const url = dashboardUrl(options.port); + const devDashboardCommand = devDashboardCommandFromEnv(process.env); + + // Look up the newest published release to decide whether to restart a running + // dashboard and which build to launch. Skipped for a custom dev command or a + // local-build override; a null manifest (offline) reuses the running dashboard + // or falls back to cache. + const dashboardOverride = dashboardDirOverride(); + const skipReleaseLookup = devDashboardCommand != null || dashboardOverride != null; + const manifest: DashboardManifest | null = skipReleaseLookup ? null : await fetchDashboardManifest(); + const latestVersion = manifest?.version; + if (await isDashboardReachable(url, options.secret)) { - const currentVersion = cliVersion(); const runningDashboard = readDevEnvState().localDashboardsByPort?.[String(options.port)]; const runningVersion = runningDashboard?.version; - if (shouldRestartDashboard(currentVersion, runningVersion)) { - logDev(`Existing Hexclave dashboard is ${runningVersion}; restarting with ${currentVersion}...`); + if (devDashboardCommand != null && runningVersion != null) { + // A custom dev command should take over a release/override dashboard left + // running from a prior run. A custom-command dashboard records no version, + // so `runningVersion != null` avoids needlessly restarting that one. + logDev("A custom Hexclave dashboard command is configured; restarting the running dashboard..."); + await killLocalDashboard(url, options.port); + } else if (dashboardOverride != null && runningVersion !== "local") { + // A local-build override should win over a release dashboard left running + // from a prior run; the override always resolves to version "local". + logDev("A local Hexclave dashboard override is configured; restarting the running dashboard..."); + await killLocalDashboard(url, options.port); + } else if (shouldRestartDashboard(latestVersion, runningVersion)) { + logDev(`A newer Hexclave dashboard (${latestVersion}) is available; restarting from ${runningVersion}...`); await killLocalDashboard(url, options.port); } else { logDev(`Using existing Hexclave dashboard on ${url}.`); @@ -454,8 +457,11 @@ async function startDashboardIfNeeded(options: { apiBaseUrl: string, secret: str } } + // Download (or reuse a cached copy of) the dashboard build to launch. Not + // needed when a custom dev dashboard command runs the dashboard itself. + const release = devDashboardCommand == null ? await resolveDashboardRuntime({ manifest }) : null; + const progress = startProgressLog(`Hexclave dashboard not found on port ${options.port}. Starting now`); - const devDashboardCommand = devDashboardCommandFromEnv(process.env); const dashboardEnv = { ...process.env, NODE_ENV: devDashboardCommand == null ? "production" : "development", @@ -511,7 +517,7 @@ async function startDashboardIfNeeded(options: { apiBaseUrl: string, secret: str try { const child = (() => { try { - return startDashboardProcess({ dashboardEnv, logFd, port: options.port }); + return startDashboardProcess({ dashboardEnv, logFd, port: options.port, dashboardRoot: release?.root }); } finally { closeSync(logFd); } @@ -519,7 +525,7 @@ async function startDashboardIfNeeded(options: { apiBaseUrl: string, secret: str if (child.pid == null) { throw new CliError(`Failed to start the development environment dashboard process. Dashboard logs: ${logPath}`); } - recordLocalDashboardProcess(options.port, options.secret, child.pid, logPath, cliVersion()); + recordLocalDashboardProcess(options.port, options.secret, child.pid, logPath, release?.version); logDev(`Dashboard logs: ${logPath}`); child.unref(); } finally { @@ -738,18 +744,14 @@ child.on("error", (error) => { `; function runChildProcess(command: ChildCommand, env: NodeJS.ProcessEnv): Promise { - // Scrub the internal re-exec handshake marker so it never leaks into the user's - // command. Done here (not in the wrapper script) to cover both spawn paths. - const childEnv = { ...env }; - delete childEnv[REEXEC_MARKER_ENV]; return new Promise((resolvePromise, reject) => { const child = process.platform === "win32" - ? spawn(command.command, command.args, { stdio: "inherit", env: childEnv }) + ? spawn(command.command, command.args, { stdio: "inherit", env }) : spawn(process.execPath, ["-e", APP_COMMAND_WRAPPER_SCRIPT], { detached: true, stdio: "inherit", env: { - ...childEnv, + ...env, [APP_COMMAND_WRAPPER_PARENT_PID_ENV_VAR]: String(process.pid), [APP_COMMAND_WRAPPER_COMMAND_ENV_VAR]: command.command, [APP_COMMAND_WRAPPER_ARGS_ENV_VAR]: JSON.stringify(command.args), @@ -903,22 +905,12 @@ export function registerDevCommand(program: Command) { .usage("--config-file -- [args...]") .description("Run a command with Hexclave development-environment credentials") .requiredOption("--config-file ", "Path to stack.config.ts") - .option("--no-auto-update", "Don't re-run the latest published CLI via npx before starting") .argument("", "Command and arguments to run after --") .action(async (commandArgs: string[], opts: DevOptions) => { if (opts.configFile == null) { throw new CliError("--config-file is required."); } - // Before doing any work, re-exec through `npx @latest` when a newer - // CLI is published so users get the latest dashboard without reinstalling. - // No-ops (and returns) when already latest, offline, in CI, or opted out. - if (opts.autoUpdate !== false) { - await maybeReexecToLatest({ - forwardArgs: ["dev", "--config-file", opts.configFile, "--", ...commandArgs], - }); - } - const childCommand = splitDevCommandArgs(commandArgs); const port = dashboardPort(); const localDashboardUrl = dashboardUrl(port); diff --git a/packages/cli/src/lib/dashboard-release.test.ts b/packages/cli/src/lib/dashboard-release.test.ts new file mode 100644 index 000000000..e866f131d --- /dev/null +++ b/packages/cli/src/lib/dashboard-release.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; +import { + DASHBOARD_DIR_OVERRIDE_ENV_VAR, + DASHBOARD_MANIFEST_URL_ENV_VAR, + dashboardDirOverride, + dashboardManifestUrl, + parseDashboardManifest, + pickLatestVersion, +} from "./dashboard-release.js"; + +const VALID_SHA = "a".repeat(64); + +describe("parseDashboardManifest", () => { + it("accepts a well-formed manifest and lowercases the digest", () => { + expect(parseDashboardManifest({ + version: "1.2.3", + sha256: VALID_SHA.toUpperCase(), + url: "https://example.com/dashboard-1.2.3.zip", + })).toEqual({ + version: "1.2.3", + sha256: VALID_SHA, + url: "https://example.com/dashboard-1.2.3.zip", + }); + }); + + it("rejects missing or empty fields", () => { + expect(parseDashboardManifest(null)).toBeNull(); + expect(parseDashboardManifest("nope")).toBeNull(); + expect(parseDashboardManifest({ sha256: VALID_SHA, url: "https://x/y.zip" })).toBeNull(); + expect(parseDashboardManifest({ version: "", sha256: VALID_SHA, url: "https://x/y.zip" })).toBeNull(); + expect(parseDashboardManifest({ version: "1.2.3", sha256: VALID_SHA, url: "" })).toBeNull(); + }); + + it("rejects a non-hex, wrong-length, or padded sha256", () => { + expect(parseDashboardManifest({ version: "1.2.3", sha256: "abc", url: "https://x/y.zip" })).toBeNull(); + expect(parseDashboardManifest({ version: "1.2.3", sha256: "z".repeat(64), url: "https://x/y.zip" })).toBeNull(); + expect(parseDashboardManifest({ version: "1.2.3", sha256: "a".repeat(63), url: "https://x/y.zip" })).toBeNull(); + expect(parseDashboardManifest({ version: "1.2.3", sha256: `${VALID_SHA} `, url: "https://x/y.zip" })).toBeNull(); + }); + + it("accepts a v-prefix and a prerelease/build version", () => { + expect(parseDashboardManifest({ version: "v1.2.3", sha256: VALID_SHA, url: "https://x/y.zip" })?.version).toBe("v1.2.3"); + expect(parseDashboardManifest({ version: "1.2.3-beta.1", sha256: VALID_SHA, url: "https://x/y.zip" })?.version).toBe("1.2.3-beta.1"); + }); + + it("requires https for the url, allowing http only for loopback", () => { + const ok = (url: string) => parseDashboardManifest({ version: "1.2.3", sha256: VALID_SHA, url })?.url; + expect(ok("https://example.com/d.zip")).toBe("https://example.com/d.zip"); + expect(ok("http://127.0.0.1:8000/d.zip")).toBe("http://127.0.0.1:8000/d.zip"); + expect(ok("http://localhost:8000/d.zip")).toBe("http://localhost:8000/d.zip"); + expect(ok("http://example.com/d.zip")).toBeUndefined(); + expect(ok("ftp://example.com/d.zip")).toBeUndefined(); + expect(ok("file:///etc/passwd")).toBeUndefined(); + expect(ok("not a url")).toBeUndefined(); + }); + + it("rejects a non-string or path-unsafe version", () => { + expect(parseDashboardManifest({ version: 123, sha256: VALID_SHA, url: "https://x/y.zip" })).toBeNull(); + expect(parseDashboardManifest({ version: "../../etc", sha256: VALID_SHA, url: "https://x/y.zip" })).toBeNull(); + expect(parseDashboardManifest({ version: "1.2", sha256: VALID_SHA, url: "https://x/y.zip" })).toBeNull(); + expect(parseDashboardManifest({ version: "latest", sha256: VALID_SHA, url: "https://x/y.zip" })).toBeNull(); + expect(parseDashboardManifest({ version: "1.2.3/../x", sha256: VALID_SHA, url: "https://x/y.zip" })).toBeNull(); + }); +}); + +describe("dashboardDirOverride", () => { + it("returns the trimmed override when set", () => { + expect(dashboardDirOverride({ [DASHBOARD_DIR_OVERRIDE_ENV_VAR]: " /tmp/dash " })).toBe("/tmp/dash"); + }); + + it("returns undefined when unset or blank", () => { + expect(dashboardDirOverride({})).toBeUndefined(); + expect(dashboardDirOverride({ [DASHBOARD_DIR_OVERRIDE_ENV_VAR]: " " })).toBeUndefined(); + }); +}); + +describe("dashboardManifestUrl", () => { + it("defaults to the dashboard-latest release manifest", () => { + expect(dashboardManifestUrl({})).toBe( + "https://github.com/hexclave/hexclave/releases/download/dashboard-latest/manifest.json", + ); + }); + + it("honors the override env var, trimming whitespace", () => { + expect(dashboardManifestUrl({ [DASHBOARD_MANIFEST_URL_ENV_VAR]: " https://mirror/manifest.json " })) + .toBe("https://mirror/manifest.json"); + }); + + it("falls back to the default for a blank override", () => { + expect(dashboardManifestUrl({ [DASHBOARD_MANIFEST_URL_ENV_VAR]: " " })) + .toBe("https://github.com/hexclave/hexclave/releases/download/dashboard-latest/manifest.json"); + }); +}); + +describe("pickLatestVersion", () => { + it("returns the highest semver", () => { + expect(pickLatestVersion(["1.0.37", "1.0.9", "1.1.0", "0.9.99"])).toBe("1.1.0"); + }); + + it("ignores unparseable entries", () => { + expect(pickLatestVersion([".extract-tmp", "1.0.5", "garbage", "1.0.10"])).toBe("1.0.10"); + }); + + it("prefers a final release over a same-core prerelease regardless of order", () => { + expect(pickLatestVersion(["1.2.3-beta.1", "1.2.3"])).toBe("1.2.3"); + expect(pickLatestVersion(["1.2.3", "1.2.3-beta.1"])).toBe("1.2.3"); + // `+build` metadata is not a prerelease, so it still outranks a same-core prerelease. + expect(pickLatestVersion(["1.2.3-rc.1", "1.2.3+build"])).toBe("1.2.3+build"); + expect(pickLatestVersion(["1.2.3+build", "1.2.3-rc.1"])).toBe("1.2.3+build"); + }); + + it("returns undefined when nothing parses", () => { + expect(pickLatestVersion([])).toBeUndefined(); + expect(pickLatestVersion([".tmp", "latest"])).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/lib/dashboard-release.ts b/packages/cli/src/lib/dashboard-release.ts new file mode 100644 index 000000000..7573a167e --- /dev/null +++ b/packages/cli/src/lib/dashboard-release.ts @@ -0,0 +1,265 @@ +import { createHash, randomBytes } from "crypto"; +import { createReadStream, createWriteStream, existsSync, mkdirSync, readdirSync, renameSync, rmSync, writeFileSync } from "fs"; +import { dirname, join } from "path"; +import { Readable } from "stream"; +import { pipeline } from "stream/promises"; +import extractZip from "extract-zip"; +import { devEnvStatePath } from "./dev-env-state.js"; +import { CliError, errorMessage } from "./errors.js"; + +// The RDE dashboard ships as a zipped standalone build attached to a GitHub +// Release rather than bundled in the CLI tarball; `hexclave dev` fetches the +// newest one at runtime and caches it. Publishing side: dashboard-release.yaml. + +const DASHBOARD_REPO = "hexclave/hexclave"; +// Floating manifest pointing at the newest build — a stable download URL (no API +// call, so no rate limit). +const DASHBOARD_LATEST_MANIFEST_URL = `https://github.com/${DASHBOARD_REPO}/releases/download/dashboard-latest/manifest.json`; + +// Point the CLI at a different manifest (mirror/staging/tests). +export const DASHBOARD_MANIFEST_URL_ENV_VAR = "HEXCLAVE_DASHBOARD_MANIFEST_URL"; +// Run a local on-disk build, skipping all networking. +export const DASHBOARD_DIR_OVERRIDE_ENV_VAR = "HEXCLAVE_DASHBOARD_DIR"; + +export const DASHBOARD_SERVER_RELATIVE_PATH = join("apps", "dashboard", "server.js"); + +const DASHBOARD_CACHE_DIR_NAME = "dashboards"; +// Written only after extraction completes, so a half-extracted dir is never used. +const DASHBOARD_COMPLETE_MARKER = ".hexclave-complete"; +const LOG_PREFIX = "[Hexclave] "; +// `version` becomes a cache dir name and the manifest is untrusted, so require a +// path-safe semver. +const SAFE_VERSION_REGEX = /^v?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)*$/; +// Don't hang forever on a slow host; a timeout falls through to the offline cache. +const MANIFEST_FETCH_TIMEOUT_MS = 10_000; +const DASHBOARD_DOWNLOAD_TIMEOUT_MS = 5 * 60_000; + +// Require https for the download (loopback http allowed for local mirrors/tests); +// also rejects non-http(s) schemes. +function isAllowedDownloadUrl(url: string): boolean { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return false; + } + if (parsed.protocol === "https:") return true; + if (parsed.protocol === "http:") { + return parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "[::1]" || parsed.hostname === "::1"; + } + return false; +} + +export type DashboardManifest = { + version: string, + sha256: string, + url: string, +}; + +export type ResolvedDashboard = { + root: string, + version: string, +}; + +function logDashboard(message: string): void { + console.warn(`${LOG_PREFIX}${message}`); +} + +export function parseDashboardManifest(raw: unknown): DashboardManifest | null { + if (raw == null || typeof raw !== "object") return null; + const manifest = raw as Record; + if (typeof manifest.version !== "string" || !SAFE_VERSION_REGEX.test(manifest.version)) return null; + if (typeof manifest.sha256 !== "string" || !/^[0-9a-f]{64}$/i.test(manifest.sha256)) return null; + if (typeof manifest.url !== "string" || !isAllowedDownloadUrl(manifest.url)) return null; + return { version: manifest.version, sha256: manifest.sha256.toLowerCase(), url: manifest.url }; +} + +export function dashboardDirOverride(env: NodeJS.ProcessEnv = process.env): string | undefined { + const override = env[DASHBOARD_DIR_OVERRIDE_ENV_VAR]?.trim(); + return override != null && override.length > 0 ? override : undefined; +} + +export function dashboardManifestUrl(env: NodeJS.ProcessEnv = process.env): string { + const override = env[DASHBOARD_MANIFEST_URL_ENV_VAR]?.trim(); + return override != null && override.length > 0 ? override : DASHBOARD_LATEST_MANIFEST_URL; +} + +export function dashboardCacheRoot(): string { + return join(dirname(devEnvStatePath()), DASHBOARD_CACHE_DIR_NAME); +} + +export function dashboardVersionDir(version: string): string { + return join(dashboardCacheRoot(), version); +} + +export function isDashboardCached(version: string): boolean { + const dir = dashboardVersionDir(version); + return existsSync(join(dir, DASHBOARD_COMPLETE_MARKER)) && existsSync(join(dir, DASHBOARD_SERVER_RELATIVE_PATH)); +} + +type ParsedVersion = { + core: [number, number, number], + // A `-suffix` after the core marks a prerelease (1.2.3-rc.1); `+build` + // metadata does not. A final release outranks a prerelease of the same core. + hasPrerelease: boolean, +}; + +// Uses the same "final release beats a same-core prerelease" rule as dev.ts's +// isVersionNewer, but kept separate: that one takes raw version strings for the +// restart check, while this ranks already-parsed cached dir names. Neither +// orders two distinct same-core prereleases against each other. +function parseVersion(version: string): ParsedVersion | null { + const match = /^v?(\d+)\.(\d+)\.(\d+)(.*)$/.exec(version.trim()); + if (!match) return null; + return { core: [Number(match[1]), Number(match[2]), Number(match[3])], hasPrerelease: match[4].startsWith("-") }; +} + +export function pickLatestVersion(versions: string[]): string | undefined { + let best: { version: string, parsed: ParsedVersion } | undefined; + for (const version of versions) { + const parsed = parseVersion(version); + if (parsed == null) continue; + if (best == null || isVersionNewer(parsed, best.parsed)) { + best = { version, parsed }; + } + } + return best?.version; +} + +function isVersionNewer(candidate: ParsedVersion, current: ParsedVersion): boolean { + for (let i = 0; i < 3; i++) { + if (candidate.core[i] !== current.core[i]) return candidate.core[i] > current.core[i]; + } + // Same core: prefer the final release over a prerelease so the offline pick is + // deterministic regardless of directory order (1.2.3 beats 1.2.3-rc.1). + if (candidate.hasPrerelease !== current.hasPrerelease) return !candidate.hasPrerelease; + return false; +} + +export function latestCachedDashboardVersion(): string | undefined { + const root = dashboardCacheRoot(); + if (!existsSync(root)) return undefined; + const cached = readdirSync(root, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && isDashboardCached(entry.name)) + .map((entry) => entry.name); + return pickLatestVersion(cached); +} + +export async function fetchDashboardManifest(env: NodeJS.ProcessEnv = process.env): Promise { + const url = dashboardManifestUrl(env); + try { + const response = await fetch(url, { headers: { Accept: "application/json" }, redirect: "follow", signal: AbortSignal.timeout(MANIFEST_FETCH_TIMEOUT_MS) }); + if (!response.ok) { + logDashboard(`Could not fetch dashboard manifest (HTTP ${response.status}) from ${url}.`); + return null; + } + return parseDashboardManifest(await response.json()); + } catch (error) { + logDashboard(`Could not fetch dashboard manifest from ${url}: ${errorMessage(error)}`); + return null; + } +} + +async function sha256File(path: string): Promise { + const hash = createHash("sha256"); + await pipeline(createReadStream(path), hash); + return hash.digest("hex"); +} + +async function downloadDashboardRelease(manifest: DashboardManifest): Promise { + const cacheRoot = dashboardCacheRoot(); + mkdirSync(cacheRoot, { recursive: true }); + // Unique temp names so parallel runs don't collide; publish is an atomic rename. + const suffix = `${process.pid}-${randomBytes(8).toString("hex")}`; + const tmpZip = join(cacheRoot, `.download-${manifest.version}-${suffix}.zip`); + const tmpDir = join(cacheRoot, `.extract-${manifest.version}-${suffix}`); + const targetDir = dashboardVersionDir(manifest.version); + try { + const response = await fetch(manifest.url, { redirect: "follow", signal: AbortSignal.timeout(DASHBOARD_DOWNLOAD_TIMEOUT_MS) }); + // The manifest URL passed isAllowedDownloadUrl, but redirects can land on a + // different host/scheme; re-check the final URL before streaming the archive. + if (!isAllowedDownloadUrl(response.url)) { + throw new CliError(`Dashboard ${manifest.version} download was redirected to a disallowed URL (${response.url}).`); + } + if (!response.ok || response.body == null) { + throw new CliError(`Failed to download dashboard ${manifest.version} (HTTP ${response.status}) from ${manifest.url}.`); + } + await pipeline(Readable.fromWeb(response.body as Parameters[0]), createWriteStream(tmpZip)); + + const digest = await sha256File(tmpZip); + if (digest !== manifest.sha256) { + throw new CliError(`Dashboard ${manifest.version} failed its integrity check (expected ${manifest.sha256}, got ${digest}).`); + } + + rmSync(tmpDir, { recursive: true, force: true }); + mkdirSync(tmpDir, { recursive: true }); + await extractZip(tmpZip, { dir: tmpDir }); + if (!existsSync(join(tmpDir, DASHBOARD_SERVER_RELATIVE_PATH))) { + throw new CliError(`Dashboard ${manifest.version} archive is missing its server entrypoint.`); + } + writeFileSync(join(tmpDir, DASHBOARD_COMPLETE_MARKER), `${manifest.sha256}\n`); + + // Publish atomically, never rmSync-ing a *valid* targetDir — a concurrent + // `hexclave dev` may be reading it. The marker is written before the rename, + // so any fully-published dir passes isDashboardCached. + if (isDashboardCached(manifest.version)) { + return; + } + try { + renameSync(tmpDir, targetDir); + } catch { + if (isDashboardCached(manifest.version)) { + return; + } + // targetDir exists but isn't valid — an interrupted publish left a partial + // dir (never the live concurrent-publisher case, handled above). No reader + // uses a marker-less entry, so replacing it is safe. + rmSync(targetDir, { recursive: true, force: true }); + renameSync(tmpDir, targetDir); + } + } finally { + rmSync(tmpZip, { force: true }); + rmSync(tmpDir, { recursive: true, force: true }); + } +} + +// Resolve the build to launch: on-disk override → manifest version (downloaded if +// not cached) → newest cached (offline). Throws only when nothing is usable. +export async function resolveDashboardRuntime(opts: { manifest?: DashboardManifest | null } = {}): Promise { + const override = dashboardDirOverride(); + if (override != null) { + if (!existsSync(join(override, DASHBOARD_SERVER_RELATIVE_PATH))) { + throw new CliError(`${DASHBOARD_DIR_OVERRIDE_ENV_VAR} is set to ${override}, but no dashboard server was found there.`); + } + return { root: override, version: "local" }; + } + + const manifest = opts.manifest !== undefined ? opts.manifest : await fetchDashboardManifest(); + if (manifest != null) { + if (isDashboardCached(manifest.version)) { + return { root: dashboardVersionDir(manifest.version), version: manifest.version }; + } + try { + await downloadDashboardRelease(manifest); + return { root: dashboardVersionDir(manifest.version), version: manifest.version }; + } catch (error) { + const cached = latestCachedDashboardVersion(); + if (cached != null) { + logDashboard(`Failed to download dashboard ${manifest.version} (${errorMessage(error)}); using cached ${cached}.`); + return { root: dashboardVersionDir(cached), version: cached }; + } + throw error; + } + } + + const cached = latestCachedDashboardVersion(); + if (cached != null) { + logDashboard(`Offline: using cached Hexclave dashboard ${cached}.`); + return { root: dashboardVersionDir(cached), version: cached }; + } + + throw new CliError([ + "Could not download the Hexclave development-environment dashboard and no cached copy is available.", + `Check your network connection, or set ${DASHBOARD_DIR_OVERRIDE_ENV_VAR} to a local dashboard build.`, + ].join(" ")); +} diff --git a/packages/cli/src/lib/errors.ts b/packages/cli/src/lib/errors.ts index 973b1ceca..afc20272c 100644 --- a/packages/cli/src/lib/errors.ts +++ b/packages/cli/src/lib/errors.ts @@ -11,3 +11,7 @@ export class AuthError extends CliError { this.name = "AuthError"; } } + +export function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/cli/src/lib/own-package.test.ts b/packages/cli/src/lib/own-package.test.ts index 0f96c1be0..cfff0d1cc 100644 --- a/packages/cli/src/lib/own-package.test.ts +++ b/packages/cli/src/lib/own-package.test.ts @@ -1,32 +1,11 @@ import { describe, expect, it } from "vitest"; -import { parseOwnPackage, resolveBinName } from "./own-package.js"; - -describe("resolveBinName", () => { - it("prefers the `hexclave` bin when present (canonical bin across versions)", () => { - expect(resolveBinName({ stack: "./d.js", hexclave: "./d.js" }, "@hexclave/cli")).toBe("hexclave"); - }); - - it("falls back to the first bin key when there is no `hexclave`", () => { - expect(resolveBinName({ stack: "./d.js" }, "@hexclave/cli")).toBe("stack"); - }); - - it("derives the bin from the unscoped package name when bin is absent", () => { - expect(resolveBinName(undefined, "@hexclave/cli")).toBe("cli"); - expect(resolveBinName(undefined, "hexclave")).toBe("hexclave"); - }); - - it("ignores a string `bin` and uses the unscoped package name", () => { - // npm convention: a string bin's name is the (unscoped) package name. - expect(resolveBinName("./dist/index.js", "@hexclave/cli")).toBe("cli"); - }); -}); +import { parseOwnPackage } from "./own-package.js"; describe("parseOwnPackage", () => { - it("parses name, version, and resolves the bin name", () => { - expect(parseOwnPackage({ name: "@hexclave/cli", version: "1.2.3", bin: { stack: "./d.js" } })).toEqual({ + it("parses name and version", () => { + expect(parseOwnPackage({ name: "@hexclave/cli", version: "1.2.3" })).toEqual({ name: "@hexclave/cli", version: "1.2.3", - binName: "stack", }); }); diff --git a/packages/cli/src/lib/own-package.ts b/packages/cli/src/lib/own-package.ts index 0e9b38fff..5a0591085 100644 --- a/packages/cli/src/lib/own-package.ts +++ b/packages/cli/src/lib/own-package.ts @@ -5,35 +5,16 @@ import { fileURLToPath } from "url"; export type OwnPackage = { name: string, version: string, - binName: string, }; -function unscopedName(packageName: string): string { - return packageName.includes("/") ? packageName.split("/")[1] : packageName; -} - -// The bin name used to re-invoke this CLI via npx. Prefer the `hexclave` bin: -// it is the canonical bin and is guaranteed to exist across published versions, -// so it's safe to invoke against `@latest`. A string `bin` (or none) maps to the -// unscoped package name, per npm convention. -export function resolveBinName(bin: unknown, packageName: string): string { - if (bin != null && typeof bin === "object") { - const keys = Object.keys(bin as Record); - if (keys.includes("hexclave")) return "hexclave"; - if (keys.length > 0) return keys[0]; - } - return unscopedName(packageName); -} - // Pure parser, separated from disk I/O so it can be unit-tested directly. export function parseOwnPackage(raw: unknown): OwnPackage | null { if (raw == null || typeof raw !== "object") return null; - const pkg = raw as { name?: unknown, version?: unknown, bin?: unknown }; + const pkg = raw as { name?: unknown, version?: unknown }; if (typeof pkg.name !== "string" || typeof pkg.version !== "string") return null; return { name: pkg.name, version: pkg.version, - binName: resolveBinName(pkg.bin, pkg.name), }; } diff --git a/packages/cli/src/lib/self-update.test.ts b/packages/cli/src/lib/self-update.test.ts deleted file mode 100644 index 84394a6fc..000000000 --- a/packages/cli/src/lib/self-update.test.ts +++ /dev/null @@ -1,354 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs"; -import { tmpdir } from "os"; -import { join } from "path"; -import { EventEmitter } from "events"; -import * as childProcess from "child_process"; -import * as ownPackage from "./own-package.js"; - -// `spawn` is a non-configurable built-in export, so it can't be vi.spyOn'd; -// replace it with a vi.fn. Everything else in child_process stays real. -vi.mock("child_process", async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, spawn: vi.fn() }; -}); - -import { - buildNpxInvocation, - decidePostReexec, - decideReexec, - DISABLE_AUTO_UPDATE_ENV, - isEnvFlagEnabled, - maybeReexecToLatest, - REEXEC_MARKER_ENV, - shouldAutoUpdate, - signalReexecStartedIfChild, - SKIP_AUTO_UPDATE_ENV, -} from "./self-update.js"; -import type { OwnPackage } from "./own-package.js"; - -describe("isEnvFlagEnabled", () => { - it("treats absent / empty / 0 / false as disabled", () => { - expect(isEnvFlagEnabled(undefined)).toBe(false); - expect(isEnvFlagEnabled("")).toBe(false); - expect(isEnvFlagEnabled(" ")).toBe(false); - expect(isEnvFlagEnabled("0")).toBe(false); - expect(isEnvFlagEnabled("false")).toBe(false); - expect(isEnvFlagEnabled("FALSE")).toBe(false); - }); - - it("treats other values as enabled", () => { - expect(isEnvFlagEnabled("1")).toBe(true); - expect(isEnvFlagEnabled("true")).toBe(true); - expect(isEnvFlagEnabled("yes")).toBe(true); - }); -}); - -describe("shouldAutoUpdate", () => { - it("returns true for an empty environment", () => { - expect(shouldAutoUpdate({})).toBe(true); - }); - - it("is disabled for the re-exec'd child", () => { - expect(shouldAutoUpdate({ [SKIP_AUTO_UPDATE_ENV]: "1" })).toBe(false); - }); - - it("is disabled when the user opts out", () => { - expect(shouldAutoUpdate({ [DISABLE_AUTO_UPDATE_ENV]: "1" })).toBe(false); - }); - - it("still auto-updates in CI so it matches what developers run locally", () => { - expect(shouldAutoUpdate({ CI: "true" })).toBe(true); - expect(shouldAutoUpdate({ CI: "1" })).toBe(true); - }); - - it("does not skip when an opt-out flag is a falsy string", () => { - expect(shouldAutoUpdate({ [SKIP_AUTO_UPDATE_ENV]: "0" })).toBe(true); - expect(shouldAutoUpdate({ [DISABLE_AUTO_UPDATE_ENV]: "false" })).toBe(true); - }); -}); - -describe("buildNpxInvocation", () => { - it("pins @latest and forwards the subcommand through the bin", () => { - const { command, args } = buildNpxInvocation({ - packageName: "@hexclave/cli", - binName: "stack", - forwardArgs: ["dev", "--config-file", "./stack.config.ts", "--", "npm", "run", "dev:app"], - }); - expect(command).toMatch(/^npx(\.cmd)?$/); - expect(args).toEqual([ - "--yes", - "--min-release-age=0", - "-p", - "@hexclave/cli@latest", - "stack", - "dev", - "--config-file", - "./stack.config.ts", - "--", - "npm", - "run", - "dev:app", - ]); - }); - - it("overrides any global npm cooldown so a just-published version is fetched", () => { - const { args } = buildNpxInvocation({ - packageName: "@hexclave/cli", - binName: "stack", - forwardArgs: [], - }); - // npm's `min-release-age` (>=11.10.0) would otherwise block the latest. - expect(args).toContain("--min-release-age=0"); - }); - - it("preserves args that start with dashes or contain spaces as individual argv elements", () => { - const { args } = buildNpxInvocation({ - packageName: "@hexclave/cli", - binName: "stack", - forwardArgs: ["dev", "--flag=a b", "--", "echo", "hello world"], - }); - expect(args).toEqual([ - "--yes", "--min-release-age=0", "-p", "@hexclave/cli@latest", "stack", - "dev", "--flag=a b", "--", "echo", "hello world", - ]); - }); - - it("uses npx.cmd and requests a shell on Windows (needed to spawn a .cmd post-CVE-2024-27980)", () => { - const spy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); - try { - const invocation = buildNpxInvocation({ - packageName: "@hexclave/cli", binName: "stack", forwardArgs: [], - }); - expect(invocation.command).toBe("npx.cmd"); - expect(invocation.shell).toBe(true); - } finally { - spy.mockRestore(); - } - }); - - it("spawns npx directly without a shell off Windows", () => { - const spy = vi.spyOn(process, "platform", "get").mockReturnValue("linux"); - try { - const invocation = buildNpxInvocation({ - packageName: "@hexclave/cli", binName: "stack", forwardArgs: [], - }); - expect(invocation.command).toBe("npx"); - expect(invocation.shell).toBe(false); - } finally { - spy.mockRestore(); - } - }); -}); - -describe("decideReexec", () => { - const pkg: OwnPackage = { name: "@hexclave/cli", version: "2.8.109", binName: "stack" }; - - it("does not re-exec when auto-update is disabled", () => { - expect(decideReexec({ env: { [SKIP_AUTO_UPDATE_ENV]: "1" }, pkg, forwardArgs: [] })) - .toEqual({ reexec: false, reason: "disabled" }); - }); - - it("does not re-exec when own package is unresolvable", () => { - expect(decideReexec({ env: {}, pkg: null, forwardArgs: [] })) - .toEqual({ reexec: false, reason: "no-package" }); - }); - - it("re-execs through a pinned `npx @latest` invocation when eligible", () => { - const decision = decideReexec({ - env: {}, - pkg, - forwardArgs: ["dev", "--config-file", "x"], - }); - expect(decision.reexec).toBe(true); - if (decision.reexec) { - expect(decision.invocation.args).toEqual([ - "--yes", "--min-release-age=0", "-p", "@hexclave/cli@latest", "stack", "dev", "--config-file", "x", - ]); - } - }); -}); - -describe("maybeReexecToLatest", () => { - const optOutKeys = [SKIP_AUTO_UPDATE_ENV, DISABLE_AUTO_UPDATE_ENV]; - const savedEnv: Record = {}; - - beforeEach(() => { - for (const key of optOutKeys) { - savedEnv[key] = process.env[key]; - delete process.env[key]; - } - }); - - afterEach(() => { - for (const key of optOutKeys) { - if (savedEnv[key] == null) delete process.env[key]; - else process.env[key] = savedEnv[key]; - } - vi.restoreAllMocks(); - }); - - it("returns without re-exec (never spawning npx) when auto-update is opted out", async () => { - // With the opt-out set, the disabled short-circuit fires before any spawn, - // so the installed CLI keeps running. Resolving here without throwing or - // hanging proves we did not re-exec into `npx @latest`. - process.env[DISABLE_AUTO_UPDATE_ENV] = "1"; - await expect(maybeReexecToLatest({ forwardArgs: ["dev"] })).resolves.toBeUndefined(); - }); -}); - -describe("decidePostReexec", () => { - it("propagates the exit code when the CLI ran to completion (code 0)", () => { - expect(decidePostReexec({ result: { exited: true, code: 0, signal: null }, started: true })) - .toEqual({ kind: "exit", code: 0 }); - }); - - it("propagates a nonzero exit code when the CLI actually started (real command failure)", () => { - // The re-exec'd CLI ran (marker present) and the wrapped command failed — we - // must surface that failure, not silently re-run it. - expect(decidePostReexec({ result: { exited: true, code: 1, signal: null }, started: true })) - .toEqual({ kind: "exit", code: 1 }); - }); - - it("falls back when npx exits nonzero before the CLI starts (e.g. Lock compromised)", () => { - // npm errored during install/lock; our CLI never ran. Don't take down dev — - // run the installed CLI instead. - const action = decidePostReexec({ result: { exited: true, code: 1, signal: null }, started: false }); - expect(action.kind).toBe("fallback"); - }); - - it("propagates (does not fall back) when npx was killed by a signal before the CLI started", () => { - // e.g. the user pressed Ctrl-C during the download. They want to abort, not - // get a fresh dev session launched on the installed CLI. - expect(decidePostReexec({ result: { exited: true, code: 130, signal: "SIGINT" }, started: false })) - .toEqual({ kind: "exit", code: 130 }); - }); - - it("falls back when npx cannot be spawned at all", () => { - const action = decidePostReexec({ result: { exited: false, error: "spawn npx ENOENT" }, started: false }); - expect(action.kind).toBe("fallback"); - }); -}); - -describe("signalReexecStartedIfChild", () => { - let dir: string; - - beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), "hexclave-reexec-test-")); - }); - - afterEach(() => { - rmSync(dir, { recursive: true, force: true }); - }); - - it("writes the marker file when the marker env var is set (we are the child)", () => { - const marker = join(dir, "started"); - signalReexecStartedIfChild({ [REEXEC_MARKER_ENV]: marker }); - expect(existsSync(marker)).toBe(true); - expect(readFileSync(marker, "utf8")).toBe("1"); - }); - - it("does nothing when no marker env var is set (normal top-level run)", () => { - const marker = join(dir, "started"); - signalReexecStartedIfChild({}); - expect(existsSync(marker)).toBe(false); - }); - - it("does not throw when the marker path is unwritable (best-effort)", () => { - const marker = join(dir, "nonexistent-subdir", "started"); - expect(() => signalReexecStartedIfChild({ [REEXEC_MARKER_ENV]: marker })).not.toThrow(); - expect(existsSync(marker)).toBe(false); - }); -}); - -// End-to-end wiring of maybeReexecToLatest (marker -> spawn -> existence check -> -// decidePostReexec). The decision functions are pure-tested above; these guard -// against the glue regressing. -describe("maybeReexecToLatest fallback wiring", () => { - const managedKeys = [SKIP_AUTO_UPDATE_ENV, DISABLE_AUTO_UPDATE_ENV, REEXEC_MARKER_ENV]; - const savedEnv: Record = {}; - - beforeEach(() => { - for (const key of managedKeys) { - savedEnv[key] = process.env[key]; - delete process.env[key]; - } - vi.spyOn(ownPackage, "getOwnPackage").mockReturnValue({ - name: "@hexclave/cli", - version: "1.0.0", - binName: "hexclave", - }); - }); - - afterEach(() => { - for (const key of managedKeys) { - if (savedEnv[key] == null) delete process.env[key]; - else process.env[key] = savedEnv[key]; - } - vi.restoreAllMocks(); - }); - - // Fake npx child. `writeMarker` simulates whether the re-exec'd CLI touched the - // marker (as signalReexecStartedIfChild would) before the process closes. - function mockNpxChild(opts: { writeMarker: boolean }): EventEmitter & { pid: number, kill: () => void } { - const child = Object.assign(new EventEmitter(), { pid: 4242, kill: () => {} }); - vi.mocked(childProcess.spawn).mockImplementation((( - _command: string, - _args: readonly string[], - spawnOpts: { env?: NodeJS.ProcessEnv }, - ) => { - if (opts.writeMarker) { - const markerFile = spawnOpts.env?.[REEXEC_MARKER_ENV]; - if (markerFile != null) writeFileSync(markerFile, "1"); - } - return child; - }) as unknown as typeof childProcess.spawn); - return child; - } - - it("falls back (never calls process.exit) when npx exits nonzero without the CLI starting", async () => { - const exitSpy = vi.spyOn(process, "exit").mockImplementation(((() => { - throw new Error("process.exit should not have been called"); - }) as never)); - const child = mockNpxChild({ writeMarker: false }); - - const promise = maybeReexecToLatest({ forwardArgs: ["dev"] }); - child.emit("close", 1, null); - - await expect(promise).resolves.toBeUndefined(); - expect(exitSpy).not.toHaveBeenCalled(); - }); - - it("propagates the exit code (calls process.exit) when the CLI started then failed", async () => { - const exitSpy = vi.spyOn(process, "exit").mockImplementation(((() => { - // Throw so we don't actually exit the test runner; the SUT's catch swallows it. - throw new Error("__exit__"); - }) as never)); - const child = mockNpxChild({ writeMarker: true }); - - const promise = maybeReexecToLatest({ forwardArgs: ["dev"] }); - child.emit("close", 1, null); - - await promise; - expect(exitSpy).toHaveBeenCalledWith(1); - }); - - it("propagates a nonzero, non-NaN code when killed by a signal missing from os.constants", async () => { - const exitSpy = vi.spyOn(process, "exit").mockImplementation(((() => { - throw new Error("__exit__"); - }) as never)); - const child = mockNpxChild({ writeMarker: false }); - - const promise = maybeReexecToLatest({ forwardArgs: ["dev"] }); - // SIGSTKFLT is absent from os.constants.signals on macOS (present on Linux). - // Either way the abort must surface as a real nonzero code, never NaN. - child.emit("close", null, "SIGSTKFLT"); - - await promise; - expect(exitSpy).toHaveBeenCalledTimes(1); - const code = exitSpy.mock.calls[0][0]; - expect(typeof code).toBe("number"); - expect(Number.isNaN(code as number)).toBe(false); - expect(code as number).toBeGreaterThan(0); - }); -}); diff --git a/packages/cli/src/lib/self-update.ts b/packages/cli/src/lib/self-update.ts deleted file mode 100644 index c1ad8fb3e..000000000 --- a/packages/cli/src/lib/self-update.ts +++ /dev/null @@ -1,256 +0,0 @@ -import { spawn } from "child_process"; -import { existsSync, mkdtempSync, rmSync, writeFileSync } from "fs"; -import { constants as osConstants, tmpdir } from "os"; -import { join } from "path"; -import { forwardSignals } from "./child-process.js"; -import { getOwnPackage, type OwnPackage } from "./own-package.js"; - -// Set on the process we re-exec via npx so the child doesn't try to update -// itself again (it already *is* the latest), preventing an infinite loop. -export const SKIP_AUTO_UPDATE_ENV = "STACK_CLI_SKIP_AUTO_UPDATE"; -// User-facing opt-out. Set to a truthy value to never auto-update. -export const DISABLE_AUTO_UPDATE_ENV = "STACK_CLI_NO_AUTO_UPDATE"; -// Marker file the re-exec'd child touches on startup; its presence lets the -// parent tell a real command failure from an npx/install failure where our CLI -// never ran. Set by the parent on the child's env. -export const REEXEC_MARKER_ENV = "HEXCLAVE_CLI_REEXEC_MARKER"; - -const LOG_PREFIX = "[Hexclave] "; - -function logUpdate(message: string): void { - console.warn(`${LOG_PREFIX}${message}`); -} - -// Treats absent / "" / "0" / "false" as disabled; anything else as enabled. -export function isEnvFlagEnabled(value: string | undefined): boolean { - if (value == null) return false; - const normalized = value.trim().toLowerCase(); - return normalized !== "" && normalized !== "0" && normalized !== "false"; -} - -// Auto-update is skipped only when we're the re-exec'd child or when the user -// explicitly opted out. We intentionally still auto-update in CI: pinning a -// different version there than developers run locally is exactly the kind of -// drift that hides "works on my machine" bugs. -export function shouldAutoUpdate(env: NodeJS.ProcessEnv): boolean { - if (isEnvFlagEnabled(env[SKIP_AUTO_UPDATE_ENV])) return false; - if (isEnvFlagEnabled(env[DISABLE_AUTO_UPDATE_ENV])) return false; - return true; -} - -export type NpxInvocation = { - command: string, - args: string[], - // Windows' launcher is `npx.cmd`; after CVE-2024-27980 Node refuses to spawn - // a .cmd/.bat directly (EINVAL) unless `shell` is set, so the re-exec has to - // go through the shell there. `args` stays a clean argv array — runReexec - // quotes it for the shell at spawn time. - shell: boolean, -}; - -export function buildNpxInvocation(opts: { - packageName: string, - binName: string, - forwardArgs: string[], -}): NpxInvocation { - const isWindows = process.platform === "win32"; - const command = isWindows ? "npx.cmd" : "npx"; - return { - command, - shell: isWindows, - args: [ - "--yes", - // Override any global npm "cooldown" for this call only — we always want - // the just-published latest, and npx of a version newer than the cooldown - // window otherwise fails with ETARGET (which would kill `hexclave dev`). - // npm's config is `min-release-age` (days, npm >=11.10.0); older npm - // silently ignores the unknown flag. - "--min-release-age=0", - "-p", - // Always pin `@latest`: npm resolves the newest published version, so we - // don't need to fetch-and-compare versions ourselves. The re-exec'd child - // carries SKIP_AUTO_UPDATE_ENV, so it runs that downloaded CLI directly - // instead of recursing. - `${opts.packageName}@latest`, - opts.binName, - ...opts.forwardArgs, - ], - }; -} - -export type ReexecDecision = - | { reexec: false, reason: "disabled" | "no-package" } - | { reexec: true, invocation: NpxInvocation }; - -// Pure decision: given the environment, our own package, and the args to -// forward, decide whether (and how) to re-exec through `npx @latest`. Kept -// free of I/O so the branching can be unit-tested directly. We re-exec unless -// auto-update is off or we can't resolve our own package name. -export function decideReexec(opts: { - env: NodeJS.ProcessEnv, - pkg: OwnPackage | null, - forwardArgs: string[], -}): ReexecDecision { - if (!shouldAutoUpdate(opts.env)) return { reexec: false, reason: "disabled" }; - if (opts.pkg == null) return { reexec: false, reason: "no-package" }; - return { - reexec: true, - invocation: buildNpxInvocation({ - packageName: opts.pkg.name, - binName: opts.pkg.binName, - forwardArgs: opts.forwardArgs, - }), - }; -} - -export type ReexecResult = - // `signal` is set when the child was killed by one (e.g. a forwarded Ctrl-C), - // distinguishing an abort from an npx failure (a bare exit code can't). - | { exited: true, code: number, signal: NodeJS.Signals | null } - | { exited: false, error: string }; - -// Quote an argument for the single cmd.exe command line that Node builds when -// `spawn` runs with `shell: true` on Windows — it joins argv with spaces and -// does not quote, so an unquoted path/arg with a space would be split. Wrap -// anything that isn't a plain token (and the empty string) in double quotes, -// escaping embedded quotes. A no-op on the non-shell (POSIX) path. -function quoteShellArg(arg: string): string { - if (arg !== "" && !/[\s"&|<>^()]/.test(arg)) return arg; - return `"${arg.replace(/"/g, '\\"')}"`; -} - -function runReexec(invocation: NpxInvocation, markerFile: string | null): Promise { - return new Promise((resolvePromise) => { - const args = invocation.shell ? invocation.args.map(quoteShellArg) : invocation.args; - const env: NodeJS.ProcessEnv = { ...process.env, [SKIP_AUTO_UPDATE_ENV]: "1" }; - if (markerFile != null) env[REEXEC_MARKER_ENV] = markerFile; - const child = spawn(invocation.command, args, { - stdio: "inherit", - env, - shell: invocation.shell, - }); - const cleanup = forwardSignals(child); - - child.on("close", (code, signal) => { - cleanup(); - if (signal != null) { - // Report the conventional 128 + signal-number exit code. The lookup can - // be undefined at runtime (not every signal is in os.constants.signals on - // every platform); 128 + undefined is NaN and process.exit(NaN) coerces - // to 0, masking the abort — so fall back to a generic nonzero code. - const signalNumber = osConstants.signals[signal] as number | undefined; - const code = signalNumber != null ? 128 + signalNumber : 1; - resolvePromise({ exited: true, code, signal }); - return; - } - resolvePromise({ exited: true, code: code ?? 1, signal: null }); - }); - // npx missing / not spawnable: report so the caller can fall back to the - // installed CLI instead of failing the whole `hexclave dev`. - child.on("error", (err) => { - cleanup(); - resolvePromise({ exited: false, error: err.message }); - }); - }); -} - -// What the parent does once the re-exec'd npx process is done: `exit` propagates -// the child's code (our CLI ran), `fallback` runs the installed CLI inline (the -// update failed before our CLI ran). Pure so it can be unit-tested. -export type PostReexecAction = - | { kind: "exit", code: number } - | { kind: "fallback", detail: string }; - -// `started` = whether the re-exec'd CLI's startup marker appeared. A nonzero -// exit with it started is a real failure (propagate); without it — or npx not -// spawnable — the update failed before our CLI ran, so we fall back. -export function decidePostReexec(opts: { result: ReexecResult, started: boolean }): PostReexecAction { - const { result, started } = opts; - if (!result.exited) { - return { kind: "fallback", detail: `could not run npx (${result.error})` }; - } - // Killed by a forwarded signal (e.g. Ctrl-C): the user wants to abort, not - // relaunch dev on the installed CLI. Propagate instead of falling back. - if (result.signal != null) { - return { kind: "exit", code: result.code }; - } - if (result.code !== 0 && !started) { - return { kind: "fallback", detail: `npx exited with code ${result.code} before the CLI started` }; - } - return { kind: "exit", code: result.code }; -} - -// Create a unique marker dir; the child writes a file inside it on startup. -// Returns null if the temp dir can't be created, in which case the caller treats -// every exit as "started" (preserving the old always-propagate behavior). -function createReexecMarker(): { dir: string, file: string } | null { - try { - const dir = mkdtempSync(join(tmpdir(), "hexclave-reexec-")); - return { dir, file: join(dir, "started") }; - } catch { - return null; - } -} - -function cleanupReexecMarker(marker: { dir: string } | null): void { - if (marker == null) return; - try { - rmSync(marker.dir, { recursive: true, force: true }); - } catch { - // best-effort temp cleanup - } -} - -// When we're the npx-spawned child (the parent set the marker env), touch the -// marker so the parent knows the latest CLI started. No-op at top level. -export function signalReexecStartedIfChild(env: NodeJS.ProcessEnv): void { - const markerFile = env[REEXEC_MARKER_ENV]; - if (markerFile == null || markerFile === "") return; - try { - writeFileSync(markerFile, "1"); - } catch { - // best-effort; if the write fails the parent just propagates as before. - } -} - -// Re-runs the command through `npx @latest` so the user always gets the -// latest CLI + dashboard without reinstalling, then exits with the child's code. -// The child carries SKIP_AUTO_UPDATE_ENV (run directly, don't recurse) and a -// marker path to signal it started. Best-effort: if the update fails before our -// CLI runs we fall back to the installed CLI instead of failing `hexclave dev`. -export async function maybeReexecToLatest(opts: { forwardArgs: string[] }): Promise { - // If npx re-exec'd us to the latest CLI, record that we started so the parent - // can tell a real command failure from an npx/install failure. - signalReexecStartedIfChild(process.env); - - let marker: { dir: string, file: string } | null = null; - try { - const decision = decideReexec({ - env: process.env, - pkg: getOwnPackage(), - forwardArgs: opts.forwardArgs, - }); - if (!decision.reexec) return; - - marker = createReexecMarker(); - const result = await runReexec(decision.invocation, marker?.file ?? null); - // No marker (couldn't create one): treat as "started" to keep the old - // always-propagate behavior rather than fall back spuriously. - const started = marker == null || existsSync(marker.file); - cleanupReexecMarker(marker); - marker = null; - - const action = decidePostReexec({ result, started }); - if (action.kind === "exit") { - process.exit(action.code); - } - logUpdate(`Auto-update skipped: ${action.detail}; continuing with the installed CLI.`); - } catch { - // Fail open: any unexpected error must not block the installed CLI from - // running. - } finally { - // Covers early-return/throw/opt-out paths; the success path already cleaned - // up before process.exit (which skips finally). - cleanupReexecMarker(marker); - } -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3d26e4f51..bb47c4cee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1723,6 +1723,9 @@ importers: commander: specifier: ^13.1.0 version: 13.1.0 + extract-zip: + specifier: ^2.0.1 + version: 2.0.1 jiti: specifier: ^2.4.2 version: 2.6.1 diff --git a/turbo.json b/turbo.json index ca14064fc..8bddb6a3b 100644 --- a/turbo.json +++ b/turbo.json @@ -95,8 +95,7 @@ }, "@hexclave/cli#build": { "dependsOn": [ - "^build", - "@hexclave/dashboard#build:rde-standalone" + "^build" ], "outputs": [ "dist/**"