@termfleet/terminal 0.1.0 → 0.1.2

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/README.md CHANGED
@@ -29,3 +29,7 @@ Created sessions can carry an app-namespaced ownership mark; discovery does not
29
29
  claim or terminate unmarked sessions. Terminal attachment is intentionally
30
30
  separate from session termination: disposing a viewer only detaches its
31
31
  ephemeral PTY client and releases all handles.
32
+
33
+ `createSession` accepts a structured `{ program, arguments }` command plus an
34
+ environment map. Arguments are passed directly without constructing a shell
35
+ command, and the credential bootstrap deletes itself before the command starts.
@@ -13,6 +13,7 @@ export type TmuxTerminalSocketOptions<TAuthorization = void> = {
13
13
  cwd?: string;
14
14
  errorLabel?: string;
15
15
  onInput?: (data: string, authorization: TAuthorization | undefined) => Promise<void> | void;
16
+ readOnly?: boolean;
16
17
  rows?: number;
17
18
  socket: WebSocket;
18
19
  terminalId: string;
@@ -1,6 +1,6 @@
1
1
  import pty from "@homebridge/node-pty-prebuilt-multiarch";
2
2
  import fs from "node:fs";
3
- import { assertSession, getWindowSize } from "./tmux.js";
3
+ import { assertSession, getStatusLineCount, getWindowSize } from "./tmux.js";
4
4
  export class TmuxTerminalAttachError extends Error {
5
5
  phase;
6
6
  constructor(phase, cause) {
@@ -25,8 +25,13 @@ export function attachTmuxTerminalSocket(options) {
25
25
  catch (error) {
26
26
  throw new TmuxTerminalAttachError("resolve", error);
27
27
  }
28
- const cols = options.cols ?? tmuxSize.width;
29
- const rows = options.rows ?? tmuxSize.height;
28
+ // Observers inherit the durable pane dimensions exactly. Even an ignore-size tmux client can
29
+ // become the size source when it is the only attached client, so accepting the browser viewport
30
+ // here would silently reflow someone else's terminal merely by opening a read-only mirror.
31
+ const cols = options.readOnly ? tmuxSize.width : options.cols ?? tmuxSize.width;
32
+ const rows = options.readOnly
33
+ ? tmuxSize.height + getStatusLineCount(options.terminalId, options.tmuxSocket)
34
+ : options.rows ?? tmuxSize.height;
30
35
  let reconciledPty;
31
36
  try {
32
37
  reconciledPty = spawnReconciledPty("tmux", [
@@ -34,6 +39,7 @@ export function attachTmuxTerminalSocket(options) {
34
39
  "-T",
35
40
  "RGB",
36
41
  "attach-session",
42
+ ...(options.readOnly ? ["-r"] : []),
37
43
  "-t",
38
44
  options.terminalId
39
45
  ], {
package/dist/tmux.d.ts CHANGED
@@ -9,7 +9,12 @@ export type TmuxWindowSize = {
9
9
  width: number;
10
10
  height: number;
11
11
  };
12
+ export type TmuxCommand = {
13
+ arguments?: string[];
14
+ program: string;
15
+ };
12
16
  export type CreateSessionOptions = {
17
+ command?: TmuxCommand;
13
18
  cwd?: string;
14
19
  env?: Record<string, string>;
15
20
  name: string;
@@ -21,7 +26,7 @@ export type CreateSessionOptions = {
21
26
  export declare function assertTmux(): void;
22
27
  export declare function targetForSession(sessionName: string): string;
23
28
  export declare function assertSession(name: string, socket?: string): void;
24
- export declare function createSession({ cwd, env, name, owner, ownerOption: ownershipOption, panes, socket }: CreateSessionOptions): Promise<{
29
+ export declare function createSession({ command, cwd, env, name, owner, ownerOption: ownershipOption, panes, socket }: CreateSessionOptions): Promise<{
25
30
  name: string;
26
31
  panes: number;
27
32
  }>;
@@ -92,6 +97,7 @@ export declare function getWindowSize({ name, socket, window }: {
92
97
  window?: number;
93
98
  socket?: string;
94
99
  }): TmuxWindowSize;
100
+ export declare function getStatusLineCount(name: string, socket?: string): number;
95
101
  export declare function listWindowSizesAsync(socket?: string): Promise<Map<string, TmuxWindowSize>>;
96
102
  export declare function setPaneStyle(target: string, style: string, socket?: string): Promise<void>;
97
103
  export declare function clearPaneStyle(target: string, socket?: string): Promise<void>;
package/dist/tmux.js CHANGED
@@ -40,7 +40,7 @@ export function assertSession(name, socket) {
40
40
  // createWindowSession), so every tmux invocation here uses runAsync (spawn, not
41
41
  // spawnSync) and the pane-ready poll below uses sleepAsync — see waitForPanesReady.
42
42
  // Nothing in this function may block the loop.
43
- export async function createSession({ cwd, env, name, owner, ownerOption: ownershipOption = ownerOption, panes, socket }) {
43
+ export async function createSession({ command, cwd, env, name, owner, ownerOption: ownershipOption = ownerOption, panes, socket }) {
44
44
  assertTmux();
45
45
  if (!name) {
46
46
  throw new Error("--name is required.");
@@ -48,6 +48,9 @@ export async function createSession({ cwd, env, name, owner, ownerOption: owners
48
48
  if (!Number.isInteger(panes) || panes < 1) {
49
49
  throw new Error("--panes must be a positive integer.");
50
50
  }
51
+ if (command !== undefined && !command.program) {
52
+ throw new Error("A command program is required.");
53
+ }
51
54
  // Global server options must be in place before the first pane spawns —
52
55
  // history-limit is read at pane creation. On a fresh dedicated `-L` socket no
53
56
  // server exists yet and `set-option -g` will NOT start one (only commands like
@@ -58,11 +61,13 @@ export async function createSession({ cwd, env, name, owner, ownerOption: owners
58
61
  const sessionEnv = env ?? {};
59
62
  const bootstrapDirectories = [];
60
63
  const paneBootstrapArgs = (pane) => {
64
+ const commandArgs = command === undefined ? [] : [command.program, ...(command.arguments ?? [])];
61
65
  if (Object.keys(sessionEnv).length === 0)
62
- return [];
66
+ return commandArgs;
63
67
  const directory = mkdtempSync(join(tmpdir(), "tmux-session-env-"));
64
68
  bootstrapDirectories.push(directory);
65
- return writeSessionEnvironmentBootstrap(directory, pane, sessionEnv);
69
+ const shell = process.env.SHELL ?? "/bin/sh";
70
+ return writeSessionEnvironmentBootstrap(directory, pane, sessionEnv, commandArgs.length === 0 ? [shell, "-l"] : commandArgs);
66
71
  };
67
72
  try {
68
73
  const startArgs = [...sessionServerOptionArgs(), "new-session", "-d", "-s", name];
@@ -271,10 +276,9 @@ function tmuxClientEnvironment(source = process.env) {
271
276
  }
272
277
  return environment;
273
278
  }
274
- function writeSessionEnvironmentBootstrap(directory, pane, environment) {
279
+ function writeSessionEnvironmentBootstrap(directory, pane, environment, command) {
275
280
  const environmentPath = join(directory, `pane-${pane}.env`);
276
281
  const wrapperPath = join(directory, `pane-${pane}.sh`);
277
- const shell = process.env.SHELL ?? "/bin/sh";
278
282
  const lines = Object.entries(environment).map(([name, value]) => {
279
283
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
280
284
  throw new Error(`Invalid session environment variable name: ${JSON.stringify(name)}.`);
@@ -284,15 +288,17 @@ function writeSessionEnvironmentBootstrap(directory, pane, environment) {
284
288
  writeFileSync(environmentPath, `${lines.join("\n")}\n`, { flag: "wx", mode: 0o600 });
285
289
  writeFileSync(wrapperPath, [
286
290
  "#!/bin/sh",
291
+ 'environment_path="$1"',
292
+ "shift",
287
293
  "set -a",
288
- '. "$1"',
294
+ '. "$environment_path"',
289
295
  "set +a",
290
- 'rm -f -- "$1" "$0"',
296
+ 'rm -f -- "$environment_path" "$0"',
291
297
  'rmdir -- "$(dirname -- "$0")" 2>/dev/null || true',
292
- 'exec "$2" -l',
298
+ 'exec "$@"',
293
299
  ""
294
300
  ].join("\n"), { flag: "wx", mode: 0o700 });
295
- return [wrapperPath, environmentPath, shell];
301
+ return [wrapperPath, environmentPath, ...command];
296
302
  }
297
303
  function shellQuote(value) {
298
304
  return `'${value.replaceAll("'", `'"'"'`)}'`;
@@ -508,6 +514,19 @@ export function getWindowSize({ name, socket, window = 0 }) {
508
514
  }
509
515
  return { height, width };
510
516
  }
517
+ export function getStatusLineCount(name, socket) {
518
+ assertSession(name, socket);
519
+ const value = run("tmux", tmuxArgs(socket, ["show-options", "-Aqv", "-t", name, "status"])).trim();
520
+ if (value === "off")
521
+ return 0;
522
+ if (value === "on")
523
+ return 1;
524
+ const lines = Number(value);
525
+ if (!Number.isInteger(lines) || lines < 0 || lines > 5) {
526
+ throw new Error(`Could not determine tmux status height for ${name}.`);
527
+ }
528
+ return lines;
529
+ }
511
530
  // Every window-0 size in ONE async exec, keyed by session. The observe loop uses
512
531
  // this to size windows off the event loop instead of a synchronous getWindowSize
513
532
  // per window — that per-window fan-out blocked the provider loop under a large fleet
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@termfleet/terminal",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Framework-neutral terminal transport and durable tmux session substrate extracted from Termfleet.",
5
5
  "keywords": [
6
6
  "pty",