@camstack/addon-terminal 0.1.1 → 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/dist/addon.js CHANGED
@@ -2,6 +2,8 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  //#region \0rolldown/runtime.js
3
3
  var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
4
4
  //#endregion
5
+ let node_fs = require("node:fs");
6
+ let node_path = require("node:path");
5
7
  //#region ../types/dist/event-category-BLcNejAE.mjs
6
8
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
7
9
  EventCategory["SystemBoot"] = "system.boot";
@@ -26081,6 +26083,40 @@ Object.freeze({
26081
26083
  });
26082
26084
  //#endregion
26083
26085
  //#region src/pty.ts
26086
+ /**
26087
+ * Minimal pty abstraction. The manager depends on this interface, never on
26088
+ * `node-pty` directly, so its tests spawn no real process and the native
26089
+ * module is imported lazily (only when a real session opens on the host).
26090
+ *
26091
+ * `child_process.spawn` is deliberately NOT used: a plain pipe is NOT-A-TTY, so
26092
+ * a full-screen program (`btm`) gets no terminal and no runtime resize channel.
26093
+ * See docs/design/2026-07-26-terminal-session-design.md ("The pty").
26094
+ */
26095
+ /**
26096
+ * Resolve `file` to an executable path the way a shell would: a path-qualified
26097
+ * name is checked directly, a bare name is searched on `PATH`. Returns the
26098
+ * resolved absolute path, or `null` when nothing executable matches — the
26099
+ * caller turns that into a clear "binary not found" error BEFORE spawning,
26100
+ * instead of a pty that dies at 127 and vanishes with no visible cause.
26101
+ */
26102
+ function resolveExecutable(file) {
26103
+ if (file.includes("/")) return isExecutableFile(file) ? file : null;
26104
+ const dirs = (process.env.PATH ?? "").split(node_path.delimiter).filter((d) => d.length > 0);
26105
+ for (const dir of dirs) {
26106
+ const candidate = (0, node_path.join)(dir, file);
26107
+ if (isExecutableFile(candidate)) return candidate;
26108
+ }
26109
+ return null;
26110
+ }
26111
+ function isExecutableFile(p) {
26112
+ try {
26113
+ if (!(0, node_fs.statSync)(p).isFile()) return false;
26114
+ (0, node_fs.accessSync)(p, node_fs.constants.X_OK);
26115
+ return true;
26116
+ } catch {
26117
+ return false;
26118
+ }
26119
+ }
26084
26120
  var cachedModule = null;
26085
26121
  async function loadNodePty() {
26086
26122
  if (cachedModule) return cachedModule;
@@ -26142,7 +26178,7 @@ function parsePath(url) {
26142
26178
  };
26143
26179
  }
