Skip to content
Merged
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
47 changes: 30 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,45 +69,58 @@ curl -L -o hsh.tar.gz https://github.com/hoophq/hsh/releases/latest/download/hsh
curl -L -o hsh.tar.gz https://github.com/hoophq/hsh/releases/latest/download/hsh-linux-arm64.tar.gz
```

Then extract and install:
Then extract and install the CLI:

```bash
tar xzf hsh.tar.gz
cd hsh-*/
sudo install -m 0755 hsh /usr/local/bin/ # the CLI
sudo install -m 0755 hsh /usr/local/bin/
```

# Linux: registers hsh-tunneld as a systemd service
./install.sh
Windows: download `hsh-windows-x64.zip` (or `hsh-windows-arm64.zip`), unpack
with Explorer or `Expand-Archive`, and copy `hsh.exe` somewhere on your PATH.
The tunnel daemon has no Windows service support yet, so step 2 does not
apply — the rest of `hsh` works without it.

# macOS: registers hsh-tunneld as a LaunchDaemon
sudo ./hsh-tunneld install
### 2. Install the networking components

```bash
hsh setup
```

On Linux the install script registers `hsh-tunneld` as a systemd service.
On macOS run `sudo ./hsh-tunneld install` directly — it registers the
daemon as a system-wide LaunchDaemon (the `install.sh` wrapper is
currently Linux-only). Either way the installer adds you to the `hsh`
group, which gates access to the daemon's control socket; open a new
login session (or run `newgrp hsh`) for it to take effect.
This downloads the matching `hsh-tunneld` daemon for your OS and
architecture, verifies it against the release's `SHA256SUMS`, and
registers it with your system service manager (systemd on Linux, a
LaunchDaemon on macOS). `sudo` prompts for your password once.

Windows: download `hsh-windows-x64.zip` (or `hsh-windows-arm64.zip`), unpack
with Explorer or `Expand-Archive`, and copy `hsh.exe` somewhere on your PATH.
`hsh login` runs this automatically the first time, so `hsh setup` is
only needed if you want to install the daemon up front, or to reinstall
it later with `hsh setup --force`.

The installer adds you to the `hsh` group, which gates access to the
daemon's control socket. Group membership only applies to new login
sessions, so open a new terminal (or run `newgrp hsh`) afterwards.

Already have the release archive extracted? `sudo ./hsh-tunneld install`
does the same registration on both Linux and macOS. The bundled
`./install.sh` wrapper is a Linux-only convenience around it and refuses
to run on macOS.

### 2. Configure
### 3. Configure

```bash
hsh config set api-url https://your-company.hoop.dev
```

### 3. Authenticate
### 4. Authenticate

```bash
hsh login
```

Opens your browser for OAuth. Done.

### 4. Activate shell plugins
### 5. Activate shell plugins

Add this single line to your shell profile:

Expand Down
62 changes: 58 additions & 4 deletions src/commands/login.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,34 @@
import { Command } from "commander";
import { openSync, closeSync } from "fs";
import { login } from "../auth/manager.ts";
import { loginDaemon } from "./tunnel.ts";
import { daemonInstalled, runSetup } from "./setup.ts";
import { daemonInstallSupported } from "../tunnel/installer.ts";
import { getApiUrl } from "../config/store.ts";
import { error } from "../ui/output.ts";
import { error, info } from "../ui/output.ts";

/**
* Does this process have a controlling terminal sudo can prompt on?
*
* NOT `process.stdin.isTTY`: sudo does not read the password from
* stdin, it opens /dev/tty directly. `hsh login < file` therefore has
* a non-TTY stdin while sudo can still prompt perfectly well — gating
* on stdin would skip setup for anyone who redirects stdin, and (worse)
* would not actually protect the case it was meant to, since a real
* CI runner with no tty is exactly where opening /dev/tty fails.
*
* Probing /dev/tty is what sudo itself does, so this answers the only
* question that matters: can the user be prompted?
*/
function hasControllingTerminal(): boolean {
if (process.platform === "win32") return false;
try {
closeSync(openSync("/dev/tty", "r"));
return true;
} catch {
return false;
}
}

export const loginCommand = new Command("login")
.description("Authenticate with Hoop (and the tunnel daemon if installed)")
Expand All @@ -15,13 +41,17 @@ export const loginCommand = new Command("login")
// "negatable boolean options".
.option(
"--no-tunnel",
"Skip authenticating the tunnel daemon, even if it is installed"
"Skip the tunnel daemon entirely (no install, no daemon login)"
)
.option(
"--no-browser",
"Print the login URL instead of opening the browser (tunnel daemon flow)"
)
.action(async (opts: { tunnel: boolean; browser: boolean }) => {
.option(
"--no-setup",
"Do not install the tunnel daemon when it is missing"
)
.action(async (opts: { tunnel: boolean; browser: boolean; setup: boolean }) => {
const apiUrl = getApiUrl();
if (!apiUrl) {
error("API URL not configured. Run first:");
Expand All @@ -33,7 +63,31 @@ export const loginCommand = new Command("login")
// This is what the kubectl/ssh plugins use. Always runs.
await login();

// Step 2: best-effort daemon login. The hsh-tunneld daemon owns its
// Step 2: install the tunnel daemon if this machine doesn't have
// it yet (DEP-141). A first `hsh login` on a fresh machine is the
// point where the user has just proven they want Hoop access, so
// it is the natural place to ask for the one sudo password the
// setup needs. `--no-setup` opts out explicitly.
//
// `daemonInstallSupported()` keeps the implicit path silent on
// hosts where setup could only ever fail (Windows, exotic arches):
// there is nothing the user could run, and hsh works fine without
// the tunnel. With no controlling terminal we print a hint instead
// of running it — sudo would block on a prompt nobody can answer,
// and a hung pipeline is worse than a missing daemon. An explicit
// `hsh setup` still runs in both cases and reports the real reason.
const setupWanted =
opts.tunnel && opts.setup && !daemonInstalled() && daemonInstallSupported();
if (setupWanted && hasControllingTerminal()) {
console.log();
// A failed install must not be reported as a clean login: the
// user asked for Hoop access and did not fully get it.
if (!(await runSetup())) process.exitCode = 1;
} else if (setupWanted) {
info("Tunnel daemon is not installed. Run `hsh setup` to install it.");
}

// Step 3: best-effort daemon login. The hsh-tunneld daemon owns its
// own token (it does its own gateway round-trip via IPC — we never
// hand it the CLI's token, preserving the daemon-owns-token trust
// model).
Expand Down
125 changes: 125 additions & 0 deletions src/commands/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/**
* `hsh setup` — install the Hoop networking components (DEP-141).
*
* The setup journey a user should get after dropping `hsh` on their
* PATH is: run one command, type their password once, and have the
* tunnel daemon running as a system service. Before this command they
* had to know that a second binary existed, which archive it came in,
* and which of two platform-specific installers to run.
*
* This file owns only the user-facing flow (messages, ordering, exit
* codes). Everything mechanical — asset selection, checksum
* verification, sudo — lives in src/tunnel/installer.ts.
*/

import { Command } from "commander";
import { getApiUrl } from "../config/store.ts";
import { ExitCodes } from "../plugins/exit-codes.ts";
import { TunnelClient } from "../tunnel/ipc-client.ts";
import { DaemonInstallError, installDaemon } from "../tunnel/installer.ts";
import { readControlToken, resolveTokenPath } from "../tunnel/socket-path.ts";
import { debug } from "../ui/log.ts";
import { dim, error, info, success, warn } from "../ui/output.ts";

/**
* The banner DEP-141 specifies verbatim. Kept as an exported constant
* because both `hsh setup` and the implicit setup leg of `hsh login`
* must print the same line — a user should not be able to tell from
* the output which entry point ran.
*/
export const SETUP_BANNER = "Installing Hoop Networking Components";

/**
* Is the tunnel daemon already installed on this machine?
*
* Same probe `hsh login` uses (a readable — or merely present —
* control token proves the daemon or its installer ran here), so the
* two commands can never disagree about whether setup is needed.
*/
export function daemonInstalled(): boolean {
if (readControlToken()) return true;
const tok = resolveTokenPath();
return tok.exists || tok.fromEnv;
Comment on lines +39 to +42

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

4. Env override masks missing daemon 🐞 Bug ≡ Correctness

daemonInstalled() returns true when resolveTokenPath().fromEnv is true even if the token file does
not exist, which can cause hsh login implicit setup (and hsh setup without --force) to skip
installation on machines where the daemon is actually absent but env vars are set.
Agent Prompt
### Issue description
`daemonInstalled()` currently treats `resolveTokenPath().fromEnv` as proof the daemon is installed. But `fromEnv` only means an env var was set (e.g., `HSH_TUNNELD_TOKEN_FILE`), not that the file exists.

This can incorrectly suppress:
- the implicit install path in `hsh login` (setupWanted becomes false), and
- `hsh setup` early-exit messaging (claims already installed),
when the env var points to a missing file.

### Issue Context
`resolveTokenPath()` sets `fromEnv: true` regardless of existence when an override is present.

### Fix Focus Areas
- src/commands/setup.ts[39-43]
- src/tunnel/socket-path.ts[90-108]
- src/commands/login.ts[79-88]

### Implementation guidance
1. Change `daemonInstalled()` to only return true on *actual presence* indicators:
   - `readControlToken()` OR `resolveTokenPath().exists` (and optionally `resolveSocketPath().exists`).
2. If you still need “don’t implicitly install when user has env overrides”, introduce a separate predicate like `daemonEnvOverridesPresent()` and use it only to gate the *implicit* setup path (not the explicit `hsh setup` command).
3. Add tests for:
   - `HSH_TUNNELD_TOKEN_FILE` set to a nonexistent path → `daemonInstalled()` should be false; implicit setup should still be allowed (or should print a clear hint, depending on desired behavior).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

}

/**
* Download + install the daemon, printing progress. Returns false when
* the install failed (the caller sets the exit code); the error and its
* remediation hint have already been rendered.
*/
export async function runSetup(opts: { force?: boolean } = {}): Promise<boolean> {
if (!opts.force && daemonInstalled()) {
success("Hoop networking components are already installed.");
info("Reinstall or upgrade them with: hsh setup --force");
return true;
}

info(SETUP_BANNER);

try {
const result = await installDaemon({ onProgress: (m) => dim(` ${m}`) });
success(`Installed hsh-tunneld ${result.version}.`);
} catch (err) {
if (err instanceof DaemonInstallError) {
error(err.message);
if (err.hint) info(err.hint);
return false;
}
error(`Setup failed: ${err instanceof Error ? err.message : String(err)}`);
return false;
}

// Hand the daemon the gateway URL the CLI already knows, so the user
// does not have to configure the same value twice. Best-effort: the
// installer just added the user to the `hsh` group, and OS group
// membership only applies to NEW login sessions, so in this shell the
// control token is usually still unreadable. That is expected, not a
// failure — we fall through to the "open a new terminal" hint.
const apiUrl = getApiUrl();
let daemonConfigured = false;
if (apiUrl) {
try {
const client = TunnelClient.connect();
const cfg = await client.config();
// A daemon that already points somewhere keeps pointing there:
// silently repointing an existing tunnel at a different gateway
// on a reinstall would be a surprising, hard-to-notice change.
if (!cfg.api_url) {
await client.updateConfig({ api_url: apiUrl });
info(`Daemon gateway set to ${apiUrl}.`);
} else if (cfg.api_url !== apiUrl) {
warn(`Daemon is pointed at ${cfg.api_url}, not the CLI's ${apiUrl}.`);
info(`Change it with: hsh tunnel config set api-url ${apiUrl}`);
} else {
info(`Daemon gateway is ${cfg.api_url}.`);
}
daemonConfigured = true;
} catch (err) {
debug("setup", `daemon config skipped: ${String(err)}`);
}
} else {
warn("No gateway configured yet. Run: hsh config set api-url <url>");
}

