Merge origin/dev into codex/analytics-overview-filters

Resolve conflict in pill-toggle.tsx: preserve PR's animated slider
feature (useEffect hooks, ResizeObserver, refs) while incorporating
dev's TooltipProvider wrapper. Kept PR's conditional TooltipProvider
approach (only wraps when showLabels=false) and moved TooltipPortal
import to @hexclave/ui per dev.

Co-Authored-By: mantra <mantra@stack-auth.com>
This commit is contained in:
Devin AI 2026-06-04 18:24:36 +00:00
commit fa5f7c91c7
27 changed files with 943 additions and 5688 deletions

View File

@ -1 +1 @@
Please compare `origin/dev` to `origin/main` (feel free to check out the dev branch if needed) and ensure that all migrations are backwards compatible. In what ways (DB, API, or otherwise) could breakage occur? Report the result to me in detail. Anything else that's scary that could occur, or that we should think about while migrating? Should we migrate first and upgrade the code second, or the other way around? Are rollbacks safe? Think hard.
Please compare `origin/dev` to `origin/main` (feel free to check out the dev branch if needed) and ensure that all migrations are backwards compatible. In what ways (DB, API, or otherwise) could breakage occur? Report the result to me in detail. Anything else that's scary that could occur, or that we should think about while migrating? Should we migrate first and upgrade the code second, or the other way around? Are rollbacks safe? Think hard. Unless specified otherwise, assume the database will be deployed before the backend, and the backend will be deployed before the frontend.

View File