26144
26180
  function handleOut(manager, sessionId, req, res) {
26145
- if (!manager.hasSession(sessionId)) {
26181
+ if (!manager.hasEntry(sessionId)) {
26146
26182
  res.writeHead(404, { "content-type": "text/plain" });
26147
26183
  res.end("no such session");
26148
26184
  return;
@@ -26264,6 +26300,13 @@ function findProfile(profiles, profileId) {
26264
26300
  var MIN_GRID = 1;
26265
26301
  var MAX_COLS = 1e3;
26266
26302
  var MAX_ROWS = 500;
26303
+ /**
26304
+ * How long an exited session is kept addressable so a GET /out that arrives
26305
+ * AFTER the pty died still delivers the `exit` event (with its code) to the UI,
26306
+ * instead of a bare 404 that renders as a blank terminal. A process that fails
26307
+ * to start (missing binary → exit 127) is the common case this makes visible.
26308
+ */
26309
+ var EXITED_RETENTION_MS = 1e4;
26267
26310
  function clampDimension(value, max) {
26268
26311
  if (!Number.isFinite(value)) return MIN_GRID;
26269
26312
  const int = Math.floor(value);
@@ -26277,6 +26320,7 @@ var TerminalSessionManager = class {
26277
26320
  profiles;
26278
26321
  mintId;
26279
26322
  now;
26323
+ resolveBinary;
26280
26324
  constructor(opts) {
26281
26325
  this.opts = opts;
26282
26326
  this.profiles = buildProfiles({
@@ -26286,6 +26330,7 @@ var TerminalSessionManager = class {
26286
26330
  });
26287
26331
  this.mintId = opts.mintId ?? (() => crypto.randomUUID());
26288
26332
  this.now = opts.now ?? (() => Date.now());
26333
+ this.resolveBinary = opts.resolveBinary ?? ((file) => file);
26289
26334
  }
26290
26335
  /** Re-derive the allowlist after a settings change. */
26291
26336
  reconfigureProfiles(config) {
@@ -26308,8 +26353,10 @@ var TerminalSessionManager = class {
26308
26353
  const cols = clampDimension(input.cols, MAX_COLS);
26309
26354
  const rows = clampDimension(input.rows, MAX_ROWS);
26310
26355
  const sessionId = this.mintId();
26356
+ const resolvedFile = this.resolveBinary(profile.file);
26357
+ if (resolvedFile === null) throw new Error(`Terminal profile '${profile.profileId}' cannot start: executable '${profile.file}' was not found on PATH. Install it in the server image or set its path in Terminal settings.`);
26311
26358
  const pty = this.opts.spawn({
26312
- file: profile.file,
26359
+ file: resolvedFile,
26313
26360
  args: profile.args,
26314
26361
  cols,
26315
26362
  rows,
@@ -26354,7 +26401,11 @@ var TerminalSessionManager = class {
26354
26401
  } catch {}
26355
26402
  session.sinks.clear();
26356
26403
  session.screen.dispose();
26357
- this.sessions.delete(sessionId);
26404
+ const retire = setTimeout(() => {
26405
+ this.sessions.delete(sessionId);
26406
+ }, EXITED_RETENTION_MS);
26407
+ retire.unref?.();
26408
+ session.retireTimer = retire;
26358
26409
  });
26359
26410
  this.opts.logger.info("terminal: session opened", { meta: {
26360
26411
  sessionId,
@@ -26395,6 +26446,15 @@ var TerminalSessionManager = class {
26395
26446
  return s !== void 0 && !s.exited;
26396
26447
  }
26397
26448
  /**
26449
+ * True when the session is still addressable — including an EXITED one inside
26450
+ * its retention window. `handleOut` gates on this (not `hasSession`) so the
26451
+ * exit event reaches the UI; live-only paths (`writeInput`) gate on
26452
+ * `hasSession`.
26453
+ */
26454
+ hasEntry(sessionId) {
26455
+ return this.sessions.has(sessionId);
26456
+ }
26457
+ /**
26398
26458
  * Attach an output sink. Immediately pushes a full-screen repaint (the current
26399
26459
  * authoritative buffer) so a fresh xterm.js is in sync, then streams live
26400
26460
  * output. Returns a detach function.
@@ -26428,6 +26488,7 @@ var TerminalSessionManager = class {
26428
26488
  /** Kill every live session — called on addon shutdown. */
26429
26489
  disposeAll() {
26430
26490
  for (const session of this.sessions.values()) {
26491
+ if (session.retireTimer !== void 0) clearTimeout(session.retireTimer);
26431
26492
  try {
26432
26493
  session.pty.kill();
26433
26494
  } catch {}
@@ -31290,6 +31351,7 @@ var TerminalAddon = class extends BaseAddon {
31290
31351
  const manager = new TerminalSessionManager({
31291
31352
  spawn: createNodePtySpawner(),
31292
31353
  screenFactory: createXtermScreen,
31354
+ resolveBinary: resolveExecutable,
31293
31355
  logger: this.ctx.logger,
31294
31356
  btmPath: this.config.btmPath,
31295
31357
  allowShell: this.config.allowShell,
package/dist/addon.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import { accessSync, constants, statSync } from "node:fs";
2
+ import { delimiter, join } from "node:path";
1
3
  //#region \0rolldown/runtime.js
2
4
  var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
3
5
  //#endregion
@@ -26080,6 +26082,40 @@ Object.freeze({
26080
26082
  });
26081
26083
  //#endregion
26082
26084
  //#region src/pty.ts
26085
+ /**
26086
+ * Minimal pty abstraction. The manager depends on this interface, never on
26087
+ * `node-pty` directly, so its tests spawn no real process and the native
26088
+ * module is imported lazily (only when a real session opens on the host).
26089
+ *
26090
+ * `child_process.spawn` is deliberately NOT used: a plain pipe is NOT-A-TTY, so
26091
+ * a full-screen program (`btm`) gets no terminal and no runtime resize channel.
26092
+ * See docs/design/2026-07-26-terminal-session-design.md ("The pty").
26093
+ */
26094
+ /**
26095
+ * Resolve `file` to an executable path the way a shell would: a path-qualified
26096
+ * name is checked directly, a bare name is searched on `PATH`. Returns the
26097
+ * resolved absolute path, or `null` when nothing executable matches — the
26098
+ * caller turns that into a clear "binary not found" error BEFORE spawning,
26099
+ * instead of a pty that dies at 127 and vanishes with no visible cause.
26100
+ */
26101
+ function resolveExecutable(file) {
26102
+ if (file.includes("/")) return isExecutableFile(file) ? file : null;
26103
+ const dirs = (process.env.PATH ?? "").split(delimiter).filter((d) => d.length > 0);
26104
+ for (const dir of dirs) {
26105
+ const candidate = join(dir, file);
26106
+ if (isExecutableFile(candidate)) return candidate;
26107
+ }
26108
+ return null;
26109
+ }
26110
+ function isExecutableFile(p) {
26111
+ try {
26112
+ if (!statSync(p).isFile()) return false;
26113
+ accessSync(p, constants.X_OK);
26114
+ return true;
26115
+ } catch {
26116
+ return false;
26117
+ }
26118
+ }
26083
26119
  var cachedModule = null;
26084
26120
  async function loadNodePty() {
26085
26121
  if (cachedModule) return cachedModule;
@@ -26141,7 +26177,7 @@ function parsePath(url) {
26141
26177
  };
26142
26178
  }
26143
26179
  function handleOut(manager, sessionId, req, res) {
26144
- if (!manager.hasSession(sessionId)) {
26180
+ if (!manager.hasEntry(sessionId)) {
26145
26181
  res.writeHead(404, { "content-type": "text/plain" });
26146
26182
  res.end("no such session");
26147
26183
  return;
@@ -26263,6 +26299,13 @@ function findProfile(profiles, profileId) {
26263
26299
  var MIN_GRID = 1;
26264
26300
  var MAX_COLS = 1e3;
26265
26301
  var MAX_ROWS = 500;
26302
+ /**
26303
+ * How long an exited session is kept addressable so a GET /out that arrives
26304
+ * AFTER the pty died still delivers the `exit` event (with its code) to the UI,
26305
+ * instead of a bare 404 that renders as a blank terminal. A process that fails
26306
+ * to start (missing binary → exit 127) is the common case this makes visible.
26307
+ */
26308
+ var EXITED_RETENTION_MS = 1e4;
26266
26309
  function clampDimension(value, max) {
26267
26310
  if (!Number.isFinite(value)) return MIN_GRID;
26268
26311
  const int = Math.floor(value);
@@ -26276,6 +26319,7 @@ var TerminalSessionManager = class {
26276
26319
  profiles;
26277
26320
  mintId;
26278
26321
  now;
26322
+ resolveBinary;
26279
26323
  constructor(opts) {
26280
26324
  this.opts = opts;
26281
26325
  this.profiles = buildProfiles({
@@ -26285,6 +26329,7 @@ var TerminalSessionManager = class {
26285
26329
  });
26286
26330
  this.mintId = opts.mintId ?? (() => crypto.randomUUID());
26287
26331
  this.now = opts.now ?? (() => Date.now());
26332
+ this.resolveBinary = opts.resolveBinary ?? ((file) => file);
26288
26333
  }
26289
26334
  /** Re-derive the allowlist after a settings change. */
26290
26335
  reconfigureProfiles(config) {
@@ -26307,8 +26352,10 @@ var TerminalSessionManager = class {
26307
26352
  const cols = clampDimension(input.cols, MAX_COLS);
26308
26353
  const rows = clampDimension(input.rows, MAX_ROWS);
26309
26354
  const sessionId = this.mintId();
26355
+ const resolvedFile = this.resolveBinary(profile.file);
26356
+ if (resolvedFile === null) throw new Error(`Terminal profile '${profile.profileId}' cannot start: executable '${profile.file}' was not found on PATH. Install it in the server image or set its path in Terminal settings.`);
26310
26357
  const pty = this.opts.spawn({
26311
- file: profile.file,
26358
+ file: resolvedFile,
26312
26359
  args: profile.args,
26313
26360
  cols,
26314
26361
  rows,
@@ -26353,7 +26400,11 @@ var TerminalSessionManager = class {
26353
26400
  } catch {}
26354
26401
  session.sinks.clear();
26355
26402
  session.screen.dispose();
26356
- this.sessions.delete(sessionId);
26403
+ const retire = setTimeout(() => {
26404
+ this.sessions.delete(sessionId);
26405
+ }, EXITED_RETENTION_MS);
26406
+ retire.unref?.();
26407
+ session.retireTimer = retire;
26357
26408
  });
26358
26409
  this.opts.logger.info("terminal: session opened", { meta: {
26359
26410
  sessionId,
@@ -26394,6 +26445,15 @@ var TerminalSessionManager = class {
26394
26445
  return s !== void 0 && !s.exited;
26395
26446
  }
26396
26447
  /**
26448
+ * True when the session is still addressable — including an EXITED one inside
26449
+ * its retention window. `handleOut` gates on this (not `hasSession`) so the
26450
+ * exit event reaches the UI; live-only paths (`writeInput`) gate on
26451
+ * `hasSession`.
26452
+ */
26453
+ hasEntry(sessionId) {
26454
+ return this.sessions.has(sessionId);
26455
+ }
26456
+ /**
26397
26457
  * Attach an output sink. Immediately pushes a full-screen repaint (the current
26398
26458
  * authoritative buffer) so a fresh xterm.js is in sync, then streams live
26399
26459
  * output. Returns a detach function.
@@ -26427,6 +26487,7 @@ var TerminalSessionManager = class {
26427
26487
  /** Kill every live session — called on addon shutdown. */
26428
26488
  disposeAll() {
26429
26489
  for (const session of this.sessions.values()) {
26490
+ if (session.retireTimer !== void 0) clearTimeout(session.retireTimer);
26430
26491
  try {
26431
26492
  session.pty.kill();
26432
26493
  } catch {}
@@ -31289,6 +31350,7 @@ var TerminalAddon = class extends BaseAddon {
31289
31350
  const manager = new TerminalSessionManager({
31290
31351
  spawn: createNodePtySpawner(),
31291
31352
  screenFactory: createXtermScreen,
31353
+ resolveBinary: resolveExecutable,
31292
31354
  logger: this.ctx.logger,
31293
31355
  btmPath: this.config.btmPath,
31294
31356
  allowShell: this.config.allowShell,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-terminal",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Interactive terminal sessions (pty + xterm) as a CamStack addon",
5
5
  "keywords": [
6
6
  "camstack",