console.log();
info("Next steps:");
if (!daemonConfigured) {
// Group membership is the reason the CLI cannot reach the daemon
// from this shell. Say so explicitly — "permission denied on a
// socket" is the single most confusing post-install state.
dim(" 1. Open a new terminal (or run: newgrp hsh) so your new group applies.");
dim(" 2. hsh login");
} else {
dim(" 1. hsh login");
}
dim(" Then: hsh tunnel connections");
return true;
}

export const setupCommand = new Command("setup")
.description("Install the Hoop networking components (hsh-tunneld system service)")
Comment on lines +119 to +120

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

1. setup.ts missing command export 📘 Rule violation ≡ Correctness

The new command file exports setupCommand but does not export a Command object as required,
making command exports inconsistent across src/commands. This can break conventions/tools that
rely on a standard Command export name.
Agent Prompt
## Issue description
Compliance rule requires each file under `src/commands/` to define and export a value named `Command`. The new `src/commands/setup.ts` instead exports `setupCommand`.

## Issue Context
`src/index.ts` currently imports `setupCommand` from `./commands/setup.ts`.

## Fix Focus Areas
- src/commands/setup.ts[119-125]
- src/index.ts[4-4]
- src/index.ts[27-29]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

.option("--force", "Reinstall even if the daemon is already present")
.action(async (opts: { force?: boolean }) => {
const ok = await runSetup({ force: opts.force });
if (!ok) process.exit(ExitCodes.GenericError);
});
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#!/usr/bin/env bun
import { program } from "commander";
import { loginCommand } from "./commands/login.ts";
import { setupCommand } from "./commands/setup.ts";
import { logoutCommand } from "./commands/logout.ts";
import { statusCommand } from "./commands/status.ts";
import { configCommand } from "./commands/config.ts";
Expand All @@ -24,6 +25,7 @@ program
.version(`${VERSION} (hsh-tunneld ${BUNDLED_DAEMON_VERSION})`);

program.addCommand(loginCommand);
program.addCommand(setupCommand);
program.addCommand(logoutCommand);
program.addCommand(statusCommand);
program.addCommand(configCommand);
Expand Down
Loading
Loading