mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
fix(cli): ship a single hexclave bin so pnpx/pnpm dlx can resolve it (#1533)
## Problem
`pnpx @hexclave/cli <cmd>` (i.e. `pnpm dlx`) fails, even though `npx
@hexclave/cli <cmd>` 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 <cmd>` guidance strings (in error messages / tips
across `auth`, `init`, `dev`, `config-file`, `exec`, `project`,
`doctor`, `local-emulator-client`) → `hexclave <cmd>`, 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`.
<!-- This is an auto-generated description by cubic. -->
---
## 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 <cmd>`.
- `resolveBinName` now prefers `hexclave`; program name, help, and error
messages updated; tests adjusted.
- **Migration**
- The `stack` command is removed. Use `hexclave <cmd>`. Older installs
that re-exec `stack` may fail; run `npx -p @hexclave/cli@latest hexclave
...` or reinstall.
<sup>Written for commit bf5d6ac727.
Summary will update on new commits.</sup>
<a
href="https://cubic.dev/pr/hexclave/hexclave/pull/1533?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
parent
97a41d545a
commit
b0181ea195
@ -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",
|
||||
|
||||
@ -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, {
|
||||
|
||||
@ -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 <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) };
|
||||
@ -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(" "));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 \"<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 {
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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<string, unknown>);
|
||||
if (keys.includes("stack")) return "stack";
|
||||
if (keys.includes("hexclave")) return "hexclave";
|
||||
if (keys.length > 0) return keys[0];
|
||||
}
|
||||
return unscopedName(packageName);
|
||||
|
||||
@ -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<ReexecResult> {
|
||||
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 });
|
||||
|
||||
Loading…
Reference in New Issue
Block a user