@ -8,6 +8,10 @@ import { typedEntries, typedFromEntries } from '@hexclave/shared/dist/utils/obje
import { deindent, stringCompare } from '@hexclave/shared/dist/utils/strings';
import * as yup from 'yup';
function isInternalApiPath(path: string) {
return path === '/internal' || path.startsWith('/internal/');
}
export function parseOpenAPI(options: {
endpoints: Map<string, Map<HttpMethod, SmartRouteHandler>>,
audience: 'client' | 'server' | 'admin',
@ -25,6 +29,11 @@ export function parseOpenAPI(options: {
}],
paths: Object.fromEntries(
[...options.endpoints]
// `/internal/*` routes are scoped to the internal Hexclave project (project.id === "internal")
// and are not part of the public API. Many of them use a permissive auth.type (e.g. adaptSchema),
// so the per-audience heuristic below does not exclude them; filter them out explicitly here so
// they never leak into the public API reference, regardless of their individual route metadata.
.filter(([path]) => !isInternalApiPath(path))
.map(([path, handlersByMethod]) => (
[path, Object.fromEntries(
[...handlersByMethod]

View File

@ -3,7 +3,7 @@
import { DevErrorNotifier } from "@/components/dev-error-notifier";
import { RouterProvider } from "@/components/router";
import { SiteLoadingIndicatorDisplay } from "@/components/site-loading-indicator";
import { Toaster } from "@/components/ui";
import { Toaster, TooltipProvider } from "@/components/ui";
import { VersionAlerter } from "@/components/version-alerter";
import { getPublicEnvVar } from "@/lib/env";
import { hexclaveClientApp } from "@/hexclave/client";
@ -186,18 +186,20 @@ export function LayoutClient(props: {
<>
<StackProvider app={hexclaveClientApp} lang={props.translationLocale as React.ComponentProps<typeof StackProvider>["lang"]}>
<StackTheme>
<ClientPolyfill />
<DevEnvironmentHealthGate>
<RemoteDevelopmentEnvironmentAuthGate>
<RouterProvider>
<UserIdentity />
<VersionAlerter />
<BackgroundShine />
{props.children}
<DevelopmentPortDisplay />
</RouterProvider>
</RemoteDevelopmentEnvironmentAuthGate>
</DevEnvironmentHealthGate>
<TooltipProvider>
<ClientPolyfill />
<DevEnvironmentHealthGate>
<RemoteDevelopmentEnvironmentAuthGate>
<RouterProvider>
<UserIdentity />
<VersionAlerter />
<BackgroundShine />
{props.children}
<DevelopmentPortDisplay />
</RouterProvider>
</RemoteDevelopmentEnvironmentAuthGate>
</DevEnvironmentHealthGate>
</TooltipProvider>
</StackTheme>
</StackProvider>
<DevErrorNotifier />

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -6,8 +6,7 @@
"main": "dist/index.js",
"type": "module",
"bin": {
"hexclave": "./dist/index.js",
"stack": "./dist/index.js"
"hexclave": "./dist/index.js"
},
"scripts": {
"clean": "rimraf node_modules && rimraf dist",

View File

@ -229,7 +229,7 @@ export function registerConfigCommand(program: Command) {
.action(async (opts) => {
const auth = resolveAuth(resolveProjectId(opts.cloudProjectId));
if (!isProjectAuthWithRefreshToken(auth)) {
throw new CliError("`stack config pull` requires `stack login`. Remove STACK_SECRET_SERVER_KEY and try again.");
throw new CliError("`hexclave config pull` requires `hexclave login`. Remove STACK_SECRET_SERVER_KEY and try again.");
}
const project = await getAdminProject(auth);
@ -292,7 +292,7 @@ export function registerConfigCommand(program: Command) {
await pushConfigWithSecretServerKey(auth, config, source);
} else {
if (!isProjectAuthWithRefreshToken(auth)) {
throw new CliError("`stack config push` requires either STACK_SECRET_SERVER_KEY or `stack login`.");
throw new CliError("`hexclave config push` requires either STACK_SECRET_SERVER_KEY or `hexclave login`.");
}
const project = await getAdminProject(auth);
await project.pushConfig(config, {

View File

@ -0,0 +1,158 @@
import { mkdtempSync, rmSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { recordLocalDashboardProcess } from "../lib/dev-env-state.js";
import { isVersionNewer, killLocalDashboard, processExists, shouldRestartDashboard } from "./dev.js";
describe("isVersionNewer", () => {
it("compares core versions numerically", () => {
expect(isVersionNewer("2.8.110", "2.8.109")).toBe(true);
expect(isVersionNewer("2.9.0", "2.8.999")).toBe(true);
expect(isVersionNewer("3.0.0", "2.999.999")).toBe(true);
expect(isVersionNewer("2.8.109", "2.8.109")).toBe(false);
expect(isVersionNewer("2.8.108", "2.8.109")).toBe(false);
});
it("does not treat double-digit segments as strings", () => {
expect(isVersionNewer("2.8.10", "2.8.9")).toBe(true);
});
it("ranks a final release above a prerelease of the same core", () => {
expect(isVersionNewer("2.8.109", "2.8.109-beta.1")).toBe(true);
expect(isVersionNewer("2.8.109-beta.1", "2.8.109")).toBe(false);
});
it("returns false for unparseable versions (never downgrade or guess)", () => {
expect(isVersionNewer("garbage", "2.8.109")).toBe(false);
expect(isVersionNewer("2.8.110", "garbage")).toBe(false);
});
it("tolerates a leading v and surrounding whitespace on either side", () => {
expect(isVersionNewer("v2.8.110", "2.8.109")).toBe(true);
expect(isVersionNewer("2.8.110", "v2.8.109")).toBe(true);
expect(isVersionNewer(" 2.8.110 ", "2.8.109")).toBe(true);
expect(isVersionNewer("v2.8.110", "v2.8.110")).toBe(false);
});
it("treats a two-segment version (x.y) as unparseable", () => {
expect(isVersionNewer("2.8", "2.8.109")).toBe(false);
expect(isVersionNewer("2.8.109", "2.8")).toBe(false);
});
it("ignores prerelease identifiers when both cores are equal prereleases", () => {
// Only "release beats prerelease" is modeled; beta.2 is NOT newer than beta.1.
expect(isVersionNewer("2.8.109-beta.2", "2.8.109-beta.1")).toBe(false);
expect(isVersionNewer("2.8.109-beta.1", "2.8.109-beta.2")).toBe(false);
});
it("compares very large numeric segments correctly", () => {
expect(isVersionNewer("2.8.1000000000", "2.8.999999999")).toBe(true);
expect(isVersionNewer("10000000000.0.0", "9999999999.0.0")).toBe(true);
});
});
describe("shouldRestartDashboard", () => {
it("restarts only when ours is strictly newer than the running dashboard", () => {
expect(shouldRestartDashboard("2.8.110", "2.8.109")).toBe(true);
expect(shouldRestartDashboard("2.8.109", "2.8.109")).toBe(false);
expect(shouldRestartDashboard("2.8.108", "2.8.109")).toBe(false);
});
it("reuses (does not restart) when either version is unknown", () => {
// A dashboard recorded by a pre-feature CLI has no version field.
expect(shouldRestartDashboard("2.8.110", undefined)).toBe(false);
expect(shouldRestartDashboard(undefined, "2.8.109")).toBe(false);
expect(shouldRestartDashboard(undefined, undefined)).toBe(false);
});
});
describe("processExists", () => {
it("returns true for the current process and false for an impossible pid", () => {
expect(processExists(process.pid)).toBe(true);
// pid 1 always exists; a huge pid effectively never does.
expect(processExists(2_147_483_646)).toBe(false);
});
});
describe("killLocalDashboard", () => {
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "dev-kill-"));
process.env.STACK_DEV_ENVS_PATH = join(tempDir, "dev-envs.json");
});
afterEach(() => {
delete process.env.STACK_DEV_ENVS_PATH;
rmSync(tempDir, { recursive: true, force: true });
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("does nothing when no dashboard pid is recorded", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
// Filter to our own signals: the worker-thread runtime may call
// process.kill for its own bookkeeping, which isn't what we're asserting.
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
await killLocalDashboard("http://127.0.0.1:26700", 26700);
// No recorded pid → return before probing the process or polling the port.
expect(fetchMock).not.toHaveBeenCalled();
const targetedCalls = killSpy.mock.calls.filter(([, sig]) => sig === "SIGTERM" || sig === "SIGKILL");
expect(targetedCalls).toHaveLength(0);
});
it("returns immediately without a wait loop when the process is already gone (ESRCH)", async () => {
recordLocalDashboardProcess(26700, "s", 4242, "/tmp/x.log", "2.8.110");
// processExists(0-probe) throws ESRCH → treated as not alive → early return.
vi.spyOn(process, "kill").mockImplementation(() => {
const e = new Error("no such process") as NodeJS.ErrnoException;
e.code = "ESRCH";
throw e;
});
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
await killLocalDashboard("http://127.0.0.1:26700", 26700);
expect(fetchMock).not.toHaveBeenCalled();
});
it("does not wait on or escalate a pid owned by another process (EPERM)", async () => {
recordLocalDashboardProcess(26700, "s", 4242, "/tmp/x.log", "2.8.110");
const killSpy = vi.spyOn(process, "kill").mockImplementation((_pid, signal) => {
// signal 0 (existence probe) → EPERM means "exists but not ours".
// SIGTERM → also EPERM; we must bail without looping.
const e = new Error("operation not permitted") as NodeJS.ErrnoException;
e.code = "EPERM";
throw e;
});
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
await killLocalDashboard("http://127.0.0.1:26700", 26700);
// processExists sees EPERM → alive; SIGTERM throws EPERM → early return.
// We never poll /health, and never send SIGKILL.
expect(fetchMock).not.toHaveBeenCalled();
const sigkillCalls = killSpy.mock.calls.filter(([, sig]) => sig === "SIGKILL");
expect(sigkillCalls).toHaveLength(0);
});
it("returns once the port is free without SIGKILL, even if the pid still resolves (recycled pid)", async () => {
recordLocalDashboardProcess(26700, "s", 4242, "/tmp/x.log", "2.8.110");
// Every process.kill (including the `0` probe) succeeds, so processExists
// always reports the pid as alive — simulating a pid recycled onto another
// live same-user process after our dashboard exited.
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
// Port is already free (connection refused), so the dashboard is gone.
const fetchMock = vi.fn().mockRejectedValue(new Error("ECONNREFUSED"));
vi.stubGlobal("fetch", fetchMock);
await killLocalDashboard("http://127.0.0.1:26700", 26700);
// SIGTERM is sent once; we must return as soon as the port frees up and
// never escalate to SIGKILL against the (possibly recycled) pid.
const sigterm = killSpy.mock.calls.filter(([, sig]) => sig === "SIGTERM");
const sigkill = killSpy.mock.calls.filter(([, sig]) => sig === "SIGKILL");
expect(sigterm).toHaveLength(1);
expect(sigkill).toHaveLength(0);
});
});

View File

@ -4,9 +4,12 @@ import { chmodSync, closeSync, cpSync, existsSync, mkdirSync, openSync, readdirS
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 { devEnvStatePath, ensureLocalDashboardSecret, recordLocalDashboardProcess } from "../lib/dev-env-state.js";
import { devEnvStatePath, ensureLocalDashboardSecret, readDevEnvState, recordLocalDashboardProcess } from "../lib/dev-env-state.js";
import { CliError } from "../lib/errors.js";
import { cliVersion } from "../lib/own-package.js";
import { maybeReexecToLatest } from "../lib/self-update.js";
type ChildCommand = {
command: string,
@ -15,6 +18,7 @@ type ChildCommand = {
type DevOptions = {
configFile?: string,
autoUpdate?: boolean,
};
type SessionResponse = {
@ -30,6 +34,8 @@ const DASHBOARD_RESTART_MIN_UPTIME_MS = 5_000;
const DEFAULT_DASHBOARD_PORT = 26700;
const DASHBOARD_PORT_ENV_VAR = "NEXT_PUBLIC_HEXCLAVE_LOCAL_DASHBOARD_PORT";
const DASHBOARD_START_TIMEOUT_MS = 60_000;
const DASHBOARD_STOP_TIMEOUT_MS = 10_000;
const DASHBOARD_FORCE_STOP_TIMEOUT_MS = 2_000;
const DASHBOARD_HEALTH_PATH = "/api/development-environment/health";
const BUNDLED_DASHBOARD_DIR_NAME = "dashboard";
const BUNDLED_DASHBOARD_SERVER_PATH = join("apps", "dashboard", "server.js");
@ -72,7 +78,7 @@ function errorMessage(error: unknown): string {
function splitDevCommandArgs(commandArgs: string[]): ChildCommand {
if (commandArgs.length === 0) {
throw new CliError("Missing command. Usage: stack dev --config-file <path> -- <command> [args...]");
throw new CliError("Missing command. Usage: hexclave dev --config-file <path> -- <command> [args...]");
}
const command = commandArgs[0];
return { command, args: commandArgs.slice(1) };
@ -179,7 +185,7 @@ function assertBundledDashboardExists(): void {
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 `stack dev`.",
"Build the CLI package with the dashboard standalone assets before running `hexclave dev`.",
].join(" "));
}
}
@ -267,11 +273,119 @@ async function isDashboardReachable(url: string): Promise<boolean> {
}
}
type ParsedVersion = {
core: [number, number, number],
hasPrerelease: boolean,
};
function parseVersionCore(version: string): ParsedVersion | null {
const trimmed = version.trim();
const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(trimmed);
if (!match) return null;
return {
core: [Number(match[1]), Number(match[2]), Number(match[3])],
// A `-` immediately after the core marks a semver prerelease (e.g.
// 2.8.109-beta.1). `.test()` returns a plain boolean, sidestepping the
// optional-capture-group typing.
hasPrerelease: /^v?\d+\.\d+\.\d+-/.test(trimmed),
};
}
// 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.
export function isVersionNewer(candidate: string, current: string): boolean {
const a = parseVersionCore(candidate);
const b = parseVersionCore(current);
if (a == null || b == null) return false;
for (let i = 0; i < 3; i++) {
if (a.core[i] !== b.core[i]) {
return a.core[i] > b.core[i];
}
}
// Same x.y.z: a final release outranks a prerelease of the same core.
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);
}
// Whether `pid` refers to a live process. EPERM means it exists but is owned by
// another user — i.e. the pid was recycled onto something that isn't ours.
export function processExists(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException).code === "EPERM";
}
}
// Terminate the background dashboard recorded for `port` in dev-env state and
// wait until the port stops answering, so a fresh (newer) dashboard can rebind
// without EADDRINUSE.
export async function killLocalDashboard(url: string, port: number): Promise<void> {
const pid = readDevEnvState().localDashboardsByPort?.[String(port)]?.pid;
if (pid == null || pid <= 0) return;
if (!processExists(pid)) return;
try {
process.kill(pid, "SIGTERM");
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
// ESRCH: already gone. EPERM: the pid was recycled onto a process we don't
// own, so it isn't our dashboard — don't wait on it or escalate to SIGKILL.
if (code === "ESRCH" || code === "EPERM") return;
throw error;
}
// Wait for the port to be released — that's the property that actually lets
// the replacement bind. Don't gate on the pid: once the dashboard exits its
// pid can be recycled onto an unrelated same-user process, which a pid probe
// would misreport as "still alive" (spinning the full timeout and then
// mis-targeting the SIGKILL below). isDashboardReachable only succeeds while
// the listener is up, so an unreachable port reliably means it's gone.
const startedAt = performance.now();
while (performance.now() - startedAt < DASHBOARD_STOP_TIMEOUT_MS) {
if (!(await isDashboardReachable(url))) return;
await wait(200);
}
// Still listening after the grace period — the process is genuinely hung and
// still holding the port, so the recorded pid is necessarily still valid;
// force it down, then wait for the socket to be released.
try {
process.kill(pid, "SIGKILL");
} catch {
// best-effort
}
const killDeadline = performance.now() + DASHBOARD_FORCE_STOP_TIMEOUT_MS;
while (performance.now() < killDeadline) {
if (!(await isDashboardReachable(url))) return;
await wait(200);
}
}
async function startDashboardIfNeeded(options: { apiBaseUrl: string, secret: string, port: number }): Promise<void> {
const url = dashboardUrl(options.port);
if (await isDashboardReachable(url)) {
logDev(`Using existing Hexclave dashboard on ${url}.`);
return;
const currentVersion = cliVersion();
const runningVersion = readDevEnvState().localDashboardsByPort?.[String(options.port)]?.version;
if (shouldRestartDashboard(currentVersion, runningVersion)) {
logDev(`Existing Hexclave dashboard is ${runningVersion}; restarting with ${currentVersion}...`);
await killLocalDashboard(url, options.port);
} else {
logDev(`Using existing Hexclave dashboard on ${url}.`);
return;
}
}
const progress = startProgressLog(`Hexclave dashboard not found on port ${options.port}. Starting now`);
@ -316,7 +430,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);
recordLocalDashboardProcess(options.port, options.secret, child.pid, logPath, cliVersion());
child.unref();
const startedAt = performance.now();
@ -426,15 +540,7 @@ async function createRemoteDevelopmentEnvironmentSession(options: {
function runChildProcess(command: ChildCommand, env: NodeJS.ProcessEnv): Promise<number> {
return new Promise((resolvePromise, reject) => {
const child = spawn(command.command, command.args, { stdio: "inherit", env });
const forward = (signal: NodeJS.Signals) => () => child.kill(signal);
const onSigint = forward("SIGINT");
const onSigterm = forward("SIGTERM");
const cleanup = () => {
process.off("SIGINT", onSigint);
process.off("SIGTERM", onSigterm);
};
process.on("SIGINT", onSigint);
process.on("SIGTERM", onSigterm);
const cleanup = forwardSignals(child);
child.on("close", (code) => {
cleanup();
resolvePromise(code ?? 1);
@ -552,12 +658,22 @@ export function registerDevCommand(program: Command) {
.usage("--config-file <path> -- <command> [args...]")
.description("Run a command with Hexclave development-environment credentials")
.requiredOption("--config-file <path>", "Path to stack.config.ts")
.option("--no-auto-update", "Don't re-run the latest published CLI via npx before starting")
.argument("<command...>", "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 <pkg>@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);

View File

@ -560,7 +560,7 @@ function renderHuman(report: Report) {
const summary = `${report.passed} passed, ${report.failed} failed${report.warned > 0 ? `, ${report.warned} warned` : ""}.`;
console.log(summary);
if (report.failed > 0) {
console.log(`${dim}Tip: run \`stack fix\` and paste the runtime error to apply fixes automatically.${reset}`);
console.log(`${dim}Tip: run \`hexclave fix\` and paste the runtime error to apply fixes automatically.${reset}`);
}
}

View File

@ -56,7 +56,7 @@ export function registerExecCommand(program: Command) {
.addHelpText("after", "\nFor available API methods, see: https://docs.hexclave.com/sdk/overview")
.action(async (javascript: string | undefined, opts: ExecTargetOpts) => {
if (javascript === undefined) {
throw new CliError("Missing JavaScript argument. Use `stack exec \"<javascript>\"` or `stack exec --help`.");
throw new CliError("Missing JavaScript argument. Use `hexclave exec \"<javascript>\"` or `hexclave exec --help`.");
}
const target = parseExecTarget(opts);
@ -64,7 +64,7 @@ export function registerExecCommand(program: Command) {
if (target.kind === "cloud") {
const cloudAuth = resolveAuth(target.projectId);
if (!isProjectAuthWithRefreshToken(cloudAuth)) {
throw new CliError("`stack exec --cloud-project-id` requires `stack login`. Remove STACK_SECRET_SERVER_KEY and try again.");
throw new CliError("`hexclave exec --cloud-project-id` requires `hexclave login`. Remove STACK_SECRET_SERVER_KEY and try again.");
}
auth = cloudAuth;
} else {

View File

@ -47,7 +47,7 @@ export function registerInitCommand(program: Command) {
const hasFlags = opts.mode != null || opts.configFile != null || opts.selectProjectId != null;
if (!hasFlags && isNonInteractiveEnv()) {
throw new CliError("stack init requires an interactive terminal. Use --mode flag for non-interactive usage.");
throw new CliError("hexclave init requires an interactive terminal. Use --mode flag for non-interactive usage.");
}
try {
@ -208,7 +208,7 @@ async function ensureLoggedInSession() {
} catch (e) {
if (e instanceof AuthError) {
if (isNonInteractiveEnv()) {
throw new CliError("Not logged in. Run `stack login` first or set STACK_CLI_REFRESH_TOKEN.");
throw new CliError("Not logged in. Run `hexclave login` first or set STACK_CLI_REFRESH_TOKEN.");
}
console.log("You need to log in first.\n");
await performLogin();
@ -296,7 +296,7 @@ async function handleLinkFromCloud(_flags: Record<string, unknown>, opts: InitOp
throw new CliError(`Project '${opts.selectProjectId}' not found among your owned projects. Check the ID or omit --select-project-id to create a new project interactively.`);
}
if (isNonInteractiveEnv()) {
throw new CliError("No projects found. Run `stack project create --display-name <name>` first.");
throw new CliError("No projects found. Run `hexclave project create --display-name <name>` first.");
}
const shouldCreate = await confirm({

View File

@ -93,7 +93,7 @@ export function registerProjectCommand(program: Command) {
.option("--display-name <name>", "Project display name")
.action(async (opts) => {
if (!opts.cloud) {
throw new CliError("stack project create currently only creates cloud projects. Pass --cloud to confirm.");
throw new CliError("hexclave project create currently only creates cloud projects. Pass --cloud to confirm.");
}
const auth = resolveSessionAuth();
const user = await getInternalUser(auth);

View File

@ -4,9 +4,7 @@ initSentry();
import * as Sentry from "@sentry/node";
import { captureError } from "@hexclave/shared/dist/utils/errors";
import { Command } from "commander";
import { readFileSync } from "fs";
import { fileURLToPath } from "url";
import { dirname, join } from "path";
import { cliVersion } from "./lib/own-package.js";
import { AuthError, CliError } from "./lib/errors.js";
import { registerLoginCommand } from "./commands/login.js";
import { registerLogoutCommand } from "./commands/logout.js";
@ -19,16 +17,12 @@ import { registerFixCommand } from "./commands/fix.js";
import { registerDoctorCommand } from "./commands/doctor.js";
import { registerWhoamiCommand } from "./commands/whoami.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
const program = new Command();
program
.name("stack")
.name("hexclave")
.description("Hexclave CLI. For more information, go to https://docs.hexclave.com. If you're an AI agent, go to https://skill.hexclave.com.")
.version(pkg.version)
.version(cliVersion() ?? "0.0.0")
.option("--json", "Output in JSON format");
registerLoginCommand(program);

View File

@ -46,7 +46,7 @@ function resolveRefreshToken(): string {
const token = process.env.STACK_CLI_REFRESH_TOKEN
?? readConfigValue("STACK_CLI_REFRESH_TOKEN");
if (!token) {
throw new AuthError("Not logged in. Run `stack login` first.");
throw new AuthError("Not logged in. Run `hexclave login` first.");
}
return token;
}

View File

@ -0,0 +1,22 @@
import type { ChildProcess } from "child_process";
// Forward SIGINT/SIGTERM from this process to a spawned child until the
// returned cleanup function is called (call it once the child has exited).
// Killing is best-effort: a child that already exited throws, which we ignore.
export function forwardSignals(child: ChildProcess): () => void {
const forward = (signal: NodeJS.Signals) => () => {
try {
child.kill(signal);
} catch {
// best-effort
}
};
const onSigint = forward("SIGINT");
const onSigterm = forward("SIGTERM");
process.on("SIGINT", onSigint);
process.on("SIGTERM", onSigterm);
return () => {
process.off("SIGINT", onSigint);
process.off("SIGTERM", onSigterm);
};
}

View File

@ -74,6 +74,87 @@ describe("dev env state", () => {
});
});
it("records the CLI version that started the dashboard", () => {
useTempStateFile();
const secret = ensureLocalDashboardSecret(26700);
recordLocalDashboardProcess(26700, secret, 12345, "/tmp/stack-rde-dashboard.log", "2.8.110");
expect(readDevEnvState().localDashboardsByPort?.["26700"]?.version).toBe("2.8.110");
});
it("preserves a previously recorded dashboard version when ensuring the secret", () => {
useTempStateFile();
const secret = ensureLocalDashboardSecret(26700);
recordLocalDashboardProcess(26700, secret, 12345, "/tmp/stack-rde-dashboard.log", "2.8.110");
ensureLocalDashboardSecret(26700);
expect(readDevEnvState().localDashboardsByPort?.["26700"]?.version).toBe("2.8.110");
});
it("does not clobber projectsByConfigPath or anonymousRefreshToken across writes", () => {
useTempStateFile();
writeDevEnvState({
version: 1,
anonymousRefreshToken: "rt-123",
projectsByConfigPath: {
"/a/stack.config.ts": {
projectId: "p", teamId: "t", publishableClientKey: "pk",
secretServerKey: "sk", apiBaseUrl: "http://x", updatedAtMillis: 1,
},
},
});
ensureLocalDashboardSecret(26700);
const state = readDevEnvState();
expect(state.anonymousRefreshToken).toBe("rt-123");
expect(state.projectsByConfigPath["/a/stack.config.ts"]?.projectId).toBe("p");
});
it("reads a recorded dashboard without a version field as version undefined", () => {
useTempStateFile();
const statePath = process.env.STACK_DEV_ENVS_PATH;
if (statePath == null) {
throw new Error("STACK_DEV_ENVS_PATH should be set by useTempStateFile().");
}
writeFileSync(statePath, JSON.stringify({
version: 1,
localDashboardsByPort: { "26700": { port: 26700, secret: "s", pid: 999, startedAtMillis: 1 } },
projectsByConfigPath: {},
}), { mode: 0o600 });
const state = readDevEnvState();
expect(state.localDashboardsByPort?.["26700"]?.pid).toBe(999);
expect(state.localDashboardsByPort?.["26700"]?.version).toBeUndefined();
});
it("drops a per-port dashboard whose version is a non-string", () => {
useTempStateFile();
const statePath = process.env.STACK_DEV_ENVS_PATH;
if (statePath == null) {
throw new Error("STACK_DEV_ENVS_PATH should be set by useTempStateFile().");
}
// A hand-edited / cross-version file with a non-string version would
// otherwise reach parseVersionCore (version.trim()) and throw, crashing
// `stack dev` outside the auto-update fail-open guard. Drop the entry.
writeFileSync(statePath, JSON.stringify({
version: 1,
localDashboardsByPort: { "26700": { port: 26700, secret: "s", pid: 999, startedAtMillis: 1, version: 2 } },
projectsByConfigPath: {},
}), { mode: 0o600 });
expect(readDevEnvState().localDashboardsByPort?.["26700"]).toBeUndefined();
});
it("drops a structurally malformed per-port dashboard on read", () => {
useTempStateFile();
const statePath = process.env.STACK_DEV_ENVS_PATH;
if (statePath == null) {
throw new Error("STACK_DEV_ENVS_PATH should be set by useTempStateFile().");
}
// Missing secret + non-numeric pid: not a usable dashboard record.
writeFileSync(statePath, JSON.stringify({
version: 1,
localDashboardsByPort: { "26700": { port: 26700, pid: "nope", startedAtMillis: 1 } },
projectsByConfigPath: {},
}), { mode: 0o600 });
expect(readDevEnvState().localDashboardsByPort?.["26700"]).toBeUndefined();
});
it("writes state as owner-readable JSON", () => {
useTempStateFile();
writeDevEnvState({

View File

@ -9,6 +9,9 @@ type LocalDashboardState = {
pid: number,
startedAtMillis: number,
logPath?: string,
// CLI version that started this dashboard, used to decide whether a
// reachable dashboard is stale and should be restarted.
version?: string,
};
export type DevEnvState = {
@ -31,6 +34,42 @@ export function devEnvStatePath(): string {
return hexclaveDevEnvStatePath();
}
// Validate an on-disk dashboard record: a hand-edited or cross-version state
// file could carry wrong-typed fields. In particular a non-string `version`
// flows into shouldRestartDashboard ->
// isVersionNewer -> parseVersionCore (version.trim()) inside
// startDashboardIfNeeded, which is not behind the auto-update fail-open guard,
// so it would throw and crash `hexclave dev`. Malformed entries are dropped on
// read (a fresh dashboard is then started for that port).
function isLocalDashboardState(value: unknown): value is LocalDashboardState {
if (value == null || typeof value !== "object") return false;
const candidate = value as Record<string, unknown>;
return (
typeof candidate.port === "number" &&
Number.isFinite(candidate.port) &&
typeof candidate.secret === "string" &&
typeof candidate.pid === "number" &&
Number.isFinite(candidate.pid) &&
typeof candidate.startedAtMillis === "number" &&
Number.isFinite(candidate.startedAtMillis) &&
(candidate.logPath === undefined || typeof candidate.logPath === "string") &&
(candidate.version === undefined || typeof candidate.version === "string")
);
}
// Keep only well-formed per-port dashboard records; drop the rest so a corrupt
// or cross-version entry never reaches the restart/version-parsing path.
function sanitizeLocalDashboardsByPort(value: unknown): Partial<Record<string, LocalDashboardState>> | undefined {
if (value == null || typeof value !== "object") return undefined;
const sanitized: Record<string, LocalDashboardState> = {};
for (const [port, entry] of Object.entries(value as Record<string, unknown>)) {
if (isLocalDashboardState(entry)) {
sanitized[port] = entry;
}
}
return sanitized;
}
export function readDevEnvState(): DevEnvState {
const path = devEnvStatePath();
if (!existsSync(path)) {
@ -47,7 +86,7 @@ export function readDevEnvState(): DevEnvState {
version: 1,
anonymousRefreshToken: typeof parsed.anonymousRefreshToken === "string" ? parsed.anonymousRefreshToken : undefined,
anonymousApiBaseUrl: typeof parsed.anonymousApiBaseUrl === "string" ? parsed.anonymousApiBaseUrl : undefined,
localDashboardsByPort: parsed.localDashboardsByPort,
localDashboardsByPort: sanitizeLocalDashboardsByPort(parsed.localDashboardsByPort),
projectsByConfigPath: parsed.projectsByConfigPath ?? {},
};
}
@ -63,15 +102,14 @@ export function ensureLocalDashboardSecret(port: number): string {
const state = readDevEnvState();
const portKey = String(port);
const existingDashboard = state.localDashboardsByPort?.[portKey];
const existing =
existingDashboard?.secret;
const secret = existing ?? randomBytes(32).toString("hex");
const secret = existingDashboard?.secret ?? randomBytes(32).toString("hex");
const dashboardState: LocalDashboardState = {
port,
secret,
pid: existingDashboard?.pid ?? 0,
startedAtMillis: existingDashboard?.startedAtMillis ?? Date.now(),
logPath: existingDashboard?.logPath,
version: existingDashboard?.version,
};
writeDevEnvState({
...state,
@ -83,7 +121,7 @@ export function ensureLocalDashboardSecret(port: number): string {
return secret;
}
export function recordLocalDashboardProcess(port: number, secret: string, pid: number, logPath: string): void {
export function recordLocalDashboardProcess(port: number, secret: string, pid: number, logPath: string, version?: string): void {
const state = readDevEnvState();
const dashboardState: LocalDashboardState = {
port,
@ -91,6 +129,7 @@ export function recordLocalDashboardProcess(port: number, secret: string, pid: n
pid,
startedAtMillis: Date.now(),
logPath,
version,
};
writeDevEnvState({
...state,

View File

@ -122,7 +122,7 @@ export async function lookupLocalEmulatorProjectIdByPath(absolutePath: string):
const projects = await listLocalEmulatorProjects();
const match = findProjectByAbsolutePath(projects, absolutePath);
if (!match) {
throw new CliError(`No development-environment project registered for ${absolutePath}. Open it in the dashboard or run \`stack init\` from that directory first.`);
throw new CliError(`No development-environment project registered for ${absolutePath}. Open it in the dashboard or run \`hexclave init\` from that directory first.`);
}
return match.projectId;
}

View File

@ -0,0 +1,45 @@
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");
});
});
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({
name: "@hexclave/cli",
version: "1.2.3",
binName: "stack",
});
});
it("returns null when name or version is missing or non-string", () => {
expect(parseOwnPackage({ version: "1.0.0" })).toBeNull();
expect(parseOwnPackage({ name: "@hexclave/cli" })).toBeNull();
expect(parseOwnPackage({ name: 123, version: "1.0.0" })).toBeNull();
expect(parseOwnPackage({ name: "@hexclave/cli", version: 1 })).toBeNull();
});
it("returns null for non-object input", () => {
expect(parseOwnPackage(null)).toBeNull();
expect(parseOwnPackage("nope")).toBeNull();
expect(parseOwnPackage(undefined)).toBeNull();
});
});

View File

@ -0,0 +1,55 @@
import { readFileSync } from "fs";
import { dirname, join } from "path";
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<string, unknown>);
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 };
if (typeof pkg.name !== "string" || typeof pkg.version !== "string") return null;
return {
name: pkg.name,
version: pkg.version,
binName: resolveBinName(pkg.bin, pkg.name),
};
}
// Reads this CLI's own package.json. After bundling, every module collapses
// into dist/index.js, so package.json is one directory up from the module dir
// in both the bundled and source layouts. Returns null on any failure so
// callers degrade gracefully.
export function getOwnPackage(): OwnPackage | null {
try {
const here = dirname(fileURLToPath(import.meta.url));
return parseOwnPackage(JSON.parse(readFileSync(join(here, "..", "package.json"), "utf-8")));
} catch {
return null;
}
}
export function cliVersion(): string | undefined {
return getOwnPackage()?.version;
}

View File

@ -0,0 +1,181 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
buildNpxInvocation,
decideReexec,
DISABLE_AUTO_UPDATE_ENV,
isEnvFlagEnabled,
maybeReexecToLatest,
shouldAutoUpdate,
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<string, string | undefined> = {};
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();
});
});

View File

@ -0,0 +1,160 @@
import { spawn } from "child_process";
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";
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 <pkg>@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,
}),
};
}
type ReexecResult =
| { exited: true, code: number }
| { 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): Promise<ReexecResult> {
return new Promise((resolvePromise) => {
const args = invocation.shell ? invocation.args.map(quoteShellArg) : invocation.args;
const child = spawn(invocation.command, args, {
stdio: "inherit",
env: { ...process.env, [SKIP_AUTO_UPDATE_ENV]: "1" },
shell: invocation.shell,
});
const cleanup = forwardSignals(child);
child.on("close", (code) => {
cleanup();
resolvePromise({ exited: true, code: code ?? 1 });
});
// 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 });
});
});
}
// Re-runs the requested command through `npx <pkg>@latest` so the user always
// gets the latest CLI + dashboard without reinstalling, then exits with the
// child's code. The re-exec'd child carries SKIP_AUTO_UPDATE_ENV so it runs the
// freshly downloaded CLI directly instead of recursing. Best-effort: if npx
// can't be spawned (or auto-update is off / opted out) we silently fall through
// to the installed CLI.
export async function maybeReexecToLatest(opts: { forwardArgs: string[] }): Promise<void> {
try {
const decision = decideReexec({
env: process.env,
pkg: getOwnPackage(),
forwardArgs: opts.forwardArgs,
});
if (!decision.reexec) return;
const result = await runReexec(decision.invocation);
if (result.exited) {
process.exit(result.code);
}
logUpdate(`Could not run npx (${result.error}); continuing with the installed CLI.`);
} catch {
// Fail open: any unexpected error must not block the installed CLI from
// running.
}
}

View File

@ -4,24 +4,12 @@ import { registerErrorSink } from "@hexclave/shared/dist/utils/errors";
import { ignoreUnhandledRejection } from "@hexclave/shared/dist/utils/promises";
import { sentryBaseConfig } from "@hexclave/shared/dist/utils/sentry";
import { nicify } from "@hexclave/shared/dist/utils/strings";
import { readFileSync } from "fs";
import { homedir } from "os";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import { cliVersion } from "./own-package.js";
// Replaced at build time by tsdown `define`. Empty = not configured (dev/unbuilt).
declare const __STACK_CLI_SENTRY_DSN__: string;
function readPackageVersion(): string | undefined {
try {
const here = dirname(fileURLToPath(import.meta.url));
const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf-8")) as { version?: string };
return pkg.version;
} catch {
return undefined;
}
}
function scrubString(input: string): string {
let out = input;
const home = homedir();
@ -58,7 +46,7 @@ function scrubValue(value: unknown, key?: string): unknown {
export function initSentry() {
const dsn = typeof __STACK_CLI_SENTRY_DSN__ === "string" ? __STACK_CLI_SENTRY_DSN__ : "";
const version = readPackageVersion();
const version = cliVersion();
Sentry.init({
...sentryBaseConfig,

View File

@ -2,7 +2,7 @@ import { ALL_APPS } from "../../apps/apps-config";
import { deindent } from "../../utils/strings";
export const remindersPrompt = deindent`
Below are some reminders on Hexclave and how to learn more about it.
Below are some reminders on Hexclave and how to learn more about it. If you're summarizing this, always try to return the entirety of the reminders verbatim, or keep as much information as possible.
- Hexclave has multiple sources of documentation optimized for LLMs. For the canonical, always-fresh instructions for every Hexclave surface, always prefer these sources over information that may be outdated or stale. Do not rely on training data or memory.
- [Highest priority] If you are an AI agent: The best way to learn about Hexclave is to fetch the latest skill documentation from https://skill.hexclave.com. Fetch the entire page, not just a summary. You can either use a web fetch tool, or call it using curl: \`curl -sSL https://skill.hexclave.com\`. This is sufficient for most simple questions.

View File

@ -1,6 +1,6 @@
import { TooltipPortal } from "@radix-ui/react-tooltip";
import { CircleAlert, Info } from "lucide-react";
import { Tooltip, TooltipContent, TooltipTrigger, cn } from "..";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, cn } from "..";
export function SimpleTooltip(props: {
tooltip: React.ReactNode,
@ -22,25 +22,27 @@ export function SimpleTooltip(props: {
);
return (
<Tooltip open={props.disabled ? false : undefined}>
<TooltipTrigger asChild>
{props.inline ? (
<span className={cn(props.className)}>
{trigger}
</span>
) : (
<div className={cn("flex items-center gap-1", props.className)}>
{trigger}
</div>
)}
</TooltipTrigger>
{props.tooltip && <TooltipPortal>
<TooltipContent>
<div className="max-w-60 text-center text-wrap whitespace-pre-wrap">
{props.tooltip}
</div>
</TooltipContent>
</TooltipPortal>}
</Tooltip>
<TooltipProvider>
<Tooltip open={props.disabled ? false : undefined}>
<TooltipTrigger asChild>
{props.inline ? (
<span className={cn(props.className)}>
{trigger}
</span>
) : (
<div className={cn("flex items-center gap-1", props.className)}>
{trigger}
</div>
)}
</TooltipTrigger>
{props.tooltip && <TooltipPortal>
<TooltipContent>
<div className="max-w-60 text-center text-wrap whitespace-pre-wrap">
{props.tooltip}
</div>
</TooltipContent>
</TooltipPortal>}
</Tooltip>
</TooltipProvider>
);
}