Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Changed

- **`npx @ably/cli <command>` now works without a redundant `ably` token.** The package now exposes a single `bin` (`ably`), so `npm exec`/`npx` can resolve the executable deterministically. Previously the package declared two bins pointing at different targets, which tripped npm's bin resolver and forced the verbose `npx -p @ably/cli ably <command>`. For backwards compatibility, a redundant leading `ably` token (`npx @ably/cli ably <command>`) is still accepted and stripped. Globally installed usage (`npm install -g @ably/cli`; then `ably ...`) is unchanged. The `bin/ably-interactive` auto-restart wrapper script is retained in the package but is no longer installed as a separate global executable.

## [1.2.0] - 2026-06-04

### Added
Expand Down
11 changes: 11 additions & 0 deletions bin/run.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
#!/usr/bin/env node

// Backwards-compatible invocation: `npx @ably/cli ably <command>` passes a
// redundant leading `ably` token through to the CLI (the package is `@ably/cli`
// and the bin is `ably`, so people naturally repeat it). Now that
// `npx @ably/cli <command>` resolves the single `ably` bin directly, tolerate a
// leading `ably` so old docs, scripts and muscle memory keep working. There is
// no top-level `ably` command, so a leading `ably` is unambiguously the
// redundant binary name and is safe to drop.
if (process.argv[2] === 'ably') {
process.argv.splice(2, 1);
}

// For interactive mode, ensure SIGINT exits with code 130
if (process.argv.includes('interactive')) {
process.env.ABLY_INTERACTIVE_MODE = 'true';
Expand Down
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,7 @@
"author": "Ably <support@ably.com>",
"license": "Apache-2.0",
"bin": {
"ably": "./bin/run.js",
"ably-interactive": "./bin/ably-interactive"
"ably": "./bin/run.js"
},
"type": "module",
"oclif": {
Expand Down
52 changes: 52 additions & 0 deletions test/integration/commands/npx-backwards-compat.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, it, expect } from "vitest";
import { spawn } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const binPath = path.join(__dirname, "..", "..", "..", "bin", "run.js");

function run(
args: string[],
): Promise<{ code: number | null; stdout: string; stderr: string }> {
return new Promise((resolve) => {
const proc = spawn("node", [binPath, ...args], { env: { ...process.env } });
let stdout = "";
let stderr = "";
proc.stdout.on("data", (d) => (stdout += d.toString()));
proc.stderr.on("data", (d) => (stderr += d.toString()));
proc.on("close", (code) => resolve({ code, stdout, stderr }));
});
}

/**
* `npx @ably/cli ably <command>` was historically the way to run the CLI (the
* package is `@ably/cli`, the bin is `ably`, so the token gets repeated). Now
* that the package is single-bin and `npx @ably/cli <command>` resolves
* directly, run.js drops a redundant leading `ably` so the old form keeps
* working. These tests guard that backwards-compatible behaviour.
*
* `version` is used because it runs fully offline (no API key required).
*/
describe("npx @ably/cli ably <command> backwards compatibility", () => {
it("runs a command with a redundant leading `ably` token", async () => {
const redundant = await run(["ably", "version"]);
expect(redundant.code).toBe(0);
expect(redundant.stdout).toContain("@ably/cli/");
expect(redundant.stderr).not.toMatch(/not found/i);
});

it("behaves identically to the plain invocation", async () => {
const [plain, redundant] = await Promise.all([
run(["version"]),
run(["ably", "version"]),
]);
expect(redundant.stdout.trim()).toBe(plain.stdout.trim());
});

it("strips only a single leading `ably` (there is no `ably` command)", async () => {
const doubled = await run(["ably", "ably", "version"]);
expect(doubled.code).not.toBe(0);
expect(doubled.stderr).toMatch(/not found/i);
});
});
70 changes: 70 additions & 0 deletions test/unit/package/npx-bin-resolution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const pkg = JSON.parse(
readFileSync(path.join(__dirname, "../../../package.json"), "utf8"),
) as { name: string; bin: Record<string, string> };

/**
* Faithful re-implementation of npm's bin resolver, used by `npx`/`npm exec`
* to decide which executable to run for a bare `npx <pkg>` invocation.
*
* Source of truth (kept in sync intentionally — this is a small, stable algo):
* node_modules/libnpmexec/lib/get-bin-from-manifest.js
*
* 1. If every bin entry points at the SAME target, run the first one.
* 2. Else if a bin is named after the unscoped package name, run that.
* 3. Else throw "could not determine executable to run".
*
* This is why `npx @ably/cli <command>` must "just work" with no redundant
* `ably` token: the package has to satisfy rule 1 or rule 2. A second bin that
* points at a DIFFERENT target (e.g. a separate `ably-interactive` wrapper)
* trips rule 3 and forces users into `npx -p @ably/cli ably <command>`.
*/
function getBinFromManifest(mani: {
name: string;
bin?: Record<string, string>;
}): string {
const bin = mani.bin ?? {};
if (new Set(Object.values(bin)).size === 1) {
return Object.keys(bin)[0];
}
const unscoped = mani.name.replace(/^@[^/]+\//, "");
if (bin[unscoped]) {
return unscoped;
}
throw new Error("could not determine executable to run");
}

describe("npx @ably/cli bin resolution", () => {
it("resolves to a single, deterministic executable (no redundant token)", () => {
// If this throws, `npx @ably/cli <command>` breaks with
// "could not determine executable to run" and users are forced back to
// `npx -p @ably/cli ably <command>`.
expect(() => getBinFromManifest(pkg)).not.toThrow();
expect(getBinFromManifest(pkg)).toBe("ably");
});

it("the resolved bin is the main oclif entrypoint", () => {
const binName = getBinFromManifest(pkg);
expect(pkg.bin[binName]).toBe("./bin/run.js");
});

// Documents the failure mode the single-bin shape protects against, so a
// future change that re-introduces a second, differently-targeted bin fails
// loudly here rather than silently regressing the npx experience.
it("regression guard: a second bin with a different target breaks npx", () => {
expect(() =>
getBinFromManifest({
name: "@ably/cli",
bin: {
ably: "./bin/run.js",
"ably-interactive": "./bin/ably-interactive",
},
}),
).toThrow(/could not determine executable to run/);
});
});
Loading