@hyperfixation/cli 0.1.1 → 0.1.3

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.
package/dist/runner.d.ts CHANGED
@@ -22,8 +22,13 @@ export interface Tunnel {
22
22
  */
23
23
  export interface Runner {
24
24
  exec(command: readonly string[], options?: ExecOptions): Promise<ExecResult>;
25
- /** Forwards a local port to `127.0.0.1:<remotePort>` on the far side. */
26
- tunnel(remotePort: number): Promise<Tunnel>;
25
+ /**
26
+ * Forwards a local port to `<remoteHost>:<remotePort>` as the far side sees it.
27
+ *
28
+ * `remoteHost` defaults to the far side's own loopback; it is an address on a network the far
29
+ * side can route to, which is how a container that publishes nothing is still reachable.
30
+ */
31
+ tunnel(remotePort: number, remoteHost?: string): Promise<Tunnel>;
27
32
  }
28
33
  export declare class RunnerError extends Error {
29
34
  constructor(message: string, options?: {
@@ -32,7 +37,9 @@ export declare class RunnerError extends Error {
32
37
  }
33
38
  /** The argv `exec` runs, `ssh` excluded — the array the test asserts against. */
34
39
  export declare function sshExecArgv(host: string, command: readonly string[]): string[];
35
- export declare function sshTunnelArgv(host: string, localPort: number, remotePort: number): string[];
40
+ export declare function sshTunnelArgv(host: string, localPort: number, remotePort: number, remoteHost?: string): string[];
41
+ /** Where a forward lands when the caller names no host: the far side's own loopback. */
42
+ export declare const TUNNEL_LOOPBACK = "127.0.0.1";
36
43
  /** Single-quotes one word for a POSIX remote shell. */
37
44
  export declare function shellQuote(command: readonly string[]): string;
38
45
  export interface SshRunnerOptions {
package/dist/runner.js CHANGED
@@ -37,17 +37,20 @@ export function sshExecArgv(host, command) {
37
37
  // re-quoted for that shell; nothing else in this file ever builds a shell word.
38
38
  return ["-T", ...SSH_OPTIONS, host, shellQuote(command)];
39
39
  }
40
- export function sshTunnelArgv(host, localPort, remotePort) {
40
+ export function sshTunnelArgv(host, localPort, remotePort, remoteHost = TUNNEL_LOOPBACK) {
41
41
  assertHost(host);
42
+ assertTunnelHost(remoteHost);
42
43
  return [
43
44
  "-N",
44
45
  "-T",
45
46
  ...SSH_OPTIONS,
46
47
  "-L",
47
- `${String(localPort)}:127.0.0.1:${String(remotePort)}`,
48
+ `${String(localPort)}:${remoteHost}:${String(remotePort)}`,
48
49
  host,
49
50
  ];
50
51
  }
52
+ /** Where a forward lands when the caller names no host: the far side's own loopback. */
53
+ export const TUNNEL_LOOPBACK = "127.0.0.1";
51
54
  /** Single-quotes one word for a POSIX remote shell. */
52
55
  export function shellQuote(command) {
53
56
  return command.map((word) => `'${word.replaceAll("'", `'\\''`)}'`).join(" ");
@@ -59,9 +62,9 @@ export function createSshRunner(options) {
59
62
  assertHost(options.host);
60
63
  return {
61
64
  exec: async (command, execOptions) => await spawnCollecting(ssh, sshExecArgv(options.host, command), execOptions),
62
- tunnel: async (remotePort) => {
65
+ tunnel: async (remotePort, remoteHost = TUNNEL_LOOPBACK) => {
63
66
  const localPort = await freeLocalPort();
64
- const child = spawn(ssh, sshTunnelArgv(options.host, localPort, remotePort), {
67
+ const child = spawn(ssh, sshTunnelArgv(options.host, localPort, remotePort, remoteHost), {
65
68
  stdio: ["ignore", "ignore", "pipe"],
66
69
  });
67
70
  let stderr = "";
@@ -75,7 +78,7 @@ export function createSshRunner(options) {
75
78
  catch (cause) {
76
79
  child.kill("SIGTERM");
77
80
  await exited;
78
- throw new RunnerError(`ssh -L ${String(localPort)}:127.0.0.1:${String(remotePort)} never became ready` +
81
+ throw new RunnerError(`ssh -L ${String(localPort)}:${remoteHost}:${String(remotePort)} never became ready` +
79
82
  (stderr === "" ? "" : `: ${stderr.trim()}`), { cause });
80
83
  }
81
84
  return {
@@ -131,14 +134,20 @@ async function spawnCollecting(bin, args, options = {}) {
131
134
  child.stderr?.on("data", (chunk) => {
132
135
  stderr += chunk.toString("utf8");
133
136
  });
134
- // A child that exits before reading its stdin (`true`, a refused ssh) makes the write fail with
135
- // EPIPE; the exit code already says what happened, so it must not surface as an unhandled error.
136
- child.stdin?.on("error", () => { });
137
- child.stdin?.end(options.input ?? "");
138
- const code = await new Promise((resolve, reject) => {
137
+ const closed = new Promise((resolve, reject) => {
138
+ // A child that exits before reading its stdin (`true`, a refused ssh) makes the write fail with
139
+ // EPIPE; the exit code already says what happened. Any other stdin error is a real failure.
140
+ child.stdin?.on("error", (error) => {
141
+ if (error.code === "EPIPE")
142
+ return;
143
+ child.kill();
144
+ reject(error);
145
+ });
139
146
  child.once("error", reject);
140
147
  child.once("close", (exitCode) => resolve(exitCode));
141
148
  });
149
+ child.stdin?.end(options.input ?? "");
150
+ const code = await closed;
142
151
  return { code, stdout, stderr };
143
152
  }
144
153
  /**
@@ -192,6 +201,19 @@ async function canConnect(port) {
192
201
  socket.once("timeout", () => done(false));
193
202
  });
194
203
  }
204
+ /**
205
+ * The far-side end of a `-L` forward, which is a bare address or hostname and nothing else.
206
+ *
207
+ * It arrives from `docker inspect` on the box rather than from the operator, and `-L` takes its
208
+ * three fields colon-separated, so a value carrying a colon or a space would silently become a
209
+ * different forward than the one asked for.
210
+ */
211
+ function assertTunnelHost(remoteHost) {
212
+ if (!TUNNEL_HOST.test(remoteHost)) {
213
+ throw new RunnerError(`a tunnel's remote host must match ${TUNNEL_HOST.source}, got ${JSON.stringify(remoteHost)}`);
214
+ }
215
+ }
216
+ const TUNNEL_HOST = /^[A-Za-z0-9._-]+$/;
195
217
  function assertHost(host) {
196
218
  if (!SSH_HOST.test(host)) {
197
219
  throw new RunnerError(`HF_SSH_HOST must match ${SSH_HOST.source}, got ${JSON.stringify(host)}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperfixation/cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "license": "MIT",
5
5
  "description": "The hf binary and its Turborepo generator templates",
6
6
  "repository": {
@@ -29,15 +29,15 @@
29
29
  "!dist/test-support/**"
30
30
  ],
31
31
  "dependencies": {
32
- "@hyperfixation/auth": "0.1.1",
33
- "@hyperfixation/core": "0.1.1",
34
- "@hyperfixation/db": "0.1.1",
32
+ "@hyperfixation/auth": "0.1.3",
33
+ "@hyperfixation/core": "0.1.3",
34
+ "@hyperfixation/db": "0.1.3",
35
35
  "giget": "3.3.1",
36
36
  "pg": "^8.23.0"
37
37
  },
38
38
  "devDependencies": {
39
- "@hyperfixation/eslint-config": "0.1.1",
40
- "@hyperfixation/testing": "0.1.1",
39
+ "@hyperfixation/eslint-config": "0.1.3",
40
+ "@hyperfixation/testing": "0.1.3",
41
41
  "@microsoft/api-extractor": "^7.59.1",
42
42
  "@types/pg": "^8.23.1",
43
43
  "eslint": "^10.10.0",