@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 +65 -3
- package/dist/addon.mjs +65 -3
- package/package.json +1 -1
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.
|
|
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:
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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
|
-
|
|
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,
|