From 66aef467e6f910a6478852272b6d6c038c7972af Mon Sep 17 00:00:00 2001 From: Matthew O'Riordan Date: Wed, 17 Jun 2026 22:26:11 +0200 Subject: [PATCH 1/6] fix: make `npx @ably/cli ` work without a redundant `ably` token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `npx @ably/cli init` (and any other command) failed with "could not determine executable to run", forcing users into the verbose `npx -p @ably/cli ably init`. People expect a single command with npx; the fact that we couldn't do that was a packaging smell. Root cause: the package declared two `bin` entries pointing at different targets (`ably` -> ./bin/run.js, `ably-interactive` -> ./bin/ably-interactive). npm's bin resolver (libnpmexec/get-bin-from-manifest) only auto-selects an executable when either every bin points at the same target, or a bin is named after the unscoped package name (`cli`). Two differently-targeted bins, neither named `cli`, hit the "could not determine executable to run" branch. Fix: expose a single `bin` (`ably`) — the same shape every other scoped CLI uses (`@angular/cli` -> ng, `@vue/cli` -> vue). `npx @ably/cli ` now resolves deterministically. Verified against npm's own resolver (old manifest throws the exact error; new manifest returns `ably`) and end-to-end via `npx --version`. Backwards compatible: `npx -p @ably/cli ably ` is unaffected, and run.js now drops a redundant leading `ably` token so the historical `npx @ably/cli ably ` form keeps working too. There is no top-level `ably` command, so a leading `ably` is unambiguously the repeated bin name. Global installs (`npm install -g @ably/cli` then `ably ...`) are unchanged. The `bin/ably-interactive` auto-restart wrapper script is retained in the package (still used by `ably interactive` and its tests) but is no longer installed as a separate global executable. The one runtime consumer of that binary, ably/cli-terminal-server, installs `@ably/cli@latest`, so its image build needs a companion one-line Dockerfile symlink landed alongside this change. Tests: a hermetic unit test re-implements npm's resolver and asserts the package always resolves to a single `ably` executable; an integration test covers the redundant-`ably` backwards-compatible invocation. Co-Authored-By: Claude Opus 4.8 --- bin/run.js | 11 +++ package.json | 3 +- .../commands/npx-backwards-compat.test.ts | 52 ++++++++++++++ test/unit/package/npx-bin-resolution.test.ts | 70 +++++++++++++++++++ 4 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 test/integration/commands/npx-backwards-compat.test.ts create mode 100644 test/unit/package/npx-bin-resolution.test.ts diff --git a/bin/run.js b/bin/run.js index 71f5a5e5b..ba9ce9e8a 100755 --- a/bin/run.js +++ b/bin/run.js @@ -1,5 +1,16 @@ #!/usr/bin/env node +// Backwards-compatible invocation: `npx @ably/cli ably ` 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 ` 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'; diff --git a/package.json b/package.json index 83d7c4756..4b2b7fe86 100644 --- a/package.json +++ b/package.json @@ -62,8 +62,7 @@ "author": "Ably ", "license": "Apache-2.0", "bin": { - "ably": "./bin/run.js", - "ably-interactive": "./bin/ably-interactive" + "ably": "./bin/run.js" }, "type": "module", "oclif": { diff --git a/test/integration/commands/npx-backwards-compat.test.ts b/test/integration/commands/npx-backwards-compat.test.ts new file mode 100644 index 000000000..e4b41ea60 --- /dev/null +++ b/test/integration/commands/npx-backwards-compat.test.ts @@ -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 ` 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 ` 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 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); + }); +}); diff --git a/test/unit/package/npx-bin-resolution.test.ts b/test/unit/package/npx-bin-resolution.test.ts new file mode 100644 index 000000000..5172d778d --- /dev/null +++ b/test/unit/package/npx-bin-resolution.test.ts @@ -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 }; + +/** + * Faithful re-implementation of npm's bin resolver, used by `npx`/`npm exec` + * to decide which executable to run for a bare `npx ` 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 ` 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 `. + */ +function getBinFromManifest(mani: { + name: string; + bin?: Record; +}): 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 ` breaks with + // "could not determine executable to run" and users are forced back to + // `npx -p @ably/cli ably `. + 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/); + }); +}); From 210a0e69ba3dbb156211ecf8c02d3443a3cb20d3 Mon Sep 17 00:00:00 2001 From: umair Date: Tue, 23 Jun 2026 10:58:33 +0100 Subject: [PATCH 2/6] test: ensure node-pty spawn-helper is executable for TTY tests pnpm strips the execute bit from node-pty's prebuilt spawn-helper, so `pnpm test:tty` fails with 'posix_spawnp failed'. Add a pretest:tty hook that restores it (no-op on Windows / when already executable). Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 1 + scripts/ensure-node-pty-executable.mjs | 51 +++++++ .../interactive-integration.test.ts | 138 ++++++++++++++++++ 3 files changed, 190 insertions(+) create mode 100644 scripts/ensure-node-pty-executable.mjs create mode 100644 test/e2e/interactive/interactive-integration.test.ts diff --git a/package.json b/package.json index 4b2b7fe86..d3cbd91a8 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "test:e2e:control": "vitest run --project e2e test/e2e/control", "test:e2e:bench": "vitest run --project e2e test/e2e/bench", "test:hooks": "vitest run --project hooks", + "pretest:tty": "node scripts/ensure-node-pty-executable.mjs", "test:tty": "VITEST_TTY=1 vitest run --project tty", "test:web-cli": "pnpm test:web-cli:build && playwright test --config test/e2e/web-cli/playwright.config.ts", "test:web-cli:build": "pnpm build && pnpm --filter @ably/react-web-cli build && pnpm --filter ./examples/web-cli build", diff --git a/scripts/ensure-node-pty-executable.mjs b/scripts/ensure-node-pty-executable.mjs new file mode 100644 index 000000000..ac6b60b79 --- /dev/null +++ b/scripts/ensure-node-pty-executable.mjs @@ -0,0 +1,51 @@ +// Ensure node-pty's prebuilt `spawn-helper` is executable. +// +// node-pty ships prebuilt binaries under `prebuilds/-/` and at +// runtime execs `/spawn-helper`. pnpm does not preserve the +// execute bit when it extracts the package into its store, and node-pty's own +// post-install only fixes files under `build/Release/` (which prebuild installs +// don't create). The result is `posix_spawnp failed` whenever the TTY tests try +// to spawn a pty. This restores the execute bit. Best-effort: never fails. +// +// Runs as the `pretest:tty` hook (and is safe to run anytime / on any platform). + +import { createRequire } from "node:module"; +import { chmodSync, existsSync, readdirSync, statSync } from "node:fs"; +import { dirname, join } from "node:path"; +import process from "node:process"; + +// Windows uses conpty, not spawn-helper — nothing to do. +if (process.platform === "win32") { + process.exit(0); +} + +try { + const require = createRequire(import.meta.url); + const packageJson = require.resolve("node-pty/package.json"); + const prebuilds = join(dirname(packageJson), "prebuilds"); + + if (!existsSync(prebuilds)) { + process.exit(0); + } + + let fixed = 0; + for (const entry of readdirSync(prebuilds)) { + const helper = join(prebuilds, entry, "spawn-helper"); + if (!existsSync(helper)) continue; + const { mode } = statSync(helper); + // already has any execute bit? + if (mode & 0o111) continue; + chmodSync(helper, 0o755); + fixed++; + console.log(`[ensure-node-pty-executable] chmod +x ${helper}`); + } + if (fixed === 0) { + console.log("[ensure-node-pty-executable] spawn-helper already executable"); + } +} catch (error) { + // Best effort — don't break installs/tests if node-pty isn't present or the + // layout changes in a future version. + console.warn( + `[ensure-node-pty-executable] skipped: ${error instanceof Error ? error.message : String(error)}`, + ); +} diff --git a/test/e2e/interactive/interactive-integration.test.ts b/test/e2e/interactive/interactive-integration.test.ts new file mode 100644 index 000000000..a964034fc --- /dev/null +++ b/test/e2e/interactive/interactive-integration.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect } from "vitest"; +import { spawn } from "node:child_process"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +/** + * Integration tests for `ably interactive` running as a single process (no bash + * wrapper — the `ably-interactive` wrapper binary has been removed). + * + * These run over piped stdio (non-TTY). Ctrl+C delivered as a real SIGINT signal + * is handled in-process: the running command unwinds and the shell returns to the + * prompt in the SAME process — there is no restart. Real-terminal (TTY) SIGINT + * behaviour and terminal-state/EIO assertions live in + * test/tty/commands/interactive-sigint.test.ts (run with `pnpm test:tty`). + */ +describe("Interactive Mode - in-process integration", () => { + const binPath = path.join(__dirname, "../../../bin/run.js"); + + it("starts and exits cleanly via `exit`", { timeout: 30000 }, async () => { + const proc = spawn("node", [binPath, "interactive"], { + stdio: "pipe", + env: { ...process.env, ABLY_SUPPRESS_WELCOME: "1" }, + }); + + let output = ""; + proc.stdout.on("data", (d) => (output += d.toString())); + + await new Promise((resolve) => { + const check = setInterval(() => { + if (output.includes("ably>")) { + clearInterval(check); + resolve(); + } + }, 100); + }); + + proc.stdin.write("exit\n"); + + const exitCode = await new Promise((resolve) => { + proc.on("exit", (code) => resolve(code ?? 0)); + }); + + expect(exitCode).toBe(0); + expect(output).toContain("Goodbye!"); + }); + + it( + "interrupts a running command via SIGINT and stays alive in the same process", + { timeout: 30000 }, + async () => { + const proc = spawn("node", [binPath, "interactive"], { + stdio: "pipe", + env: { ...process.env, ABLY_SUPPRESS_WELCOME: "1" }, + }); + + let output = ""; + let exited = false; + proc.stdout.on("data", (d) => (output += d.toString())); + proc.stderr.on("data", (d) => (output += d.toString())); + proc.on("exit", () => (exited = true)); + + const waitFor = (substr: string, timeoutMs: number) => + new Promise((resolve, reject) => { + const start = Date.now(); + const check = setInterval(() => { + if (output.includes(substr)) { + clearInterval(check); + resolve(); + } else if (exited) { + clearInterval(check); + reject(new Error(`process exited before "${substr}"`)); + } else if (Date.now() - start > timeoutMs) { + clearInterval(check); + reject(new Error(`timeout waiting for "${substr}"`)); + } + }, 100); + }); + + await waitFor("ably>", 8000); + + // Start a long-running command, then interrupt it with a real SIGINT. + proc.stdin.write("test:wait --duration 30\n"); + await waitFor("Waiting for", 8000); + proc.kill("SIGINT"); + + // The process must NOT exit; it should re-prompt and still run commands. + await new Promise((r) => setTimeout(r, 500)); + expect(exited).toBe(false); + + proc.stdin.write("version\n"); + await waitFor("Version:", 6000); + + proc.stdin.write("exit\n"); + await new Promise((resolve) => proc.on("exit", () => resolve())); + + expect(output).not.toMatch(/setRawMode EIO|Terminal state corrupted/); + }, + ); + + it( + "emits exit code 42 on `exit` under ABLY_WRAPPER_MODE (host restart-loop contract)", + { timeout: 30000 }, + async () => { + const proc = spawn("node", [binPath, "interactive"], { + stdio: "pipe", + env: { + ...process.env, + ABLY_SUPPRESS_WELCOME: "1", + ABLY_WRAPPER_MODE: "1", + }, + }); + + let output = ""; + proc.stdout.on("data", (d) => (output += d.toString())); + + await new Promise((resolve) => { + const check = setInterval(() => { + if (output.includes("ably>")) { + clearInterval(check); + resolve(); + } + }, 100); + }); + + proc.stdin.write("exit\n"); + + const exitCode = await new Promise((resolve) => { + proc.on("exit", (code) => resolve(code ?? 0)); + }); + + // 42 tells a host restart loop the user deliberately quit (vs. a crash). + expect(exitCode).toBe(42); + }, + ); +}); From f292f3acde2b3307118208858f57e97c11d5c140 Mon Sep 17 00:00:00 2001 From: umair Date: Tue, 23 Jun 2026 11:14:48 +0100 Subject: [PATCH 3/6] refactor(interactive): retire ably-interactive wrapper; handle Ctrl+C in-process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ably-interactive bin was a bash wrapper whose only job was to relaunch the CLI after a Ctrl+C force-quit. `ably interactive` already handles Ctrl+C in-process (single Ctrl+C interrupts the running command and returns to the prompt), so the wrapper is redundant — and removing it is what lets the package expose a single `ably` bin (fixing npx). - Delete bin/ably-interactive and the never-wired-up bin/ably-interactive.ps1 - Fix the welcome hint: single Ctrl+C interrupts and returns to the prompt; it no longer (falsely) warns that Ctrl+C exits the shell or points at a wrapper - Non-TTY Ctrl+C now delivers an in-process SIGINT instead of process.exit(130) (there is no wrapper to restart the process) Hosts that want the shell to survive a double-Ctrl+C force-quit (e.g. the terminal server) wrap `ably interactive` in their own restart loop, keyed off exit codes 42/130 with ABLY_WRAPPER_MODE=1 (contract preserved). Co-Authored-By: Claude Opus 4.8 (1M context) --- bin/ably-interactive | 78 ------------------------------------- bin/ably-interactive.ps1 | 75 ----------------------------------- src/commands/interactive.ts | 30 +++++++------- 3 files changed, 17 insertions(+), 166 deletions(-) delete mode 100755 bin/ably-interactive delete mode 100644 bin/ably-interactive.ps1 diff --git a/bin/ably-interactive b/bin/ably-interactive deleted file mode 100755 index 95f60f503..000000000 --- a/bin/ably-interactive +++ /dev/null @@ -1,78 +0,0 @@ -#!/bin/bash - -# Ably Interactive Shell Wrapper -# This script provides seamless Ctrl+C handling by automatically -# restarting the CLI when it exits due to SIGINT - -# Configuration -SOURCE="${BASH_SOURCE[0]}" -while [ -L "$SOURCE" ]; do - DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)" - SOURCE="$(readlink "$SOURCE")" - [[ $SOURCE != /* ]] && SOURCE="$DIR/$SOURCE" -done -SCRIPT_DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)" -ABLY_BIN="$SCRIPT_DIR/run.js" -ABLY_CONFIG_DIR="$HOME/.ably" -HISTORY_FILE="$ABLY_CONFIG_DIR/history" -EXIT_CODE_USER_EXIT=42 -WELCOME_SHOWN=0 - -# Ensure we have a valid Node.js binary -if ! command -v node &> /dev/null; then - echo "Error: Node.js is required but not found in PATH" >&2 - exit 1 -fi - -# Create config directory if it doesn't exist -mkdir -p "$ABLY_CONFIG_DIR" 2>/dev/null || true - -# Initialize history file -touch "$HISTORY_FILE" 2>/dev/null || true - -# Since we're running in foreground, no need for signal forwarding -# The signals will be sent directly to the node process - -# Main loop -while true; do - # Run the CLI in foreground - env ABLY_HISTORY_FILE="$HISTORY_FILE" \ - ABLY_WRAPPER_MODE=1 \ - ${ABLY_SUPPRESS_WELCOME:+ABLY_SUPPRESS_WELCOME=1} \ - node "$ABLY_BIN" interactive - - EXIT_CODE=$? - - # Mark welcome as shown after first run - WELCOME_SHOWN=1 - export ABLY_SUPPRESS_WELCOME=1 - - # Check exit code - case $EXIT_CODE in - $EXIT_CODE_USER_EXIT) - # User typed 'exit' - break the loop - break - ;; - 130) - # SIGINT (Ctrl+C) - ensure we're on a new line - echo "" - ;; - 0) - # Normal exit (shouldn't happen in interactive mode) - # But if it does, exit gracefully - break - ;; - *) - # Other error - show message and restart - echo -e "\033[31m\nProcess exited unexpectedly (code: $EXIT_CODE)\033[0m" - sleep 0.5 - ;; - esac -done - -# Exit with the appropriate code -if [ $EXIT_CODE -eq $EXIT_CODE_USER_EXIT ]; then - exit 0 -else - exit $EXIT_CODE -fi \ No newline at end of file diff --git a/bin/ably-interactive.ps1 b/bin/ably-interactive.ps1 deleted file mode 100644 index 8dad04a56..000000000 --- a/bin/ably-interactive.ps1 +++ /dev/null @@ -1,75 +0,0 @@ -# Ably Interactive Shell Wrapper for Windows -# This script provides seamless Ctrl+C handling by automatically -# restarting the CLI when it exits due to interruption - -$ErrorActionPreference = "Stop" - -# Configuration -$ScriptDir = Split-Path -Parent (Resolve-Path $MyInvocation.MyCommand.Path).Path -$AblyBin = Join-Path $ScriptDir "run.cmd" -$AblyConfigDir = Join-Path $env:USERPROFILE ".ably" -$HistoryFile = Join-Path $AblyConfigDir "history" -$ExitCodeUserExit = 42 -$WelcomeShown = $false - -# Check if Node.js is available -try { - $null = Get-Command node -ErrorAction Stop -} catch { - Write-Host "Error: Node.js is required but not found in PATH" -ForegroundColor Red - exit 1 -} - -# Create config directory if it doesn't exist -if (!(Test-Path $AblyConfigDir)) { - New-Item -ItemType Directory -Path $AblyConfigDir -Force | Out-Null -} - -# Initialize history file -if (!(Test-Path $HistoryFile)) { - New-Item -ItemType File -Path $HistoryFile -Force | Out-Null -} - -# Main loop -while ($true) { - # Set environment variables - $env:ABLY_HISTORY_FILE = $HistoryFile - $env:ABLY_WRAPPER_MODE = "1" - - if ($WelcomeShown) { - $env:ABLY_SUPPRESS_WELCOME = "1" - } - - # Run the CLI - & cmd /c $AblyBin interactive - $ExitCode = $LASTEXITCODE - - # Mark welcome as shown after first run - $WelcomeShown = $true - - # Check exit code - switch ($ExitCode) { - $ExitCodeUserExit { - # User typed 'exit' - break the loop - break - } - 130 { - # SIGINT (Ctrl+C) equivalent - continue loop silently - # The new prompt will appear automatically - continue - } - 0 { - # Normal exit (shouldn't happen in interactive mode) - # But if it does, exit gracefully - break - } - default { - # Other error - show message and restart - Write-Host "`nProcess exited unexpectedly (code: $ExitCode)" -ForegroundColor Red - Start-Sleep -Milliseconds 500 - } - } -} - -# Exit message is handled by the CLI itself -exit 0 \ No newline at end of file diff --git a/src/commands/interactive.ts b/src/commands/interactive.ts index 4d1b89a93..81b183d91 100644 --- a/src/commands/interactive.ts +++ b/src/commands/interactive.ts @@ -75,12 +75,19 @@ export default class Interactive extends Command { // Install a data handler on stdin to detect Ctrl+C const handleStdinData = (data: Buffer) => { if (data.includes(0x03)) { - // Ctrl+C byte + // Non-TTY readline doesn't synthesise SIGINT from a 0x03 (Ctrl+C) + // byte, so we do it. This only fires while stdin is flowing: at the + // prompt, or while a command is itself reading stdin (e.g. a y/n + // confirmation). For an ordinary long-running command the outer + // readline keeps stdin paused, so the byte is buffered and never + // reaches here — those are interrupted by a real OS SIGINT, which is + // what a TTY and the node-pty web CLI deliver. if (this.runningCommand) { - // Exit immediately with 130 during command execution - process.exit(130); + // A command is reading stdin (e.g. a prompt): SIGINT ourselves so + // its own handlers unwind and we return to the prompt. + process.kill(process.pid, "SIGINT"); } else { - // Emit SIGINT event to readline + // At the prompt: let readline show ^C and a fresh prompt. this.rl?.emit("SIGINT"); } } @@ -98,7 +105,8 @@ export default class Interactive extends Command { // Don't install any signal handlers at the process level // When SIGINT is received: // - If at prompt: readline handles it (shows ^C and new prompt) - // - If running command: process exits with 130, wrapper restarts + // - If running command: the command's own SIGINT handling unwinds and we + // return to the prompt in-process (a double Ctrl+C still force-quits) // Set environment variable to indicate we're in interactive mode process.env.ABLY_INTERACTIVE_MODE = "true"; @@ -147,16 +155,12 @@ export default class Interactive extends Command { console.log(chalk.bold(tagline)); console.log(); - // Warn if running without wrapper + // Ctrl+C guidance. A single Ctrl+C interrupts a running command and + // returns to the prompt; type `exit` (or Ctrl+D) to leave the shell. if (!this.isWrapperMode && !this.isWebCliMode()) { console.log( - chalk.yellow( - "⚠️ Running without the wrapper script. Ctrl+C will exit the shell.", - ), - ); - console.log( - chalk.yellow( - " For better experience with automatic restart after Ctrl+C, use: ably-interactive\n", + chalk.dim( + "Press Ctrl+C to interrupt a running command. Type 'exit' to quit.\n", ), ); } From 6854d67e1bed9be3af66bcd203f3af2831b7f485 Mon Sep 17 00:00:00 2001 From: umair Date: Tue, 23 Jun 2026 11:15:00 +0100 Subject: [PATCH 4/6] docs: stop referencing the removed ably-interactive wrapper Sweep user-facing and architecture references now that the wrapper binary is gone and ably interactive handles Ctrl+C in-process: - env-vars: ABLY_HISTORY_FILE example uses `ably interactive`; reword the note (HistoryManager already defaults to ~/.ably/history); update coupled unit test - Troubleshooting/Debugging: accurate Ctrl+C guidance; no wrapper run-commands - Exit-Codes: reframe the exit-code consumer as a host session restart loop - Interactive-REPL: status note marking the bash-wrapper design as superseded - Project-Structure: drop the deleted bin/ably-interactive(.ps1) tree entries - manual tests: run `node bin/run.js interactive`; keep the exit-42 contract Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/Debugging.md | 2 +- docs/Exit-Codes.md | 21 +- docs/Interactive-REPL.md | 403 ++---------------------- docs/Project-Structure.md | 4 +- docs/Troubleshooting.md | 9 +- src/data/env-vars.ts | 6 +- test/manual/interactive-mode-test.md | 191 +++++------ test/manual/manual-test-instructions.md | 16 +- test/unit/utils/env-vars-render.test.ts | 6 +- 9 files changed, 141 insertions(+), 517 deletions(-) diff --git a/docs/Debugging.md b/docs/Debugging.md index 06b879a94..01af68fe5 100644 --- a/docs/Debugging.md +++ b/docs/Debugging.md @@ -57,7 +57,7 @@ Refer to [Testing.md](Testing.md) for how to run specific tests. ``` * **Terminal Diagnostics:** Enable terminal state logging for TTY/stdin/stdout issues: ```bash - TERMINAL_DIAGNOSTICS=1 ably-interactive + TERMINAL_DIAGNOSTICS=1 ably interactive ``` * **Check Configuration:** Use `ably config show` to view stored credentials or `ably config path` to find the config file location. * **Override Configuration:** Use environment variables to override config for testing: diff --git a/docs/Exit-Codes.md b/docs/Exit-Codes.md index 7f892f5a9..6a10290c0 100644 --- a/docs/Exit-Codes.md +++ b/docs/Exit-Codes.md @@ -6,11 +6,11 @@ This document describes the exit codes used by the Ably CLI, particularly in int ### 0 - Success - Normal successful completion of a command -- Clean exit after user types `exit` command (in non-wrapper mode) +- Clean exit after the user types `exit` (when not running under a session restart loop) ### 42 - User Exit (Interactive Mode) -- Special exit code used when user types `exit` in interactive mode with wrapper -- Signals to the wrapper script (`bin/ably-interactive`) to terminate the loop +- Special exit code emitted when the user types `exit` while running under a session restart loop (`ABLY_WRAPPER_MODE=1`) +- Signals the restart loop (e.g. the Ably terminal server's session entrypoint) to stop, rather than treating the exit as a crash to relaunch - Defined as `Interactive.EXIT_CODE_USER_EXIT` ### 130 - SIGINT (Ctrl+C) @@ -40,25 +40,24 @@ This document describes the exit codes used by the Ably CLI, particularly in int - Shows "⚠ Force quit" message - Bypasses normal cleanup -## Wrapper Script Behavior +## Session Restart Loop Behavior -The `bin/ably-interactive` wrapper script uses these exit codes to determine whether to restart the interactive shell: +`ably interactive` is a normal command and handles Ctrl+C in-process — the CLI no longer ships a separate `ably-interactive` wrapper binary. A host that wants the interactive shell to survive a force-quit (for example the Ably terminal server, which keeps a browser session alive) can wrap `ably interactive` in a small restart loop, set `ABLY_WRAPPER_MODE=1`, and key the loop off these exit codes: -- **Exit code 42**: User typed 'exit' - terminate the wrapper loop -- **Exit code 130**: SIGINT - restart the shell (unless double Ctrl+C) -- **Exit code 0**: Normal exit - terminate the wrapper loop -- **Other codes**: Show error message and restart after delay +- **Exit code 42**: User typed 'exit' - stop the loop +- **Exit code 0**: Normal exit - stop the loop +- **Exit code 130**: Force quit (double Ctrl+C) - relaunch the shell +- **Other codes**: Show an error and relaunch after a short delay ## Implementation Details Exit codes are handled in: - `src/commands/interactive.ts`: Sets exit code 42 for user exit - `src/utils/sigint-exit.ts`: Handles SIGINT behavior and exit code 130 -- `bin/ably-interactive`: Wrapper script that interprets exit codes --- ## Related -- [Interactive-REPL.md](Interactive-REPL.md) — Architecture and wrapper script design +- [Interactive-REPL.md](Interactive-REPL.md) — Interactive mode architecture - [Troubleshooting.md](Troubleshooting.md#interactive-mode-issues) — Common interactive mode issues \ No newline at end of file diff --git a/docs/Interactive-REPL.md b/docs/Interactive-REPL.md index 712c547fa..fa8904a2d 100644 --- a/docs/Interactive-REPL.md +++ b/docs/Interactive-REPL.md @@ -1,5 +1,9 @@ # Interactive ([Immersive](https://github.com/dthree/vorpal)) CLI +> Interactive mode now ships as the hidden `ably interactive` command. The +> background below records the original motivation; the +> [Current design](#current-design) section documents how it actually works today. + The Ably CLI is designed to be run as a traditional command line tool, where commands are run individually from a bash-like shell. Between each invocation of commands, the entire CLI environment is loaded and executed. This model works very well for a locally installed CLI. However, the Ably CLI is also available as a Web Terminal CLI as a convenience for Ably customers who are logged in or browsing the docs, with a CLI drawer available to slide up and execute commands. This is made possible with a local restricted shell within a secure container being spawned for each session, with STDIN/STDOUT streamed over a WebSocket connection. @@ -31,391 +35,40 @@ However, a [REPL plugin](https://github.com/sisou/oclif-plugin-repl) exists, alt If there are any existing libraries that we can depend on to enable this functionality, that should be our preference to keep the CLI complexity low. However, any dependencies used should be well maintained and popular. If the additional dependencies to support this functionality add any material bloat, we should consider how this functionality can be added as an optional plugin so that the standard locally installed CLI has minimal dependencies. -## Execution Plan - -### Overview - -This execution plan implements an interactive REPL mode using a bash wrapper approach with inline command execution. The design prioritizes simplicity, natural Ctrl+C handling, and seamless user experience. - -### Architecture: Bash Wrapper with Inline Execution - -The chosen approach runs commands inline (no spawning/forking) with a bash wrapper script that automatically restarts the CLI after Ctrl+C interruptions. Key features: - -- **Inline execution**: Commands run in the same process, eliminating spawn overhead -- **Natural Ctrl+C**: Interrupting commands exits the process, wrapper restarts seamlessly -- **Persistent history**: Command history saved to `~/.ably/history` across restarts (configurable via `ABLY_HISTORY_FILE`) -- **Special exit handling**: Typing 'exit' uses exit code 42 to signal wrapper to terminate (see [Exit Codes documentation](Exit-Codes.md) for details) - -**Expected Performance**: -- Command execution: 0ms spawn overhead (runs inline) -- Ctrl+C to new prompt: ~200-300ms (CLI restart time) -- Memory usage: Shared with main process - -### Implementation Phases - -#### Phase 1: Basic REPL with Bash Wrapper (2-3 days) - -**Goal**: Create functioning interactive shell with inline execution and bash wrapper. - -**Tasks**: -1. Create `src/commands/interactive.ts` command (hidden initially) -2. Implement inline command execution using oclif's `execute()` API -3. Basic readline loop with `$ ` prompt -4. Create bash wrapper script for auto-restart -5. Implement special exit code handling - -**Key Files**: - -```typescript -// src/commands/interactive.ts -import { Command, execute } from '@oclif/core'; -import * as readline from 'readline'; -import { HistoryManager } from '../services/history-manager.js'; - -export default class Interactive extends Command { - static description = 'Launch interactive Ably shell'; - static hidden = true; - static EXIT_CODE_USER_EXIT = 42; // Special code for 'exit' command - - private rl!: readline.Interface; - private historyManager!: HistoryManager; - private isWrapperMode = process.env.ABLY_WRAPPER_MODE === '1'; - - async run() { - // Show welcome message only on first run - if (!process.env.ABLY_SUPPRESS_WELCOME) { - console.log('Welcome to Ably interactive shell. Type "exit" to quit.'); - if (this.isWrapperMode) { - console.log('Press Ctrl+C to interrupt running commands.'); - } - console.log(); - } - - this.historyManager = new HistoryManager(); - this.setupReadline(); - await this.historyManager.loadHistory(this.rl); - this.rl.prompt(); - } - - private setupReadline() { - this.rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - prompt: '$ ', - terminal: true - }); - - this.rl.on('line', async (input) => { - await this.handleCommand(input.trim()); - }); - - this.rl.on('SIGINT', () => { - // Show yellow warning message - console.log('\n\x1b[33mSignal received. To exit this shell, type \'exit\' and press Enter.\x1b[0m'); - this.rl.prompt(); - }); - - this.rl.on('close', () => { - this.cleanup(); - // Use special exit code when in wrapper mode - const exitCode = this.isWrapperMode ? Interactive.EXIT_CODE_USER_EXIT : 0; - process.exit(exitCode); - }); - } - - private async handleCommand(input: string) { - if (input === 'exit' || input === '.exit') { - this.rl.close(); - return; - } - - if (input === '') { - this.rl.prompt(); - return; - } - - // Save to history - await this.historyManager.saveCommand(input); - - try { - const args = this.parseCommand(input); - - // Execute command inline (no spawning) - await execute({ - args, - dir: import.meta.url - }); - - } catch (error: any) { - if (error.code === 'EEXIT') { - // Normal oclif exit - don't treat as error - return; - } - console.error('Error:', error.message); - } finally { - this.rl.prompt(); - } - } - - private parseCommand(input: string): string[] { - // Handle quoted strings properly - const regex = /[^\s"']+|"([^"]*)"|'([^']*)'/g; - const args: string[] = []; - let match; - - while ((match = regex.exec(input))) { - args.push(match[1] || match[2] || match[0]); - } - - return args; - } - - private cleanup() { - console.log('\nGoodbye!'); - } -} -``` - -```bash -#!/bin/bash -# bin/ably-interactive - -# Configuration -ABLY_BIN="$(dirname "$0")/run.js" -ABLY_CONFIG_DIR="$HOME/.ably" -HISTORY_FILE="$ABLY_CONFIG_DIR/history" -EXIT_CODE_USER_EXIT=42 -WELCOME_SHOWN=0 - -# Create config directory if it doesn't exist -mkdir -p "$ABLY_CONFIG_DIR" 2>/dev/null || true - -# Initialize history file -touch "$HISTORY_FILE" 2>/dev/null || true - -# Main loop -while true; do - # Run the CLI - env ABLY_HISTORY_FILE="$HISTORY_FILE" \ - ABLY_WRAPPER_MODE=1 \ - ${ABLY_SUPPRESS_WELCOME:+ABLY_SUPPRESS_WELCOME=1} \ - node "$ABLY_BIN" interactive - - EXIT_CODE=$? - - # Mark welcome as shown after first run - WELCOME_SHOWN=1 - export ABLY_SUPPRESS_WELCOME=1 - - # Check exit code - case $EXIT_CODE in - $EXIT_CODE_USER_EXIT) - # User typed 'exit' - break - ;; - 130) - # SIGINT (Ctrl+C) - continue loop - ;; - 0) - # Should not happen in interactive mode - break - ;; - *) - # Other error - echo -e "\033[31m\nProcess exited unexpectedly (code: $EXIT_CODE)\033[0m" - sleep 0.5 - ;; - esac -done - -echo "Goodbye!" -``` - -**Testing**: -- Verify inline command execution works -- Test Ctrl+C during long-running commands -- Verify wrapper restarts seamlessly -- Test exit command with special exit code -- Verify history persistence across restarts - -#### Phase 2: History Persistence (1-2 days) - -**Goal**: Implement persistent command history that survives restarts. - -**Tasks**: -1. Create `HistoryManager` service -2. Load history on startup -3. Save commands before execution -4. Implement history file trimming -5. Test history across restarts - -**Implementation**: -```typescript -// src/services/history-manager.ts -import * as fs from 'fs'; -import * as readline from 'readline'; - -export class HistoryManager { - private historyFile: string; - private maxHistorySize = 1000; - - constructor(historyFile?: string) { - this.historyFile = historyFile || process.env.ABLY_HISTORY_FILE || - `${process.env.HOME}/.ably/history`; - } - - async loadHistory(rl: readline.Interface): Promise { - try { - if (!fs.existsSync(this.historyFile)) return; - - const history = fs.readFileSync(this.historyFile, 'utf-8') - .split('\n') - .filter(line => line.trim()) - .slice(-this.maxHistorySize); - - // Access internal history - const internalRl = rl as any; - internalRl.history = history.reverse(); - } catch (error) { - // Silently ignore history load errors - } - } - - async saveCommand(command: string): Promise { - if (!command.trim()) return; - - try { - fs.appendFileSync(this.historyFile, command + '\n'); - - // Trim history file if too large - const lines = fs.readFileSync(this.historyFile, 'utf-8').split('\n'); - if (lines.length > this.maxHistorySize * 2) { - const trimmed = lines.slice(-this.maxHistorySize).join('\n'); - fs.writeFileSync(this.historyFile, trimmed); - } - } catch (error) { - // Silently ignore history save errors - } - } -} -``` - -#### Phase 3: Autocomplete Implementation (3-4 days) - -**Goal**: Add tab completion for commands, subcommands, and flags. - -**Tasks**: -1. Extract command metadata from oclif config -2. Implement readline completer function -3. Support nested command completion -4. Add flag completion - -**Implementation**: -```typescript -// Add to Interactive class -private completer(line: string): [string[], string] { - const commands = this.getAvailableCommands(); - const words = line.trim().split(/\s+/); - - if (words.length <= 1) { - // Complete command names - const partial = words[0] || ''; - const matches = commands.filter(cmd => cmd.startsWith(partial)); - return [matches, partial]; - } else { - // Complete subcommands or flags - const cmdPath = words.slice(0, -1).join(' '); - const partial = words[words.length - 1]; - - if (partial.startsWith('--')) { - // Complete flags - const flags = this.getFlagsForCommand(cmdPath); - const matches = flags.filter(flag => flag.startsWith(partial)); - return [matches, partial]; - } else { - // Complete subcommands - const subcommands = this.getSubcommands(cmdPath); - const matches = subcommands.filter(cmd => cmd.startsWith(partial)); - return [matches, partial]; - } - } -} - -private getAvailableCommands(): string[] { - // Cache this on initialization - return Array.from(this.config.commands.keys()) - .map(cmd => cmd.replace(/:/g, ' ')) - .sort(); -} -``` - -#### Phase 4: Enhanced Parsing & Error Handling (2 days) - -**Goal**: Improve command parsing and error handling. - -**Tasks**: -1. Better quote handling in command parsing -2. Enhanced error messages -3. Worker crash recovery -4. Timeout handling - -#### Phase 5: Testing & Polish (3 days) - -**Goal**: Comprehensive testing and refinement. - -**Tasks**: -1. Cross-platform testing (Windows, macOS, Linux) -2. Performance benchmarking -3. Edge case handling -4. Documentation - -### Performance Metrics - -**Target Performance**: -- Command execution: 0ms spawn overhead (inline execution) -- Ctrl+C to new prompt: < 300ms (CLI restart time) -- Autocomplete response: < 50ms -- History load time: < 50ms - -### Risk Mitigation - -1. **Oclif inline execution issues**: Test execute() API thoroughly -2. **Memory growth**: Monitor memory usage over time -3. **Platform compatibility**: Create PowerShell wrapper for Windows -4. **Rapid restart loops**: Add restart counter and backoff - -### Success Criteria - -1. **Latency**: 0ms spawn overhead for command execution -2. **Reliability**: Natural Ctrl+C handling with seamless restart -3. **Features**: Full autocomplete and persistent history -4. **Compatibility**: All existing commands work unchanged -5. **User Experience**: Invisible restart after Ctrl+C +## Current design -### Deployment Strategy +Interactive mode ships as the hidden `ably interactive` command (currently ALPHA). +It is implemented in [`src/commands/interactive.ts`](../src/commands/interactive.ts) +and runs as a single long-lived process: the welcome banner and a short list of +common commands print once, then a readline loop presents an `ably> ` prompt and +executes each entered command **in-process** via oclif's `Config.runCommand`. +There is no per-command spawn, so there is no bootstrap cost between commands. -1. **Week 1**: Core implementation (Phases 1-2) -2. **Week 2**: Autocomplete and enhancements (Phases 3-4) -3. **Week 3**: Testing and polish (Phase 5) -4. **Week 4**: Beta testing with web terminal -5. **Week 5**: Production rollout +Key behaviours: -### Advantages of Bash Wrapper Approach +- **In-process execution** — commands run in the same process as the shell, so there is no spawn overhead between commands. +- **Ctrl+C** — handled in-process (see [`src/utils/sigint-exit.ts`](../src/utils/sigint-exit.ts)). A single Ctrl+C while a command is running interrupts that command and returns to the prompt; a second Ctrl+C force-quits. At an empty prompt it prints a hint to type `exit`; with text already on the line it clears the line, zsh-style. +- **Exit** — type `exit` (or `.exit`), or press Ctrl+D. The shell exits `0` normally, or with code `42` when `ABLY_WRAPPER_MODE=1` so a host can distinguish a deliberate quit from an interrupt (`130`). See [Exit Codes](Exit-Codes.md). +- **History** — persisted to `~/.ably/history` (override with `ABLY_HISTORY_FILE`) by `HistoryManager`, with up/down recall and Ctrl+R reverse search. +- **Autocomplete** — TAB completes commands, subcommands, and flags, read from the oclif manifest. See [Auto-completion](Auto-completion.md). +- **Restricted commands** — commands listed in `INTERACTIVE_UNSUITABLE_COMMANDS` (and, in the web CLI, the web-mode restriction lists) are hidden from completion and rejected if entered. -1. **Simplicity**: No complex process management or signal forwarding -2. **Natural Ctrl+C**: Works exactly as users expect -3. **Performance**: Zero spawn overhead for commands -4. **Maintainability**: Much less code to maintain -5. **Reliability**: Leverages OS-level process management +### No wrapper binary -This plan delivers a responsive interactive shell with natural Ctrl+C handling and seamless user experience through the bash wrapper approach. +The CLI ships a single `ably` bin. There is no longer an `ably-interactive` wrapper +binary — it was retired once Ctrl+C was handled in-process. The +auto-restart-on-force-quit behaviour the bash wrapper used to provide is now +optional and owned by whichever host wants it. The Ably terminal server, for +example, wraps `ably interactive` in a small restart loop keyed off exit codes `42` +(user exit) and `130` (interrupt), with `ABLY_WRAPPER_MODE=1` set so the CLI emits +code `42` on a clean exit. --- ## Related -- [Exit Codes](Exit-Codes.md) — Exit codes used in interactive mode and wrapper script behavior +- [Exit Codes](Exit-Codes.md) — Exit codes used in interactive mode - [Troubleshooting](Troubleshooting.md#interactive-mode-issues) — Common interactive mode issues (unexpected exits, Ctrl+C, history) - [Auto-completion](Auto-completion.md) — Shell tab completion setup for commands and flags - [Testing Guide](Testing.md) — Subprocess and TTY test layers for interactive mode -- [Project Structure](Project-Structure.md) — Repository layout including `src/commands/interactive.ts` and `bin/ably-interactive` +- [Project Structure](Project-Structure.md) — Repository layout including `src/commands/interactive.ts` diff --git a/docs/Project-Structure.md b/docs/Project-Structure.md index dcc6f3cc1..56d11c984 100644 --- a/docs/Project-Structure.md +++ b/docs/Project-Structure.md @@ -8,8 +8,6 @@ This document outlines the directory structure of the Ably CLI project. / ├── assets/ # Static assets (e.g. CLI screenshot) ├── bin/ # Executable scripts -│ ├── ably-interactive # Bash wrapper for interactive mode (restarts on Ctrl+C) -│ ├── ably-interactive.ps1 # PowerShell equivalent for Windows │ ├── dev.cmd # Development run script (Windows) │ ├── development.js # Development run script (Unix) │ ├── run.cmd # Production run script (Windows) @@ -175,5 +173,5 @@ This document outlines the directory structure of the Ably CLI project. - [Development Stage](Environment-Variables/Development-Usage.md) Env Variables — Development, testing, debugging, and internal env variables. For user-facing variables, run `ably env`. - [Testing Guide](Testing.md) — Test layers and directory layout (`test/unit/`, `test/e2e/`, `test/tty/`, `test/integration/`) - [Debugging Guide](Debugging.md) — Debugging tips for CLI development -- [Interactive REPL](Interactive-REPL.md) — Architecture of `src/commands/interactive.ts` and `bin/ably-interactive` +- [Interactive REPL](Interactive-REPL.md) — Architecture of `src/commands/interactive.ts` - [Exit Codes](Exit-Codes.md) — Exit codes handled in `src/commands/interactive.ts` and `src/utils/sigint-exit.ts` diff --git a/docs/Troubleshooting.md b/docs/Troubleshooting.md index 308e4dd49..620d88726 100644 --- a/docs/Troubleshooting.md +++ b/docs/Troubleshooting.md @@ -226,8 +226,8 @@ Property 'x' does not exist on type 'Y' **Solution**: - Check the exit code to understand what happened (see [Exit Codes documentation](Exit-Codes.md) and [Development Stage Env Variables](Environment-Variables/Development-Usage.md) for interactive mode env vars) - Common exit codes: - - Exit code 0: Wrapper (`ably-interactive`) terminated normally - - Exit code 42: User typed 'exit' in interactive mode (signals wrapper to terminate) + - Exit code 0: Interactive shell exited normally + - Exit code 42: User typed 'exit' in interactive mode (signals a session restart loop to stop) - Exit code 130: SIGINT/Ctrl+C (double Ctrl+C or force quit) - Exit code 143: SIGTERM received @@ -236,10 +236,9 @@ Property 'x' does not exist on type 'Y' **Problem**: Ctrl+C doesn't interrupt commands or behaves unexpectedly. **Solution**: -- Use the wrapper script `ably-interactive` for better Ctrl+C handling -- Single Ctrl+C should interrupt running command and return to prompt +- Single Ctrl+C interrupts the running command and returns to the prompt +- At an empty prompt, single Ctrl+C prints `^C`; type `exit` (or Ctrl+D) to leave the shell - Double Ctrl+C (within 500ms) force quits with exit code 130 -- If running without wrapper, Ctrl+C may exit the entire shell ### Command History Not Persisting diff --git a/src/data/env-vars.ts b/src/data/env-vars.ts index dfbfce1bd..48fa03633 100644 --- a/src/data/env-vars.ts +++ b/src/data/env-vars.ts @@ -235,17 +235,17 @@ const ABLY_HISTORY_FILE = new EnvVarEntry( "File path", "~/.ably/history", null, - ["ably-interactive"], + ["ably interactive"], "Override the location of the interactive mode command history file.", new Example([ `export ABLY_HISTORY_FILE="/path/to/custom/history"`, - `ably-interactive`, + `ably interactive`, ]), [ new DetailSection("", [ { kind: "note", - text: "Auto-set by the `ably-interactive` shell wrapper; only set manually for a custom location.", + text: "Defaults to `~/.ably/history`; only set this to use a custom location.", }, ]), ], diff --git a/test/manual/interactive-mode-test.md b/test/manual/interactive-mode-test.md index c205e41fc..35b462996 100644 --- a/test/manual/interactive-mode-test.md +++ b/test/manual/interactive-mode-test.md @@ -1,185 +1,162 @@ # Manual Test Instructions for Interactive Mode ## Overview -This document provides manual test cases for the new interactive mode implementation using the bash wrapper approach. + +This document provides manual test cases for interactive mode (`ably interactive`). + +Interactive mode runs as a single Node process and handles Ctrl+C **in-process** — +there is no longer a separate `ably-interactive` bash wrapper. A single Ctrl+C +interrupts the running command and returns to the prompt; a double Ctrl+C (within +500ms) force-quits with exit code 130. The "restart the shell after a force-quit" +behaviour previously provided by the wrapper is now optional and owned by the host +(e.g. the Ably terminal server wraps `ably interactive` in a small restart loop; +see `docs/Exit-Codes.md`). ## Prerequisites -1. Build the project: `npm run build` + +1. Build the project: `pnpm build` 2. Ensure you have a valid Ably account configured +3. Run these in a **real terminal** (not an IDE/script stdio) so Ctrl+C is delivered as a signal ## Test Cases -### 1. Basic Interactive Mode (Without Wrapper) +### 1. Basic Interactive Mode ```bash -# Start interactive mode directly -./bin/run.js interactive +node bin/run.js interactive ``` **Expected behavior:** - Welcome message appears -- `$ ` prompt is shown +- `ably>` prompt is shown - Commands execute inline -- Ctrl+C shows yellow warning message -- Type `exit` to quit +- A dim hint explains Ctrl+C/`exit` behaviour +- Type `exit` to quit (exit code 0) -### 2. Interactive Mode with Bash Wrapper +### 2. Long-Running Command Interruption (single Ctrl+C) ```bash -# Start with wrapper for seamless Ctrl+C handling -./bin/ably-interactive +node bin/run.js interactive +# At the prompt: +ably> test:wait --duration 30 +# Press Ctrl+C while it is waiting ``` **Expected behavior:** -- Welcome message appears on first run only -- `$ ` prompt is shown -- Commands execute inline -- Ctrl+C during command execution: - - Interrupts the command - - CLI automatically restarts - - No welcome message on restart - - New prompt appears immediately -- Type `exit` to quit completely +- Command starts running ("Waiting for ...") +- Single Ctrl+C interrupts the command and shows stopping feedback +- The **same process** returns to a new `ably>` prompt (no restart, no welcome message) +- No `setRawMode EIO` / terminal-corruption errors +- Repeat several times — behaviour stays stable -### 3. Long-Running Command Interruption +### 3. Double Ctrl+C (force quit) ```bash -# In wrapper mode -./bin/ably-interactive - -# At prompt, run a long command -$ channels subscribe test-channel --duration 30 +node bin/run.js interactive +ably> test:wait --duration 30 +# Press Ctrl+C twice rapidly (within 500ms) while it is waiting ``` **Expected behavior:** -- Command starts running -- Press Ctrl+C -- Command is interrupted -- Shell automatically restarts -- New prompt appears without welcome message +- `⚠ Force quit` is shown +- Process exits with code 130 (`echo $?`) +- (Under a host restart loop with `ABLY_WRAPPER_MODE=1`, the host would relaunch the shell instead — see test 6) ### 4. Interactive Prompts ```bash -# In wrapper mode -./bin/ably-interactive - -# Run a command that requires confirmation -$ apps create test-app +node bin/run.js interactive +ably> apps create test-app ``` **Expected behavior:** - Command prompts for confirmation (Y/N) -- Typing Y or N works correctly -- Prompt response is processed by the command +- Typing Y or N works correctly and is processed by the command ### 5. Command History ```bash -# Run several commands -./bin/ably-interactive -$ help -$ version -$ apps list -$ exit - -# Start again -./bin/ably-interactive +node bin/run.js interactive +ably> help +ably> version +ably> exit + +# Start again and press the up arrow +node bin/run.js interactive ``` **Expected behavior:** -- Press up arrow -- Previous commands appear in reverse order -- History persists across sessions -- Check `~/.ably/history` file exists +- Up arrow recalls previous commands in reverse order +- History persists across sessions in `~/.ably/history` -### 6. Exit Code Testing +### 6. Exit Code Contract ```bash -# Test normal exit -./bin/ably-interactive -$ exit -echo $? # Should be 0 - -# Test wrapper mode exit -ABLY_WRAPPER_MODE=1 ./bin/run.js interactive -$ exit -echo $? # Should be 42 +# Normal exit +node bin/run.js interactive +ably> exit +echo $? # 0 + +# Restart-loop contract: with ABLY_WRAPPER_MODE=1, `exit` returns 42 so a host +# restart loop knows to stop (rather than relaunch). 130 = force quit. +ABLY_WRAPPER_MODE=1 node bin/run.js interactive +ably> exit +echo $? # 42 ``` ### 7. Error Handling ```bash -./bin/ably-interactive - -# Try invalid commands -$ invalid-command -$ apps invalid-subcommand +node bin/run.js interactive +ably> invalid-command +ably> apps invalid-subcommand ``` **Expected behavior:** - Error messages appear -- Shell continues running -- New prompt appears +- Shell continues running and shows a new prompt -### 8. Rapid Ctrl+C Testing +### 8. Rapid Ctrl+C at the Prompt ```bash -./bin/ably-interactive - -# Press Ctrl+C multiple times rapidly at the prompt +node bin/run.js interactive +# Press Ctrl+C several times at an empty prompt ``` **Expected behavior:** -- Multiple yellow warning messages may appear -- Shell remains stable -- No crashes or unexpected exits +- Each shows `^C` and a hint to type `exit` +- Shell remains stable (no crashes); a rapid double-press force-quits with 130 -### 9. Environment Variable Testing +### 9. Custom History File ```bash -# Test with custom history file -ABLY_HISTORY_FILE=/tmp/test-history ./bin/ably-interactive -$ test command -$ exit - -# Verify custom history location +ABLY_HISTORY_FILE=/tmp/test-history node bin/run.js interactive +ably> help +ably> exit cat /tmp/test-history ``` -### 10. Cross-Platform Testing - -If testing on different platforms: +### 10. Cross-Platform -**macOS/Linux:** -- Bash wrapper should work normally -- All features functional - -**Windows (Git Bash/WSL):** -- Bash wrapper should work in Git Bash or WSL -- Note: Native Windows Command Prompt won't support bash wrapper +The single `ably` bin works on all platforms, and Ctrl+C is handled in-process, so +interactive mode no longer depends on a bash wrapper (macOS/Linux/Windows behave +the same). Any restart-on-force-quit behaviour is a host concern, not the CLI's. ## Verification Checklist - [ ] Interactive mode starts successfully - [ ] Commands execute without spawn overhead -- [ ] Ctrl+C interrupts long-running commands -- [ ] Shell restarts seamlessly after Ctrl+C +- [ ] Single Ctrl+C interrupts a long-running command and returns to the prompt in-process +- [ ] No terminal corruption (`setRawMode EIO`) across repeated interrupts +- [ ] Double Ctrl+C force-quits with exit code 130 +- [ ] `exit` returns 0 normally, and 42 under `ABLY_WRAPPER_MODE=1` - [ ] Interactive prompts (Y/N) work correctly - [ ] Command history persists in `~/.ably/history` -- [ ] Exit command works with correct exit codes - [ ] Error handling doesn't crash the shell -- [ ] Welcome message only shows on first run - -## Known Limitations - -1. Ctrl+C at the prompt shows a warning instead of clearing the line -2. In direct mode (without wrapper), Ctrl+C exits the shell during command execution -3. Bash wrapper requires bash shell (won't work in pure Windows CMD) +- [ ] Welcome message shows once (suppressed by `ABLY_SUPPRESS_WELCOME=1`) -## Troubleshooting +## Notes -If the wrapper doesn't restart after Ctrl+C: -1. Check the exit code: `echo $?` -2. Ensure you're using the wrapper script, not direct mode -3. Check for any error messages before the shell exits \ No newline at end of file +- Ctrl+C at the prompt prints `^C` and a hint rather than clearing the line. +- For automated coverage of the SIGINT/terminal behaviour, see + `test/tty/commands/interactive-sigint.test.ts` (run with `pnpm test:tty`). diff --git a/test/manual/manual-test-instructions.md b/test/manual/manual-test-instructions.md index 3d05bbb4c..15b3c114e 100644 --- a/test/manual/manual-test-instructions.md +++ b/test/manual/manual-test-instructions.md @@ -20,20 +20,20 @@ node bin/run.js interactive # 3. Note any error messages ``` -## Test 2: Wrapper Script Test +## Test 2: Repeated Interrupt Test ```bash # Keep diagnostics enabled export TERMINAL_DIAGNOSTICS=1 export DEBUG_SIGINT=1 -# Run with wrapper -bin/ably-interactive +# Run interactive mode +node bin/run.js interactive -# When you see the prompt: -# 1. Type: test:wait --duration 10 -# 2. Press Ctrl+C while it's waiting -# 3. Watch for "setRawMode EIO" error when wrapper tries to restart +# When you see the prompt, repeat several times: +# 1. Type: test:wait --duration 10 +# 2. Press Ctrl+C while it's waiting (should return to the prompt in-process) +# 3. Watch for any "setRawMode EIO" / terminal-corruption errors across repeats ``` ## Test 3: Check Terminal State @@ -72,7 +72,7 @@ ps aux | grep ably ```bash # Clean up any stale processes -pkill -f "ably-interactive" +pkill -f "run.js interactive" # Reset terminal if corrupted reset diff --git a/test/unit/utils/env-vars-render.test.ts b/test/unit/utils/env-vars-render.test.ts index 0b5baeb02..780954056 100644 --- a/test/unit/utils/env-vars-render.test.ts +++ b/test/unit/utils/env-vars-render.test.ts @@ -119,10 +119,8 @@ describe("env-vars-render", () => { expect(renderSingleVar("ABLY_TOKEN")).toContain("unset ABLY_TOKEN"); }); - it("ABLY_HISTORY_FILE section explains the wrapper auto-set behavior", () => { - expect(renderSingleVar("ABLY_HISTORY_FILE")).toContain( - "ably-interactive", - ); + it("ABLY_HISTORY_FILE section documents the custom history location", () => { + expect(renderSingleVar("ABLY_HISTORY_FILE")).toContain("custom location"); }); it("does not surface deleted detail-section content (Behavior, Obtaining, etc.)", () => { From 95085897699757832ac5f8e90af8f978a90dc0fe Mon Sep 17 00:00:00 2001 From: umair Date: Tue, 23 Jun 2026 11:15:00 +0100 Subject: [PATCH 5/6] test(e2e): replace wrapper-integration with in-process interactive test The old suite spawned bin/ably-interactive (now deleted) to assert wrapper restart/EIO behaviour. Rewrite as interactive-integration.test.ts covering the no-wrapper reality: starts/exits cleanly, a real SIGINT interrupts a running command and the SAME process re-prompts and stays usable, and `exit` emits 42 under ABLY_WRAPPER_MODE (the host restart-loop contract). Real-terminal SIGINT and terminal-corruption/EIO coverage lives in test/tty/commands/interactive-sigint.test.ts. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../interactive/wrapper-integration.test.ts | 449 ------------------ 1 file changed, 449 deletions(-) delete mode 100644 test/e2e/interactive/wrapper-integration.test.ts diff --git a/test/e2e/interactive/wrapper-integration.test.ts b/test/e2e/interactive/wrapper-integration.test.ts deleted file mode 100644 index 94051fff6..000000000 --- a/test/e2e/interactive/wrapper-integration.test.ts +++ /dev/null @@ -1,449 +0,0 @@ -import { describe, it, beforeAll, expect } from "vitest"; -import { spawn } from "node:child_process"; -import * as path from "node:path"; -import { fileURLToPath } from "node:url"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -describe("Interactive Mode - Wrapper Integration", () => { - let binPath: string; - let wrapperPath: string; - - beforeAll(() => { - binPath = path.join(__dirname, "../../../bin/run.js"); - wrapperPath = path.join(__dirname, "../../../bin/ably-interactive"); - }); - - it( - "should use wrapper script when running node bin/run.js interactive", - { timeout: 30000 }, - async () => { - // Start the process - const proc = spawn("node", [binPath, "interactive"], { - stdio: "pipe", - env: { - ...process.env, - ABLY_SUPPRESS_WELCOME: "1", - PS_TEST: "1", // Mark as test to check process tree - }, - }); - - let output = ""; - - proc.stdout.on("data", (data) => { - output += data.toString(); - }); - - // Wait for prompt - await new Promise((resolve) => { - const checkPrompt = setInterval(() => { - if (output.includes("ably>")) { - clearInterval(checkPrompt); - resolve(); - } - }, 100); - }); - - // Note: Process tree check removed - not reliable in test environment - - // Send exit command - proc.stdin.write("exit\n"); - - // Wait for exit - const exitCode = await new Promise((resolve) => { - proc.on("exit", (code) => { - resolve(code || 0); - }); - }); - - expect(exitCode).toBe(0); - expect(output).toContain("Goodbye!"); - - // Note: We can't reliably check process tree in test environment - // but at least verify the command works - }, - ); - - it( - "should restart after SIGINT when using wrapper", - { timeout: 30000 }, - async () => { - const proc = spawn("node", [binPath, "interactive"], { - stdio: "pipe", - env: { - ...process.env, - ABLY_SUPPRESS_WELCOME: "1", - }, - }); - - let promptCount = 0; - - proc.stdout.on("data", (data) => { - // Count how many times we see the prompt - const matches = data.toString().match(/ably> /g); - if (matches) { - promptCount += matches.length; - } - }); - - // Wait for first prompt - await new Promise((resolve) => { - const checkPrompt = setInterval(() => { - if (promptCount >= 1) { - clearInterval(checkPrompt); - resolve(); - } - }, 100); - }); - - // Send test:wait command - proc.stdin.write("test:wait --duration 10\n"); - - // Wait for command to start - await new Promise((resolve) => setTimeout(resolve, 1000)); - - // Send SIGINT - proc.kill("SIGINT"); - - // Wait for second prompt (after restart) - await new Promise((resolve) => { - const timeout = setTimeout(() => { - // If no second prompt, that's ok - wrapper might not restart in test env - resolve(); - }, 3000); - - const checkPrompt = setInterval(() => { - if (promptCount >= 2) { - clearInterval(checkPrompt); - clearTimeout(timeout); - resolve(); - } - }, 100); - }); - - // Send exit command - proc.stdin.write("exit\n"); - - // Wait for exit - await new Promise((resolve) => { - proc.on("exit", resolve); - // Force kill after timeout - setTimeout(() => { - proc.kill(); - resolve(null); - }, 2000); - }); - - // In wrapper mode, we should see at least one prompt - expect(promptCount).toBeGreaterThanOrEqual(1); - }, - ); - - it( - "should have consistent behavior between bin/run.js interactive and bin/ably-interactive", - { timeout: 30000 }, - async () => { - // Test direct wrapper - const proc1 = spawn(wrapperPath, [], { - stdio: "pipe", - env: { - ...process.env, - ABLY_SUPPRESS_WELCOME: "1", - }, - }); - - let output1 = ""; - proc1.stdout.on("data", (data) => { - output1 += data.toString(); - }); - - // Wait for prompt with timeout - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - clearInterval(checkPrompt); - reject(new Error("Timeout waiting for prompt from wrapper")); - }, 10000); - - const checkPrompt = setInterval(() => { - if (output1.includes("ably>")) { - clearInterval(checkPrompt); - clearTimeout(timeout); - resolve(); - } - }, 100); - }); - - proc1.stdin.write("exit\n"); - - const exitCode1 = await new Promise((resolve) => { - proc1.on("exit", (code) => { - resolve(code || 0); - }); - }); - - // Test via run.js - const proc2 = spawn("node", [binPath, "interactive"], { - stdio: "pipe", - env: { - ...process.env, - ABLY_SUPPRESS_WELCOME: "1", - }, - }); - - let output2 = ""; - proc2.stdout.on("data", (data) => { - output2 += data.toString(); - }); - - // Wait for prompt - await new Promise((resolve) => { - const checkPrompt = setInterval(() => { - if (output2.includes("ably>")) { - clearInterval(checkPrompt); - resolve(); - } - }, 100); - }); - - proc2.stdin.write("exit\n"); - - const exitCode2 = await new Promise((resolve) => { - proc2.on("exit", (code) => { - resolve(code || 0); - }); - }); - - // Both should exit cleanly - expect(exitCode1).toBe(0); - expect(exitCode2).toBe(0); - expect(output1).toContain("Goodbye!"); - expect(output2).toContain("Goodbye!"); - }, - ); - - describe("Wrapper restart after SIGINT", () => { - it( - "should NOT fail with setRawMode EIO when wrapper restarts after SIGINT", - { timeout: 30000 }, - async () => { - // This test specifically targets the issue where: - // 1. User runs test:wait command - // 2. User presses Ctrl+C - // 3. Process exits with code 130 - // 4. Wrapper tries to restart - // 5. New instance fails with "setRawMode EIO" - - const proc = spawn(wrapperPath, [], { - stdio: ["pipe", "pipe", "pipe"], - env: { - ...process.env, - ABLY_SUPPRESS_WELCOME: "1", - }, - detached: true, // Create process group - }); - - let output = ""; - let errorCount = 0; - const errors: string[] = []; - let promptCount = 0; - - proc.stdout.on("data", (data) => { - const text = data.toString(); - output += text; - - // Count prompts - const prompts = text.match(/ably> /g); - if (prompts) { - promptCount += prompts.length; - } - }); - - proc.stderr.on("data", (data) => { - const text = data.toString(); - output += text; - - // Check for the specific error pattern - if ( - text.includes( - "Failed to start interactive mode: Error: setRawMode EIO", - ) || - text.includes("at ReadStream.setRawMode (node:tty:") || - text.includes("errno: -5") || - text.includes("code: 'EIO'") || - text.includes("syscall: 'setRawMode'") || - text.includes("Process exited unexpectedly (code: 1)") - ) { - errorCount++; - errors.push(text); - } - }); - - // Wait for initial prompt - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error("Initial prompt timeout")); - }, 10000); - - const interval = setInterval(() => { - if (promptCount >= 1) { - clearInterval(interval); - clearTimeout(timeout); - resolve(); - } - }, 100); - }); - - // Send test:wait command - proc.stdin.write("test:wait --duration 10\n"); - - // Wait for command to start - await new Promise((resolve) => { - const interval = setInterval(() => { - if (output.includes("Waiting for") && output.includes("seconds")) { - clearInterval(interval); - resolve(); - } - }, 100); - }); - - // Clear output to better track what happens after SIGINT - output = ""; - const beforeSigintPromptCount = promptCount; - - // Send SIGINT to process group (simulates real Ctrl+C) - try { - process.kill(-proc.pid!, "SIGINT"); - } catch { - // Ignore errors - process group might not exist in some environments - } - - // Wait for wrapper to handle the restart - await new Promise((resolve) => setTimeout(resolve, 5000)); - - // The test PASSES if we DON'T see the error - expect(errorCount).toBe(0); - - // We should have gotten a new prompt after restart - expect(promptCount).toBeGreaterThan(beforeSigintPromptCount); - - // Verify the CLI is functional after restart - proc.stdin.write("version\n"); - - await new Promise((resolve) => setTimeout(resolve, 1000)); - - expect(output).toMatch(/\d+\.\d+\.\d+/); // Version number - - // Clean exit - proc.stdin.write("exit\n"); - - await new Promise((resolve) => { - proc.on("exit", resolve); - // Force kill after timeout - setTimeout(() => { - proc.kill(); - resolve(); - }, 2000); - }); - }, - ); - }); - - describe("Terminal corruption prevention", () => { - it( - "should prevent terminal corruption after SIGINT in wrapper mode", - { timeout: 30000 }, - async () => { - const proc = spawn(wrapperPath, [], { - stdio: ["pipe", "pipe", "pipe"], - env: { ...process.env, ABLY_SUPPRESS_WELCOME: "1" }, - detached: true, - }); - - let output = ""; - let errorOccurred = false; - const errorMessages: string[] = []; - - proc.stdout.on("data", (data) => { - output += data.toString(); - }); - - proc.stderr.on("data", (data) => { - const text = data.toString(); - output += text; - - // Check for terminal corruption errors - if ( - text.includes( - "Failed to start interactive mode: Error: setRawMode EIO", - ) || - text.includes("errno: -5") || - text.includes("code: 'EIO'") || - text.includes("syscall: 'setRawMode'") || - text.includes("Process exited unexpectedly (code: 1)") - ) { - errorOccurred = true; - errorMessages.push(text); - } - }); - - // Wait for initial prompt - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error("Timeout")); - }, 10000); - - const interval = setInterval(() => { - if (output.includes("ably>")) { - clearInterval(interval); - clearTimeout(timeout); - resolve(); - } - }, 100); - }); - - // Send test:wait command - proc.stdin.write("test:wait --duration 10\n"); - - // Wait for command to start - await new Promise((resolve) => { - const interval = setInterval(() => { - if (output.includes("Waiting for")) { - clearInterval(interval); - resolve(); - } - }, 100); - }); - - // Send SIGINT to process group (like real Ctrl+C) - try { - process.kill(-proc.pid!, "SIGINT"); - } catch { - // Ignore errors - } - - // Wait for wrapper to handle the signal and restart - await new Promise((resolve) => setTimeout(resolve, 5000)); - - // The test FAILS if we see the setRawMode EIO error - expect(errorOccurred).toBe(false); - - // Should have multiple prompts (initial + after restart) - const promptCount = (output.match(/ably> /g) || []).length; - expect(promptCount).toBeGreaterThan(1); - - // Clean exit - proc.stdin.write("exit\n"); - - await new Promise((resolve) => { - proc.on("exit", resolve); - // Force kill after timeout - setTimeout(() => { - proc.kill(); - resolve(); - }, 2000); - }); - }, - ); - }); -}); From 2dcd3f5f8d875ac515a9dc139fd9da19f3182deb Mon Sep 17 00:00:00 2001 From: umair Date: Tue, 23 Jun 2026 16:59:48 +0100 Subject: [PATCH 6/6] test(e2e): cover non-TTY 0x03 Ctrl+C path; share interactive harness Hoist the spawn/output-capture/waitFor boilerplate into a single `startInteractive()` helper used by all four interactive integration tests. Replace the mid-command 0x03 test with the reachable at-prompt case. A 0x03 (ETX) byte cannot interrupt a *running* command over piped stdin: handleCommand calls rl.pause() during execution, which pauses the stdin stream so the process.stdin "data" handler never receives the byte. The at-prompt branch (emit SIGINT to readline -> "^C" + hint) is the part that actually works in non-TTY mode. Mid-command interruption stays covered by the real-SIGINT test. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../interactive-integration.test.ts | 142 ++++++++++-------- 1 file changed, 82 insertions(+), 60 deletions(-) diff --git a/test/e2e/interactive/interactive-integration.test.ts b/test/e2e/interactive/interactive-integration.test.ts index a964034fc..fd2dc9fae 100644 --- a/test/e2e/interactive/interactive-integration.test.ts +++ b/test/e2e/interactive/interactive-integration.test.ts @@ -1,10 +1,11 @@ import { describe, it, expect } from "vitest"; -import { spawn } from "node:child_process"; +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +const binPath = path.join(__dirname, "../../../bin/run.js"); /** * Integration tests for `ably interactive` running as a single process (no bash @@ -16,27 +17,54 @@ const __dirname = path.dirname(__filename); * behaviour and terminal-state/EIO assertions live in * test/tty/commands/interactive-sigint.test.ts (run with `pnpm test:tty`). */ -describe("Interactive Mode - in-process integration", () => { - const binPath = path.join(__dirname, "../../../bin/run.js"); - it("starts and exits cleanly via `exit`", { timeout: 30000 }, async () => { - const proc = spawn("node", [binPath, "interactive"], { - stdio: "pipe", - env: { ...process.env, ABLY_SUPPRESS_WELCOME: "1" }, - }); +interface InteractiveProc { + proc: ChildProcessWithoutNullStreams; + /** Resolves once `substr` appears in combined stdout+stderr, else rejects. */ + waitFor: (substr: string, timeoutMs: number) => Promise; + getOutput: () => string; + hasExited: () => boolean; +} + +function startInteractive( + extraEnv: Record = {}, +): InteractiveProc { + const proc = spawn("node", [binPath, "interactive"], { + stdio: "pipe", + env: { ...process.env, ABLY_SUPPRESS_WELCOME: "1", ...extraEnv }, + }); - let output = ""; - proc.stdout.on("data", (d) => (output += d.toString())); + let output = ""; + let exited = false; + proc.stdout.on("data", (d) => (output += d.toString())); + proc.stderr.on("data", (d) => (output += d.toString())); + proc.on("exit", () => (exited = true)); - await new Promise((resolve) => { + const waitFor = (substr: string, timeoutMs: number) => + new Promise((resolve, reject) => { + const start = Date.now(); const check = setInterval(() => { - if (output.includes("ably>")) { + if (output.includes(substr)) { clearInterval(check); resolve(); + } else if (exited) { + clearInterval(check); + reject(new Error(`process exited before "${substr}"`)); + } else if (Date.now() - start > timeoutMs) { + clearInterval(check); + reject(new Error(`timeout waiting for "${substr}"`)); } }, 100); }); + return { proc, waitFor, getOutput: () => output, hasExited: () => exited }; +} + +describe("Interactive Mode - in-process integration", () => { + it("starts and exits cleanly via `exit`", { timeout: 30000 }, async () => { + const { proc, waitFor, getOutput } = startInteractive(); + + await waitFor("ably>", 8000); proc.stdin.write("exit\n"); const exitCode = await new Promise((resolve) => { @@ -44,40 +72,14 @@ describe("Interactive Mode - in-process integration", () => { }); expect(exitCode).toBe(0); - expect(output).toContain("Goodbye!"); + expect(getOutput()).toContain("Goodbye!"); }); it( "interrupts a running command via SIGINT and stays alive in the same process", { timeout: 30000 }, async () => { - const proc = spawn("node", [binPath, "interactive"], { - stdio: "pipe", - env: { ...process.env, ABLY_SUPPRESS_WELCOME: "1" }, - }); - - let output = ""; - let exited = false; - proc.stdout.on("data", (d) => (output += d.toString())); - proc.stderr.on("data", (d) => (output += d.toString())); - proc.on("exit", () => (exited = true)); - - const waitFor = (substr: string, timeoutMs: number) => - new Promise((resolve, reject) => { - const start = Date.now(); - const check = setInterval(() => { - if (output.includes(substr)) { - clearInterval(check); - resolve(); - } else if (exited) { - clearInterval(check); - reject(new Error(`process exited before "${substr}"`)); - } else if (Date.now() - start > timeoutMs) { - clearInterval(check); - reject(new Error(`timeout waiting for "${substr}"`)); - } - }, 100); - }); + const { proc, waitFor, getOutput, hasExited } = startInteractive(); await waitFor("ably>", 8000); @@ -88,7 +90,7 @@ describe("Interactive Mode - in-process integration", () => { // The process must NOT exit; it should re-prompt and still run commands. await new Promise((r) => setTimeout(r, 500)); - expect(exited).toBe(false); + expect(hasExited()).toBe(false); proc.stdin.write("version\n"); await waitFor("Version:", 6000); @@ -96,35 +98,55 @@ describe("Interactive Mode - in-process integration", () => { proc.stdin.write("exit\n"); await new Promise((resolve) => proc.on("exit", () => resolve())); - expect(output).not.toMatch(/setRawMode EIO|Terminal state corrupted/); + expect(getOutput()).not.toMatch( + /setRawMode EIO|Terminal state corrupted/, + ); }, ); it( - "emits exit code 42 on `exit` under ABLY_WRAPPER_MODE (host restart-loop contract)", + "treats a 0x03 byte at the prompt as Ctrl+C and stays alive (non-TTY data handler)", { timeout: 30000 }, async () => { - const proc = spawn("node", [binPath, "interactive"], { - stdio: "pipe", - env: { - ...process.env, - ABLY_SUPPRESS_WELCOME: "1", - ABLY_WRAPPER_MODE: "1", - }, - }); + // In non-TTY mode readline does NOT turn a 0x03 (ETX) byte into a SIGINT, + // so the stdin data handler in interactive.ts does it. At an idle prompt + // it emits SIGINT to readline, which prints `^C` and a hint to type + // `exit` (it does NOT kill the shell). Note: this only covers the + // at-prompt branch — the running-command branch cannot be exercised over + // piped stdin because readline pauses the stream during command + // execution, so a 0x03 byte never reaches the handler then. Mid-command + // interruption is covered by the real-SIGINT test above (the path a TTY + // and the node-pty-backed web CLI actually use). + const { proc, waitFor, getOutput, hasExited } = startInteractive(); - let output = ""; - proc.stdout.on("data", (d) => (output += d.toString())); + await waitFor("ably>", 8000); + + // Deliver Ctrl+C as the ETX byte over stdin rather than as an OS signal. + proc.stdin.write(Buffer.from([0x03])); + await waitFor("Signal received", 6000); + expect(hasExited()).toBe(false); - await new Promise((resolve) => { - const check = setInterval(() => { - if (output.includes("ably>")) { - clearInterval(check); - resolve(); - } - }, 100); + // The shell is still usable. + proc.stdin.write("exit\n"); + const exitCode = await new Promise((resolve) => { + proc.on("exit", (code) => resolve(code ?? 0)); }); + expect(exitCode).toBe(0); + expect(getOutput()).toContain("Goodbye!"); + expect(getOutput()).not.toMatch( + /setRawMode EIO|Terminal state corrupted/, + ); + }, + ); + + it( + "emits exit code 42 on `exit` under ABLY_WRAPPER_MODE (host restart-loop contract)", + { timeout: 30000 }, + async () => { + const { proc, waitFor } = startInteractive({ ABLY_WRAPPER_MODE: "1" }); + + await waitFor("ably>", 8000); proc.stdin.write("exit\n"); const exitCode = await new Promise((resolve) => {