From b0181ea19577314920c0c1cff21ffb3e00bac48d Mon Sep 17 00:00:00 2001 From: BilalG1 Date: Tue, 2 Jun 2026 15:57:13 -0700 Subject: [PATCH] fix(cli): ship a single `hexclave` bin so pnpx/pnpm dlx can resolve it (#1533) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem `pnpx @hexclave/cli ` (i.e. `pnpm dlx`) fails, even though `npx @hexclave/cli ` works. ## Root cause `@hexclave/cli` exposed **two** bins — `hexclave` and `stack` — and neither matches the package's unscoped name (`@hexclave/cli` → `cli`). When a package has multiple bins and none matches the unscoped name, the two runners diverge: - **npx** silently picks the *first* bin and runs it → worked by luck. - **pnpm dlx / pnpx** refuses to guess and errors: ``` ERR_PNPM_DLX_MULTIPLE_BINS Could not determine executable to run. @hexclave/cli has multiple binaries: hexclave, stack ``` Reproduced and verified offline with a throwaway 2-bin package; also verified that a **single**-bin package auto-resolves under both `npx` and `pnpm dlx` even when the bin name doesn't match the unscoped package name. ## Fix Drop the `stack` bin, leaving a single `hexclave` bin → both `npx` and `pnpm dlx`/`pnpx` resolve it unambiguously. Follow-through to keep the CLI self-consistent: - `resolveBinName` now prefers `hexclave` for the npx self-update re-exec, so auto-update targets a bin guaranteed to exist in `@latest` (it previously hard-preferred `stack`). - Program name (`--help` usage) `stack` → `hexclave`. - User-facing `stack ` guidance strings (in error messages / tips across `auth`, `init`, `dev`, `config-file`, `exec`, `project`, `doctor`, `local-emulator-client`) → `hexclave `, so the CLI never points users at a command that no longer exists. - Unit test updated for the new bin preference. ## ⚠️ Breaking-change note This drops the `stack` command. Fresh `npx`/`pnpx` users are unaffected, but anyone with an *older* version installed loses the `stack` bin on upgrade. Subtly, a stale install's auto-update re-execs `npx -p @hexclave/cli@latest stack …` (its baked-in logic prefers `stack`); once `@latest` drops the `stack` bin that one re-exec fails for those stale installs. If we want a deprecation window, we can keep `stack` as a bin for a release instead. ## Testing - `pnpm lint` ✅ - `pnpm typecheck` ✅ - `pnpm test` (stack-cli) ✅ 148/148 (pure unit tests, no backend required) > Stacked on top of `rde-cli-auto-update` — `resolveBinName`/`self-update.ts` only exist on that branch, so this targets it rather than `dev`. --- ## Summary by cubic Ship a single `hexclave` bin for `@hexclave/cli` so `pnpx`/`pnpm dlx` resolve the executable reliably. The CLI now uses `hexclave` as the canonical command, including self-update re-execs. - **Bug Fixes** - Dropped the `stack` bin; kept only `hexclave`, fixing ERR_PNPM_DLX_MULTIPLE_BINS when running `pnpx @hexclave/cli `. - `resolveBinName` now prefers `hexclave`; program name, help, and error messages updated; tests adjusted. - **Migration** - The `stack` command is removed. Use `hexclave `. Older installs that re-exec `stack` may fail; run `npx -p @hexclave/cli@latest hexclave ...` or reinstall. Written for commit bf5d6ac7277948d286f3d8a8ad94d669c704d145. Summary will update on new commits. Review in cubic --- packages/stack-cli/package.json | 3 +-- packages/stack-cli/src/commands/config-file.ts | 4 ++-- packages/stack-cli/src/commands/dev.ts | 4 ++-- packages/stack-cli/src/commands/doctor.ts | 2 +- packages/stack-cli/src/commands/exec.ts | 4 ++-- packages/stack-cli/src/commands/init.ts | 6 +++--- packages/stack-cli/src/commands/project.ts | 2 +- packages/stack-cli/src/index.ts | 2 +- packages/stack-cli/src/lib/auth.ts | 2 +- packages/stack-cli/src/lib/dev-env-state.ts | 2 +- packages/stack-cli/src/lib/local-emulator-client.ts | 2 +- packages/stack-cli/src/lib/own-package.test.ts | 8 ++++---- packages/stack-cli/src/lib/own-package.ts | 8 ++++---- packages/stack-cli/src/lib/self-update.ts | 6 +++--- 14 files changed, 27 insertions(+), 28 deletions(-) diff --git a/packages/stack-cli/package.json b/packages/stack-cli/package.json index d5b3e3880..46b3c9faf 100644 --- a/packages/stack-cli/package.json +++ b/packages/stack-cli/package.json @@ -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", diff --git a/packages/stack-cli/src/commands/config-file.ts b/packages/stack-cli/src/commands/config-file.ts index 5c3ed737a..fcdb34d49 100644 --- a/packages/stack-cli/src/commands/config-file.ts +++ b/packages/stack-cli/src/commands/config-file.ts @@ -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, { diff --git a/packages/stack-cli/src/commands/dev.ts b/packages/stack-cli/src/commands/dev.ts index 7084e09ef..62b4f44a6 100644 --- a/packages/stack-cli/src/commands/dev.ts +++ b/packages/stack-cli/src/commands/dev.ts @@ -78,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 -- [args...]"); + throw new CliError("Missing command. Usage: hexclave dev --config-file -- [args...]"); } const command = commandArgs[0]; return { command, args: commandArgs.slice(1) }; @@ -185,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(" ")); } } diff --git a/packages/stack-cli/src/commands/doctor.ts b/packages/stack-cli/src/commands/doctor.ts index 21020b05b..871b20a42 100644 --- a/packages/stack-cli/src/commands/doctor.ts +++ b/packages/stack-cli/src/commands/doctor.ts @@ -552,7 +552,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}`); } } diff --git a/packages/stack-cli/src/commands/exec.ts b/packages/stack-cli/src/commands/exec.ts index 0ce8cd5f9..0ddc687ba 100644 --- a/packages/stack-cli/src/commands/exec.ts +++ b/packages/stack-cli/src/commands/exec.ts @@ -56,7 +56,7 @@ export function registerExecCommand(program: Command) { .addHelpText("after", "\nFor available API methods, see: https://docs.hexclave.com/docs/sdk") .action(async (javascript: string | undefined, opts: ExecTargetOpts) => { if (javascript === undefined) { - throw new CliError("Missing JavaScript argument. Use `stack exec \"\"` or `stack exec --help`."); + throw new CliError("Missing JavaScript argument. Use `hexclave exec \"\"` 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 { diff --git a/packages/stack-cli/src/commands/init.ts b/packages/stack-cli/src/commands/init.ts index af39b0646..f6b214a54 100644 --- a/packages/stack-cli/src/commands/init.ts +++ b/packages/stack-cli/src/commands/init.ts @@ -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, 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 ` first."); + throw new CliError("No projects found. Run `hexclave project create --display-name ` first."); } const shouldCreate = await confirm({ diff --git a/packages/stack-cli/src/commands/project.ts b/packages/stack-cli/src/commands/project.ts index b6d0e0adb..841597115 100644 --- a/packages/stack-cli/src/commands/project.ts +++ b/packages/stack-cli/src/commands/project.ts @@ -93,7 +93,7 @@ export function registerProjectCommand(program: Command) { .option("--display-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); diff --git a/packages/stack-cli/src/index.ts b/packages/stack-cli/src/index.ts index e90ba3860..23ee7aad8 100644 --- a/packages/stack-cli/src/index.ts +++ b/packages/stack-cli/src/index.ts @@ -20,7 +20,7 @@ import { registerWhoamiCommand } from "./commands/whoami.js"; 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(cliVersion() ?? "0.0.0") .option("--json", "Output in JSON format"); diff --git a/packages/stack-cli/src/lib/auth.ts b/packages/stack-cli/src/lib/auth.ts index 139bc43f8..a10da5bc6 100644 --- a/packages/stack-cli/src/lib/auth.ts +++ b/packages/stack-cli/src/lib/auth.ts @@ -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; } diff --git a/packages/stack-cli/src/lib/dev-env-state.ts b/packages/stack-cli/src/lib/dev-env-state.ts index 9a6eac4ba..9fa859174 100644 --- a/packages/stack-cli/src/lib/dev-env-state.ts +++ b/packages/stack-cli/src/lib/dev-env-state.ts @@ -61,7 +61,7 @@ function isCliUpdateCheckCache(value: unknown): value is CliUpdateCheckCache { // 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 `stack dev`. Malformed entries are dropped on +// 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; diff --git a/packages/stack-cli/src/lib/local-emulator-client.ts b/packages/stack-cli/src/lib/local-emulator-client.ts index 57b408f7c..801b1807b 100644 --- a/packages/stack-cli/src/lib/local-emulator-client.ts +++ b/packages/stack-cli/src/lib/local-emulator-client.ts @@ -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; } diff --git a/packages/stack-cli/src/lib/own-package.test.ts b/packages/stack-cli/src/lib/own-package.test.ts index 30cd5cc23..0f96c1be0 100644 --- a/packages/stack-cli/src/lib/own-package.test.ts +++ b/packages/stack-cli/src/lib/own-package.test.ts @@ -2,12 +2,12 @@ import { describe, expect, it } from "vitest"; import { parseOwnPackage, resolveBinName } from "./own-package.js"; describe("resolveBinName", () => { - it("prefers the `stack` bin when present (stable alias across versions)", () => { - expect(resolveBinName({ stack: "./d.js", hexclave: "./d.js" }, "@hexclave/cli")).toBe("stack"); + 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 `stack`", () => { - expect(resolveBinName({ 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", () => { diff --git a/packages/stack-cli/src/lib/own-package.ts b/packages/stack-cli/src/lib/own-package.ts index 4f262a9be..0e9b38fff 100644 --- a/packages/stack-cli/src/lib/own-package.ts +++ b/packages/stack-cli/src/lib/own-package.ts @@ -12,14 +12,14 @@ 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 `stack` bin: it -// exists today and is kept as an alias after the hexclave rename, so it's the -// one bin name guaranteed across versions. A string `bin` (or none) maps to the +// 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("stack")) return "stack"; + if (keys.includes("hexclave")) return "hexclave"; if (keys.length > 0) return keys[0]; } return unscopedName(packageName); diff --git a/packages/stack-cli/src/lib/self-update.ts b/packages/stack-cli/src/lib/self-update.ts index aca6cc160..9f5ea1446 100644 --- a/packages/stack-cli/src/lib/self-update.ts +++ b/packages/stack-cli/src/lib/self-update.ts @@ -121,7 +121,7 @@ async function fetchLatestVersion(packageName: string, timeoutMs: number): Promi } // Resolves the latest published version, memoizing the result in the dev-env -// state file for `ttlMs` so back-to-back `stack dev` runs don't hammer the +// state file for `ttlMs` so back-to-back `hexclave dev` runs don't hammer the // registry. Returns null when the registry can't be reached (offline, // timeout) so callers fall back to the installed CLI. export async function resolveLatestVersion( @@ -165,7 +165,7 @@ export function buildNpxInvocation(opts: { // Override any global npm "cooldown" for this call only — we always want // the just-published latest, and npx of a pinned version newer than the // cooldown window otherwise fails with ETARGET (which would kill - // `stack dev`). npm's config is `min-release-age` (days, npm >=11.10.0); + // `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", @@ -233,7 +233,7 @@ function runReexec(invocation: NpxInvocation): Promise { 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 `stack dev`. + // installed CLI instead of failing the whole `hexclave dev`. child.on("error", (err) => { cleanup(); resolvePromise({ exited: false, error: err.message });