cmux-ssh-here 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +48 -19
  2. package/bin.js +39 -34
  3. package/package.json +4 -1
package/README.md CHANGED
@@ -1,46 +1,75 @@
1
1
  # cmux-ssh-here
2
2
 
3
- One command spins up an ephemeral, token-authenticated SSH server on this machine and prints a [cmux SSH deep link](https://cmux.com/en/docs/ssh). Open the link in [cmux](https://cmux.com) and you instantly land in a shell as the current user over the local network — no `sshd` setup, no keys, no passwords.
3
+ **Share a shell on this machine in one command — no SSH setup, no keys, no passwords.**
4
+
5
+ [![npm](https://img.shields.io/npm/v/cmux-ssh-here.svg)](https://www.npmjs.com/package/cmux-ssh-here)
6
+ [![CI](https://github.com/viktor-silakov/cmux-ssh-here/actions/workflows/ci.yml/badge.svg)](https://github.com/viktor-silakov/cmux-ssh-here/actions/workflows/ci.yml)
7
+ [![license](https://img.shields.io/npm/l/cmux-ssh-here.svg)](./LICENSE)
8
+
9
+ `cmux-ssh-here` spins up a throwaway, token-authenticated SSH server and prints a [cmux](https://cmux.com) deep link. Send the link to any device on your LAN, open it in cmux, and you're instantly in a terminal on this machine. When you're done, hit `Ctrl-C` — the server, token, and host key vanish.
4
10
 
5
11
  ```bash
6
12
  npx cmux-ssh-here
7
13
  ```
8
14
 
9
- Output:
15
+ That's it. No `sshd` to configure, no `~/.ssh/authorized_keys` to edit, no firewall dance.
10
16
 
11
- ```
12
- Connect to this machine via cmux over the LAN
13
- (shell as you; Ctrl-C here to stop):
17
+ ---
14
18
 
15
- https://cmux.com/deeplink/ssh?host=192.168.1.42&port=52968&user=wudUKicRFRw3&host-key-policy=accept-new&title=your-mac
16
- ```
19
+ The terminal turns into a live dashboard — the link, a countdown bar for its lifetime, and who's connected:
20
+
21
+ ![cmux-ssh-here dashboard](https://raw.githubusercontent.com/viktor-silakov/cmux-ssh-here/main/assets/dashboard.png)
22
+
23
+ Open the link and cmux connects on its own — one click, straight into the shell:
24
+
25
+ ![Open in cmux](https://raw.githubusercontent.com/viktor-silakov/cmux-ssh-here/main/assets/open-in-cmux.png)
26
+
27
+ ## Why you'll like it
17
28
 
18
- Open the linkcmux connects on its own. The server terminal shows a live dashboard: the current link, a countdown until it regenerates, and the list of connected sessions. `Ctrl-C` stops the server.
29
+ - **Zero setup** one `npx` command, no SSH server administration.
30
+ - 🔑 **No credentials to share** — auth is a one-time token baked into the link.
31
+ - ⏳ **Self-expiring** — the link rotates every 3 minutes; leaked links go stale on their own. Live sessions stay connected.
32
+ - 👀 **Live dashboard** — see the current link, a countdown bar, and every connected client at a glance.
33
+ - 🧩 **Real SSH** — full PTY shell, `scp`/`sftp`, and the exec channel cmux needs to bootstrap remote workspaces.
34
+ - 🪟 **Persistent sessions** — when `tmux` is present, sessions survive disconnects and are shared across connections.
19
35
 
20
- The link (and its token) is valid for **3 minutes**, then a fresh one is generated — already-connected sessions stay alive; only new connections need the new link.
36
+ ## Quick start
37
+
38
+ ```bash
39
+ # on the machine you want to reach
40
+ npx cmux-ssh-here
41
+ ```
42
+
43
+ Then open the printed `https://cmux.com/deeplink/ssh?…` link in cmux on any device on the same network.
21
44
 
22
45
  ## How it works
23
46
 
24
47
  - Its own SSH server (`ssh2`) with an ephemeral host key and token — both live only while the process runs.
25
- - The token is passed in the deep link's `user=`; the server only accepts the correct token.
48
+ - The token rides in the deep link's `user=`; the server accepts only that token, and rotates it every 3 minutes.
26
49
  - cmux deep links deliberately carry no passwords or keys, so the secret is the token itself.
27
- - Full PTY shell via `node-pty`, with window-resize support.
28
- - Real exec channel (raw pipes) and a filesystem-backed SFTP server, so `scp`/`sftp` work this is what lets `cmux ssh` upload and run its remote helper. A shell-only server is not enough for cmux.
29
- - When `tmux` is available, the interactive shell runs inside it: sessions persist and are shared across connections, and the session list (`choose-tree`) is shown on connect. Without `tmux`, it falls back to a plain login shell.
50
+ - Full PTY shell via `node-pty`, a raw-pipe exec channel, and a filesystem-backed SFTP server — together they let `cmux ssh` upload and run its remote helper (a shell-only server isn't enough).
51
+ - With `tmux` available, the interactive shell runs inside it: sessions persist, are shared across connections, and the session list (`choose-tree`) is shown on connect.
52
+
53
+ ## Compatibility
30
54
 
31
- ## Requirements
55
+ | Host running `npx cmux-ssh-here` | Supported |
56
+ | --- | --- |
57
+ | macOS | ✅ |
58
+ | Linux | ✅ |
59
+ | Windows | ❌ — needs a POSIX shell, and cmux's remote daemon has no Windows build |
32
60
 
33
- - Node 18+.
34
- - [cmux](https://cmux.com) on the device you open the link from.
35
- - macOS or Linux on the host sharing the shell. Windows host is not supported.
36
- - `tmux` (optional) for persistent, shared server-side sessions.
61
+ The device you open the link from just needs [cmux](https://cmux.com) installed. Node 18+ is required on the host. `tmux` is optional (for persistent, shared sessions).
37
62
 
38
63
  ## Security
39
64
 
40
- ⚠️ **The token in the link is a bearer secret that grants a shell as your user.** Trusted local network only. Don't publish the link or send it over untrusted channels. Close the terminal and the token and host key are gone.
65
+ ⚠️ **The token in the link is a bearer secret that grants a shell as your user.** Use it on a trusted local network only. Don't publish the link or send it over untrusted channels. Close the terminal and the token and host key are gone.
41
66
 
42
67
  ## Options
43
68
 
44
69
  - `PORT=2222 npx cmux-ssh-here` — fixed port (random free port by default).
45
70
  - `CMUX_SSH_TTL=600 npx cmux-ssh-here` — link/token lifetime in seconds before regeneration (default 180).
46
71
  - `CMUX_SSH_DEBUG=1 npx cmux-ssh-here` — log incoming auth/env/exec/shell requests to stderr (disables the live dashboard).
72
+
73
+ ## License
74
+
75
+ MIT
package/bin.js CHANGED
@@ -19,6 +19,14 @@ import { chmodSync } from "node:fs";
19
19
  import { createRequire } from "node:module";
20
20
  import { dirname, join, resolve } from "node:path";
21
21
 
22
+ // ponytail: the host side needs a POSIX shell + scp/exec semantics, and cmux's
23
+ // own remote daemon only ships linux/darwin/freebsd builds — so a Windows host
24
+ // can't serve `cmux ssh`. Fail fast with a clear message instead of crashing later.
25
+ if (process.platform === "win32") {
26
+ console.error("cmux-ssh-here must run on a macOS or Linux host (POSIX shell required). Windows is not supported.");
27
+ process.exit(1);
28
+ }
29
+
22
30
  // ponytail: node-pty's prebuilt spawn-helper sometimes unpacks without +x
23
31
  // (packaging bug) -> "posix_spawnp failed". Fix it before importing.
24
32
  if (process.platform !== "win32") {
@@ -33,10 +41,10 @@ const { default: pty } = await import("node-pty");
33
41
  // and an ssh client treats a leading-dash username as a flag. Hex is URL/ssh-safe.
34
42
  const TTL_SECONDS = Number(process.env.CMUX_SSH_TTL) || 180; // link/token lifetime before regeneration
35
43
  let token; // secret carried in user=<token>; rotated every TTL_SECONDS
36
- let remaining; // seconds until the next regeneration
44
+ let expiry; // epoch ms when the current token expires
37
45
  const regenerateToken = () => {
38
46
  token = randomBytes(12).toString("hex");
39
- remaining = TTL_SECONDS;
47
+ expiry = Date.now() + TTL_SECONDS * 1000;
40
48
  };
41
49
  regenerateToken();
42
50
 
@@ -50,7 +58,9 @@ const { privateKey } = generateKeyPairSync("rsa", {
50
58
  publicKeyEncoding: { type: "spki", format: "pem" },
51
59
  });
52
60
 
53
- const shellPath = process.env.SHELL || "/bin/zsh";
61
+ // ponytail: $SHELL is set on real logins; the fallback only matters in bare
62
+ // envs — bash on Linux, zsh on macOS.
63
+ const shellPath = process.env.SHELL || (process.platform === "darwin" ? "/bin/zsh" : "/bin/bash");
54
64
  const debug = process.env.CMUX_SSH_DEBUG ? (...a) => console.error("[debug]", ...a) : () => {};
55
65
 
56
66
  const lanIP = () =>
@@ -251,13 +261,11 @@ const server = new Server(serverCfg, (client, info) => {
251
261
  return ctx.username === token ? ctx.accept() : ctx.reject();
252
262
  });
253
263
  client.on("ready", () => {
264
+ // ponytail: just track the connection; the 5s timer redraws — no per-event
265
+ // repaint (that was the screen-churn that broke copying).
254
266
  const id = nextSid++;
255
267
  sessions.set(id, { ip: info?.ip || "?", since: Date.now() });
256
- render();
257
- client.on("close", () => {
258
- sessions.delete(id);
259
- render();
260
- });
268
+ client.on("close", () => sessions.delete(id));
261
269
  });
262
270
  client.on("session", (accept) => {
263
271
  const session = accept();
@@ -324,16 +332,18 @@ server.listen(PORT, "0.0.0.0", function () {
324
332
 
325
333
  // ponytail: in debug mode skip the dashboard so logs stay readable.
326
334
  const liveUI = !process.env.CMUX_SSH_DEBUG;
335
+ const REFRESH_MS = 5000; // ponytail: 5s cadence — long enough to select & copy the link
327
336
  const ago = (since) => `${Math.floor((Date.now() - since) / 1000)}s`;
337
+ const remainingSec = () => Math.max(0, Math.ceil((expiry - Date.now()) / 1000));
328
338
 
329
- // ponytail: never clear the whole screen (that wipes the user's selection and
330
- // makes the link impossible to copy). Repaint the static block only on real
331
- // events (link rotation, session add/remove); the per-second countdown rewrites
332
- // just its own line via "\r", leaving the link above untouched and selectable.
333
- const countdownLine = () => ` ↻ Link regenerates in ${remaining}s — copy it above. Ctrl-C to stop.`;
339
+ // Decreasing progress bar for the link's remaining lifetime.
340
+ const bar = (rem) => {
341
+ const W = 28;
342
+ const filled = Math.round((rem / TTL_SECONDS) * W);
343
+ return `[${"█".repeat(filled)}${"░".repeat(W - filled)}]`;
344
+ };
334
345
 
335
- // Collapse the raw SSH connections (cmux opens several from one machine:
336
- // ControlMaster, the daemon channel, short-lived probes) into one row per IP.
346
+ // Collapse cmux's several SSH connections from one machine into one row per IP.
337
347
  const sessionRows = () => {
338
348
  const byIp = new Map();
339
349
  for (const s of sessions.values()) {
@@ -346,7 +356,9 @@ server.listen(PORT, "0.0.0.0", function () {
346
356
  );
347
357
  };
348
358
 
349
- const block = () => {
359
+ render = () => {
360
+ if (!liveUI) return;
361
+ const rem = remainingSec();
350
362
  const lines = [
351
363
  "",
352
364
  ` cmux-ssh-here — shell as ${user} over the LAN`,
@@ -354,33 +366,26 @@ server.listen(PORT, "0.0.0.0", function () {
354
366
  " Open in cmux:",
355
367
  ` ${buildLink()}`,
356
368
  "",
369
+ ` Link valid ${bar(rem)} ${rem}s`,
370
+ "",
357
371
  ];
358
372
  const rows = sessionRows();
359
373
  if (rows.length) lines.push(` Connected (${rows.length}):`, ...rows);
360
374
  else lines.push(" No active sessions yet.");
361
- lines.push("");
362
- return lines.join("\n");
363
- };
364
-
365
- // Repaint the block, then leave the cursor parked on the countdown line.
366
- render = () => {
367
- if (!liveUI) return;
368
- process.stdout.write(`\n${block()}\n${countdownLine()}`);
375
+ lines.push("", " Updates every 5s · Ctrl-C to stop.", "");
376
+ // Home + clear-to-end (not full \x1b[2J): redraw in the same spot every 5s.
377
+ process.stdout.write(`\x1b[H\x1b[J${lines.join("\n")}\n`);
369
378
  };
370
379
 
371
380
  if (liveUI) render();
372
- else console.log(`\n Open in cmux (regenerates in ${remaining}s):\n ${buildLink()}\n`);
381
+ else console.log(`\n Open in cmux (regenerates in ${remainingSec()}s):\n ${buildLink()}\n`);
373
382
 
383
+ // Refresh every 5s: regenerate the link when it expires, then redraw.
374
384
  setInterval(() => {
375
- remaining--;
376
- if (remaining <= 0) {
385
+ if (remainingSec() <= 0) {
377
386
  regenerateToken();
378
- // Link changed: repaint the whole block (rare — once per TTL).
379
- if (liveUI) render();
380
- else console.log(`\n [link regenerated]\n ${buildLink()}\n`);
381
- return;
387
+ if (!liveUI) console.log(`\n [link regenerated]\n ${buildLink()}\n`);
382
388
  }
383
- // Common case: rewrite only the countdown line in place — no screen churn.
384
- if (liveUI) process.stdout.write(`\r\x1b[2K${countdownLine()}`);
385
- }, 1000);
389
+ render();
390
+ }, REFRESH_MS);
386
391
  });
package/package.json CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "cmux-ssh-here",
3
- "version": "0.4.1",
3
+ "version": "0.6.0",
4
4
  "description": "Spin up a token-auth SSH server and print a cmux deep link to connect over LAN",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "cmux-ssh-here": "bin.js"
8
8
  },
9
+ "scripts": {
10
+ "test": "node --test"
11
+ },
9
12
  "files": [
10
13
  "bin.js",
11
14
  "README.md"