@camstack/addon-terminal 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/addon.js +150 -7
- package/dist/addon.mjs +150 -7
- 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";
|
|
@@ -7505,16 +7507,23 @@ var StorageLocationDeclarationSchema = object({
|
|
|
7505
7507
|
* Which node root the seeded `<id>:default` instance is placed under on a
|
|
7506
7508
|
* FRESH install:
|
|
7507
7509
|
* - `'data'` (default) — the node's data dir (`CAMSTACK_DATA` / boot dir),
|
|
7508
|
-
* the appData volume. Right for small/durable data (
|
|
7510
|
+
* the appData volume. Right for small/durable data (logs, models).
|
|
7509
7511
|
* - `'media'` — the dedicated media volume (`CAMSTACK_MEDIA_ROOT`) when that
|
|
7510
7512
|
* env is set, else falls back to the data root. Right for bulky, hot media
|
|
7511
7513
|
* (recordings, event media) that should stay off the appData disk.
|
|
7514
|
+
* - `'backup'` — the dedicated backup volume (`CAMSTACK_BACKUP_ROOT`, default
|
|
7515
|
+
* `/backups` in the image) so archives live on their own mount rather than
|
|
7516
|
+
* filling the appData disk. Falls back to the data root when unset.
|
|
7512
7517
|
*
|
|
7513
7518
|
* Only affects the seeded default's `basePath`; operators can repoint any
|
|
7514
7519
|
* location afterwards, and a `defaultsTo` slot inherits its parent's root
|
|
7515
7520
|
* regardless of this field. Absent (the common case) is treated as `'data'`.
|
|
7516
7521
|
*/
|
|
7517
|
-
defaultRoot: _enum([
|
|
7522
|
+
defaultRoot: _enum([
|
|
7523
|
+
"data",
|
|
7524
|
+
"media",
|
|
7525
|
+
"backup"
|
|
7526
|
+
]).optional()
|
|
7518
7527
|
});
|
|
7519
7528
|
var DecoderStatsSchema = object({
|
|
7520
7529
|
inputFps: number(),
|
|
@@ -14526,11 +14535,53 @@ var LocationStatSchema = object({
|
|
|
14526
14535
|
fileCount: number(),
|
|
14527
14536
|
present: boolean()
|
|
14528
14537
|
});
|
|
14538
|
+
/**
|
|
14539
|
+
* A backup schedule — the N:M "entry" that binds one cron cadence to a
|
|
14540
|
+
* SET of destination locations. Supersedes the per-location cron on
|
|
14541
|
+
* `BackupDestinationPolicy`: an operator creates a schedule, picks the
|
|
14542
|
+
* `backups` locations it should write to, and the orchestrator fans a
|
|
14543
|
+
* single archive out to all of them when the cron fires.
|
|
14544
|
+
*
|
|
14545
|
+
* `retentionCount` is per-schedule (D-decision 2026-07-28): every
|
|
14546
|
+
* location targeted by this schedule keeps this many archives from
|
|
14547
|
+
* this schedule's runs.
|
|
14548
|
+
*
|
|
14549
|
+
* `dataSources` optionally narrows which top-level state locations
|
|
14550
|
+
* (db, addons, tls, …) are archived; omitted = the orchestrator's
|
|
14551
|
+
* default full set.
|
|
14552
|
+
*/
|
|
14553
|
+
var BackupScheduleSchema = object({
|
|
14554
|
+
/** Stable id. Generated by the orchestrator on first upsert if absent. */
|
|
14555
|
+
id: string(),
|
|
14556
|
+
/** Operator-facing display name. */
|
|
14557
|
+
label: string(),
|
|
14558
|
+
/** 5-field POSIX cron. Empty = disabled cadence (kept for editing). */
|
|
14559
|
+
cron: string(),
|
|
14560
|
+
/** Master on/off toggle for the whole schedule. */
|
|
14561
|
+
enabled: boolean(),
|
|
14562
|
+
/** `backups`-location ids this schedule writes to (fan-out set). */
|
|
14563
|
+
locationIds: array(string()).readonly(),
|
|
14564
|
+
/** Archives kept per targeted location for this schedule. */
|
|
14565
|
+
retentionCount: number().int().min(1).max(1e3),
|
|
14566
|
+
/** Optional subset of source locations to include; omitted = all. */
|
|
14567
|
+
dataSources: array(string()).readonly().optional(),
|
|
14568
|
+
/** ms-epoch of last successful run. */
|
|
14569
|
+
lastRunAt: number().optional(),
|
|
14570
|
+
/** ms-epoch of next computed firing (read-only, filled on list). */
|
|
14571
|
+
nextRunAt: number().optional()
|
|
14572
|
+
});
|
|
14529
14573
|
method(_void(), array(BackupDestinationInfoSchema).readonly(), { auth: "admin" }), method(object({
|
|
14530
14574
|
/** Subset of registered `backup-destination` addon ids to write to. */
|
|
14531
14575
|
destinations: array(string()).optional(),
|
|
14532
14576
|
locations: array(string()).optional(),
|
|
14533
|
-
label: string().optional()
|
|
14577
|
+
label: string().optional(),
|
|
14578
|
+
/**
|
|
14579
|
+
* Per-run retention override applied to every targeted
|
|
14580
|
+
* destination. Used by schedule-driven runs (per-entry
|
|
14581
|
+
* retention). Omitted = each destination's own policy
|
|
14582
|
+
* retention (manual runs).
|
|
14583
|
+
*/
|
|
14584
|
+
retentionCount: number().int().min(1).max(1e3).optional()
|
|
14534
14585
|
}).optional(), array(BackupEntrySchema).readonly(), {
|
|
14535
14586
|
kind: "mutation",
|
|
14536
14587
|
auth: "admin"
|
|
@@ -14579,7 +14630,21 @@ method(_void(), array(BackupDestinationInfoSchema).readonly(), { auth: "admin" }
|
|
|
14579
14630
|
ok: boolean(),
|
|
14580
14631
|
error: string().optional(),
|
|
14581
14632
|
nextRuns: array(number()).readonly()
|
|
14582
|
-
}))
|
|
14633
|
+
})), method(_void(), array(BackupScheduleSchema).readonly(), { auth: "admin" }), method(object({
|
|
14634
|
+
id: string().optional(),
|
|
14635
|
+
label: string(),
|
|
14636
|
+
cron: string(),
|
|
14637
|
+
enabled: boolean(),
|
|
14638
|
+
locationIds: array(string()).readonly(),
|
|
14639
|
+
retentionCount: number().int().min(1).max(1e3),
|
|
14640
|
+
dataSources: array(string()).readonly().optional()
|
|
14641
|
+
}), BackupScheduleSchema, {
|
|
14642
|
+
kind: "mutation",
|
|
14643
|
+
auth: "admin"
|
|
14644
|
+
}), method(object({ id: string() }), _void(), {
|
|
14645
|
+
kind: "mutation",
|
|
14646
|
+
auth: "admin"
|
|
14647
|
+
});
|
|
14583
14648
|
/**
|
|
14584
14649
|
* `broker` — unified pub/sub broker registry, system-scoped collection.
|
|
14585
14650
|
*
|
|
@@ -21701,6 +21766,12 @@ Object.freeze({
|
|
|
21701
21766
|
addonId: null,
|
|
21702
21767
|
access: "delete"
|
|
21703
21768
|
},
|
|
21769
|
+
"backup.deleteSchedule": {
|
|
21770
|
+
capName: "backup",
|
|
21771
|
+
capScope: "system",
|
|
21772
|
+
addonId: null,
|
|
21773
|
+
access: "delete"
|
|
21774
|
+
},
|
|
21704
21775
|
"backup.getEntries": {
|
|
21705
21776
|
capName: "backup",
|
|
21706
21777
|
capScope: "system",
|
|
@@ -21731,6 +21802,12 @@ Object.freeze({
|
|
|
21731
21802
|
addonId: null,
|
|
21732
21803
|
access: "view"
|
|
21733
21804
|
},
|
|
21805
|
+
"backup.listSchedules": {
|
|
21806
|
+
capName: "backup",
|
|
21807
|
+
capScope: "system",
|
|
21808
|
+
addonId: null,
|
|
21809
|
+
access: "view"
|
|
21810
|
+
},
|
|
21734
21811
|
"backup.previewSchedule": {
|
|
21735
21812
|
capName: "backup",
|
|
21736
21813
|
capScope: "system",
|
|
@@ -21755,6 +21832,12 @@ Object.freeze({
|
|
|
21755
21832
|
addonId: null,
|
|
21756
21833
|
access: "create"
|
|
21757
21834
|
},
|
|
21835
|
+
"backup.upsertSchedule": {
|
|
21836
|
+
capName: "backup",
|
|
21837
|
+
capScope: "system",
|
|
21838
|
+
addonId: null,
|
|
21839
|
+
access: "create"
|
|
21840
|
+
},
|
|
21758
21841
|
"battery.wakeForStream": {
|
|
21759
21842
|
capName: "battery",
|
|
21760
21843
|
capScope: "device",
|
|
@@ -26081,6 +26164,40 @@ Object.freeze({
|
|
|
26081
26164
|
});
|
|
26082
26165
|
//#endregion
|
|
26083
26166
|
//#region src/pty.ts
|
|
26167
|
+
/**
|
|
26168
|
+
* Minimal pty abstraction. The manager depends on this interface, never on
|
|
26169
|
+
* `node-pty` directly, so its tests spawn no real process and the native
|
|
26170
|
+
* module is imported lazily (only when a real session opens on the host).
|
|
26171
|
+
*
|
|
26172
|
+
* `child_process.spawn` is deliberately NOT used: a plain pipe is NOT-A-TTY, so
|
|
26173
|
+
* a full-screen program (`btm`) gets no terminal and no runtime resize channel.
|
|
26174
|
+
* See docs/design/2026-07-26-terminal-session-design.md ("The pty").
|
|
26175
|
+
*/
|
|
26176
|
+
/**
|
|
26177
|
+
* Resolve `file` to an executable path the way a shell would: a path-qualified
|
|
26178
|
+
* name is checked directly, a bare name is searched on `PATH`. Returns the
|
|
26179
|
+
* resolved absolute path, or `null` when nothing executable matches — the
|
|
26180
|
+
* caller turns that into a clear "binary not found" error BEFORE spawning,
|
|
26181
|
+
* instead of a pty that dies at 127 and vanishes with no visible cause.
|
|
26182
|
+
*/
|
|
26183
|
+
function resolveExecutable(file) {
|
|
26184
|
+
if (file.includes("/")) return isExecutableFile(file) ? file : null;
|
|
26185
|
+
const dirs = (process.env.PATH ?? "").split(node_path.delimiter).filter((d) => d.length > 0);
|
|
26186
|
+
for (const dir of dirs) {
|
|
26187
|
+
const candidate = (0, node_path.join)(dir, file);
|
|
26188
|
+
if (isExecutableFile(candidate)) return candidate;
|
|
26189
|
+
}
|
|
26190
|
+
return null;
|
|
26191
|
+
}
|
|
26192
|
+
function isExecutableFile(p) {
|
|
26193
|
+
try {
|
|
26194
|
+
if (!(0, node_fs.statSync)(p).isFile()) return false;
|
|
26195
|
+
(0, node_fs.accessSync)(p, node_fs.constants.X_OK);
|
|
26196
|
+
return true;
|
|
26197
|
+
} catch {
|
|
26198
|
+
return false;
|
|
26199
|
+
}
|
|
26200
|
+
}
|
|
26084
26201
|
var cachedModule = null;
|
|
26085
26202
|
async function loadNodePty() {
|
|
26086
26203
|
if (cachedModule) return cachedModule;
|
|
@@ -26142,7 +26259,7 @@ function parsePath(url) {
|
|
|
26142
26259
|
};
|
|
26143
26260
|
}
|
|
26144
26261
|
function handleOut(manager, sessionId, req, res) {
|
|
26145
|
-
if (!manager.
|
|
26262
|
+
if (!manager.hasEntry(sessionId)) {
|
|
26146
26263
|
res.writeHead(404, { "content-type": "text/plain" });
|
|
26147
26264
|
res.end("no such session");
|
|
26148
26265
|
return;
|
|
@@ -26264,6 +26381,13 @@ function findProfile(profiles, profileId) {
|
|
|
26264
26381
|
var MIN_GRID = 1;
|
|
26265
26382
|
var MAX_COLS = 1e3;
|
|
26266
26383
|
var MAX_ROWS = 500;
|
|
26384
|
+
/**
|
|
26385
|
+
* How long an exited session is kept addressable so a GET /out that arrives
|
|
26386
|
+
* AFTER the pty died still delivers the `exit` event (with its code) to the UI,
|
|
26387
|
+
* instead of a bare 404 that renders as a blank terminal. A process that fails
|
|
26388
|
+
* to start (missing binary → exit 127) is the common case this makes visible.
|
|
26389
|
+
*/
|
|
26390
|
+
var EXITED_RETENTION_MS = 1e4;
|
|
26267
26391
|
function clampDimension(value, max) {
|
|
26268
26392
|
if (!Number.isFinite(value)) return MIN_GRID;
|
|
26269
26393
|
const int = Math.floor(value);
|
|
@@ -26277,6 +26401,7 @@ var TerminalSessionManager = class {
|
|
|
26277
26401
|
profiles;
|
|
26278
26402
|
mintId;
|
|
26279
26403
|
now;
|
|
26404
|
+
resolveBinary;
|
|
26280
26405
|
constructor(opts) {
|
|
26281
26406
|
this.opts = opts;
|
|
26282
26407
|
this.profiles = buildProfiles({
|
|
@@ -26286,6 +26411,7 @@ var TerminalSessionManager = class {
|
|
|
26286
26411
|
});
|
|
26287
26412
|
this.mintId = opts.mintId ?? (() => crypto.randomUUID());
|
|
26288
26413
|
this.now = opts.now ?? (() => Date.now());
|
|
26414
|
+
this.resolveBinary = opts.resolveBinary ?? ((file) => file);
|
|
26289
26415
|
}
|
|
26290
26416
|
/** Re-derive the allowlist after a settings change. */
|
|
26291
26417
|
reconfigureProfiles(config) {
|
|
@@ -26308,8 +26434,10 @@ var TerminalSessionManager = class {
|
|
|
26308
26434
|
const cols = clampDimension(input.cols, MAX_COLS);
|
|
26309
26435
|
const rows = clampDimension(input.rows, MAX_ROWS);
|
|
26310
26436
|
const sessionId = this.mintId();
|
|
26437
|
+
const resolvedFile = this.resolveBinary(profile.file);
|
|
26438
|
+
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
26439
|
const pty = this.opts.spawn({
|
|
26312
|
-
file:
|
|
26440
|
+
file: resolvedFile,
|
|
26313
26441
|
args: profile.args,
|
|
26314
26442
|
cols,
|
|
26315
26443
|
rows,
|
|
@@ -26354,7 +26482,11 @@ var TerminalSessionManager = class {
|
|
|
26354
26482
|
} catch {}
|
|
26355
26483
|
session.sinks.clear();
|
|
26356
26484
|
session.screen.dispose();
|
|
26357
|
-
|
|
26485
|
+
const retire = setTimeout(() => {
|
|
26486
|
+
this.sessions.delete(sessionId);
|
|
26487
|
+
}, EXITED_RETENTION_MS);
|
|
26488
|
+
retire.unref?.();
|
|
26489
|
+
session.retireTimer = retire;
|
|
26358
26490
|
});
|
|
26359
26491
|
this.opts.logger.info("terminal: session opened", { meta: {
|
|
26360
26492
|
sessionId,
|
|
@@ -26395,6 +26527,15 @@ var TerminalSessionManager = class {
|
|
|
26395
26527
|
return s !== void 0 && !s.exited;
|
|
26396
26528
|
}
|
|
26397
26529
|
/**
|
|
26530
|
+
* True when the session is still addressable — including an EXITED one inside
|
|
26531
|
+
* its retention window. `handleOut` gates on this (not `hasSession`) so the
|
|
26532
|
+
* exit event reaches the UI; live-only paths (`writeInput`) gate on
|
|
26533
|
+
* `hasSession`.
|
|
26534
|
+
*/
|
|
26535
|
+
hasEntry(sessionId) {
|
|
26536
|
+
return this.sessions.has(sessionId);
|
|
26537
|
+
}
|
|
26538
|
+
/**
|
|
26398
26539
|
* Attach an output sink. Immediately pushes a full-screen repaint (the current
|
|
26399
26540
|
* authoritative buffer) so a fresh xterm.js is in sync, then streams live
|
|
26400
26541
|
* output. Returns a detach function.
|
|
@@ -26428,6 +26569,7 @@ var TerminalSessionManager = class {
|
|
|
26428
26569
|
/** Kill every live session — called on addon shutdown. */
|
|
26429
26570
|
disposeAll() {
|
|
26430
26571
|
for (const session of this.sessions.values()) {
|
|
26572
|
+
if (session.retireTimer !== void 0) clearTimeout(session.retireTimer);
|
|
26431
26573
|
try {
|
|
26432
26574
|
session.pty.kill();
|
|
26433
26575
|
} catch {}
|
|
@@ -31290,6 +31432,7 @@ var TerminalAddon = class extends BaseAddon {
|
|
|
31290
31432
|
const manager = new TerminalSessionManager({
|
|
31291
31433
|
spawn: createNodePtySpawner(),
|
|
31292
31434
|
screenFactory: createXtermScreen,
|
|
31435
|
+
resolveBinary: resolveExecutable,
|
|
31293
31436
|
logger: this.ctx.logger,
|
|
31294
31437
|
btmPath: this.config.btmPath,
|
|
31295
31438
|
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
|
|
@@ -7504,16 +7506,23 @@ var StorageLocationDeclarationSchema = object({
|
|
|
7504
7506
|
* Which node root the seeded `<id>:default` instance is placed under on a
|
|
7505
7507
|
* FRESH install:
|
|
7506
7508
|
* - `'data'` (default) — the node's data dir (`CAMSTACK_DATA` / boot dir),
|
|
7507
|
-
* the appData volume. Right for small/durable data (
|
|
7509
|
+
* the appData volume. Right for small/durable data (logs, models).
|
|
7508
7510
|
* - `'media'` — the dedicated media volume (`CAMSTACK_MEDIA_ROOT`) when that
|
|
7509
7511
|
* env is set, else falls back to the data root. Right for bulky, hot media
|
|
7510
7512
|
* (recordings, event media) that should stay off the appData disk.
|
|
7513
|
+
* - `'backup'` — the dedicated backup volume (`CAMSTACK_BACKUP_ROOT`, default
|
|
7514
|
+
* `/backups` in the image) so archives live on their own mount rather than
|
|
7515
|
+
* filling the appData disk. Falls back to the data root when unset.
|
|
7511
7516
|
*
|
|
7512
7517
|
* Only affects the seeded default's `basePath`; operators can repoint any
|
|
7513
7518
|
* location afterwards, and a `defaultsTo` slot inherits its parent's root
|
|
7514
7519
|
* regardless of this field. Absent (the common case) is treated as `'data'`.
|
|
7515
7520
|
*/
|
|
7516
|
-
defaultRoot: _enum([
|
|
7521
|
+
defaultRoot: _enum([
|
|
7522
|
+
"data",
|
|
7523
|
+
"media",
|
|
7524
|
+
"backup"
|
|
7525
|
+
]).optional()
|
|
7517
7526
|
});
|
|
7518
7527
|
var DecoderStatsSchema = object({
|
|
7519
7528
|
inputFps: number(),
|
|
@@ -14525,11 +14534,53 @@ var LocationStatSchema = object({
|
|
|
14525
14534
|
fileCount: number(),
|
|
14526
14535
|
present: boolean()
|
|
14527
14536
|
});
|
|
14537
|
+
/**
|
|
14538
|
+
* A backup schedule — the N:M "entry" that binds one cron cadence to a
|
|
14539
|
+
* SET of destination locations. Supersedes the per-location cron on
|
|
14540
|
+
* `BackupDestinationPolicy`: an operator creates a schedule, picks the
|
|
14541
|
+
* `backups` locations it should write to, and the orchestrator fans a
|
|
14542
|
+
* single archive out to all of them when the cron fires.
|
|
14543
|
+
*
|
|
14544
|
+
* `retentionCount` is per-schedule (D-decision 2026-07-28): every
|
|
14545
|
+
* location targeted by this schedule keeps this many archives from
|
|
14546
|
+
* this schedule's runs.
|
|
14547
|
+
*
|
|
14548
|
+
* `dataSources` optionally narrows which top-level state locations
|
|
14549
|
+
* (db, addons, tls, …) are archived; omitted = the orchestrator's
|
|
14550
|
+
* default full set.
|
|
14551
|
+
*/
|
|
14552
|
+
var BackupScheduleSchema = object({
|
|
14553
|
+
/** Stable id. Generated by the orchestrator on first upsert if absent. */
|
|
14554
|
+
id: string(),
|
|
14555
|
+
/** Operator-facing display name. */
|
|
14556
|
+
label: string(),
|
|
14557
|
+
/** 5-field POSIX cron. Empty = disabled cadence (kept for editing). */
|
|
14558
|
+
cron: string(),
|
|
14559
|
+
/** Master on/off toggle for the whole schedule. */
|
|
14560
|
+
enabled: boolean(),
|
|
14561
|
+
/** `backups`-location ids this schedule writes to (fan-out set). */
|
|
14562
|
+
locationIds: array(string()).readonly(),
|
|
14563
|
+
/** Archives kept per targeted location for this schedule. */
|
|
14564
|
+
retentionCount: number().int().min(1).max(1e3),
|
|
14565
|
+
/** Optional subset of source locations to include; omitted = all. */
|
|
14566
|
+
dataSources: array(string()).readonly().optional(),
|
|
14567
|
+
/** ms-epoch of last successful run. */
|
|
14568
|
+
lastRunAt: number().optional(),
|
|
14569
|
+
/** ms-epoch of next computed firing (read-only, filled on list). */
|
|
14570
|
+
nextRunAt: number().optional()
|
|
14571
|
+
});
|
|
14528
14572
|
method(_void(), array(BackupDestinationInfoSchema).readonly(), { auth: "admin" }), method(object({
|
|
14529
14573
|
/** Subset of registered `backup-destination` addon ids to write to. */
|
|
14530
14574
|
destinations: array(string()).optional(),
|
|
14531
14575
|
locations: array(string()).optional(),
|
|
14532
|
-
label: string().optional()
|
|
14576
|
+
label: string().optional(),
|
|
14577
|
+
/**
|
|
14578
|
+
* Per-run retention override applied to every targeted
|
|
14579
|
+
* destination. Used by schedule-driven runs (per-entry
|
|
14580
|
+
* retention). Omitted = each destination's own policy
|
|
14581
|
+
* retention (manual runs).
|
|
14582
|
+
*/
|
|
14583
|
+
retentionCount: number().int().min(1).max(1e3).optional()
|
|
14533
14584
|
}).optional(), array(BackupEntrySchema).readonly(), {
|
|
14534
14585
|
kind: "mutation",
|
|
14535
14586
|
auth: "admin"
|
|
@@ -14578,7 +14629,21 @@ method(_void(), array(BackupDestinationInfoSchema).readonly(), { auth: "admin" }
|
|
|
14578
14629
|
ok: boolean(),
|
|
14579
14630
|
error: string().optional(),
|
|
14580
14631
|
nextRuns: array(number()).readonly()
|
|
14581
|
-
}))
|
|
14632
|
+
})), method(_void(), array(BackupScheduleSchema).readonly(), { auth: "admin" }), method(object({
|
|
14633
|
+
id: string().optional(),
|
|
14634
|
+
label: string(),
|
|
14635
|
+
cron: string(),
|
|
14636
|
+
enabled: boolean(),
|
|
14637
|
+
locationIds: array(string()).readonly(),
|
|
14638
|
+
retentionCount: number().int().min(1).max(1e3),
|
|
14639
|
+
dataSources: array(string()).readonly().optional()
|
|
14640
|
+
}), BackupScheduleSchema, {
|
|
14641
|
+
kind: "mutation",
|
|
14642
|
+
auth: "admin"
|
|
14643
|
+
}), method(object({ id: string() }), _void(), {
|
|
14644
|
+
kind: "mutation",
|
|
14645
|
+
auth: "admin"
|
|
14646
|
+
});
|
|
14582
14647
|
/**
|
|
14583
14648
|
* `broker` — unified pub/sub broker registry, system-scoped collection.
|
|
14584
14649
|
*
|
|
@@ -21700,6 +21765,12 @@ Object.freeze({
|
|
|
21700
21765
|
addonId: null,
|
|
21701
21766
|
access: "delete"
|
|
21702
21767
|
},
|
|
21768
|
+
"backup.deleteSchedule": {
|
|
21769
|
+
capName: "backup",
|
|
21770
|
+
capScope: "system",
|
|
21771
|
+
addonId: null,
|
|
21772
|
+
access: "delete"
|
|
21773
|
+
},
|
|
21703
21774
|
"backup.getEntries": {
|
|
21704
21775
|
capName: "backup",
|
|
21705
21776
|
capScope: "system",
|
|
@@ -21730,6 +21801,12 @@ Object.freeze({
|
|
|
21730
21801
|
addonId: null,
|
|
21731
21802
|
access: "view"
|
|
21732
21803
|
},
|
|
21804
|
+
"backup.listSchedules": {
|
|
21805
|
+
capName: "backup",
|
|
21806
|
+
capScope: "system",
|
|
21807
|
+
addonId: null,
|
|
21808
|
+
access: "view"
|
|
21809
|
+
},
|
|
21733
21810
|
"backup.previewSchedule": {
|
|
21734
21811
|
capName: "backup",
|
|
21735
21812
|
capScope: "system",
|
|
@@ -21754,6 +21831,12 @@ Object.freeze({
|
|
|
21754
21831
|
addonId: null,
|
|
21755
21832
|
access: "create"
|
|
21756
21833
|
},
|
|
21834
|
+
"backup.upsertSchedule": {
|
|
21835
|
+
capName: "backup",
|
|
21836
|
+
capScope: "system",
|
|
21837
|
+
addonId: null,
|
|
21838
|
+
access: "create"
|
|
21839
|
+
},
|
|
21757
21840
|
"battery.wakeForStream": {
|
|
21758
21841
|
capName: "battery",
|
|
21759
21842
|
capScope: "device",
|
|
@@ -26080,6 +26163,40 @@ Object.freeze({
|
|
|
26080
26163
|
});
|
|
26081
26164
|
//#endregion
|
|
26082
26165
|
//#region src/pty.ts
|
|
26166
|
+
/**
|
|
26167
|
+
* Minimal pty abstraction. The manager depends on this interface, never on
|
|
26168
|
+
* `node-pty` directly, so its tests spawn no real process and the native
|
|
26169
|
+
* module is imported lazily (only when a real session opens on the host).
|
|
26170
|
+
*
|
|
26171
|
+
* `child_process.spawn` is deliberately NOT used: a plain pipe is NOT-A-TTY, so
|
|
26172
|
+
* a full-screen program (`btm`) gets no terminal and no runtime resize channel.
|
|
26173
|
+
* See docs/design/2026-07-26-terminal-session-design.md ("The pty").
|
|
26174
|
+
*/
|
|
26175
|
+
/**
|
|
26176
|
+
* Resolve `file` to an executable path the way a shell would: a path-qualified
|
|
26177
|
+
* name is checked directly, a bare name is searched on `PATH`. Returns the
|
|
26178
|
+
* resolved absolute path, or `null` when nothing executable matches — the
|
|
26179
|
+
* caller turns that into a clear "binary not found" error BEFORE spawning,
|
|
26180
|
+
* instead of a pty that dies at 127 and vanishes with no visible cause.
|
|
26181
|
+
*/
|
|
26182
|
+
function resolveExecutable(file) {
|
|
26183
|
+
if (file.includes("/")) return isExecutableFile(file) ? file : null;
|
|
26184
|
+
const dirs = (process.env.PATH ?? "").split(delimiter).filter((d) => d.length > 0);
|
|
26185
|
+
for (const dir of dirs) {
|
|
26186
|
+
const candidate = join(dir, file);
|
|
26187
|
+
if (isExecutableFile(candidate)) return candidate;
|
|
26188
|
+
}
|
|
26189
|
+
return null;
|
|
26190
|
+
}
|
|
26191
|
+
function isExecutableFile(p) {
|
|
26192
|
+
try {
|
|
26193
|
+
if (!statSync(p).isFile()) return false;
|
|
26194
|
+
accessSync(p, constants.X_OK);
|
|
26195
|
+
return true;
|
|
26196
|
+
} catch {
|
|
26197
|
+
return false;
|
|
26198
|
+
}
|
|
26199
|
+
}
|
|
26083
26200
|
var cachedModule = null;
|
|
26084
26201
|
async function loadNodePty() {
|
|
26085
26202
|
if (cachedModule) return cachedModule;
|
|
@@ -26141,7 +26258,7 @@ function parsePath(url) {
|
|
|
26141
26258
|
};
|
|
26142
26259
|
}
|
|
26143
26260
|
function handleOut(manager, sessionId, req, res) {
|
|
26144
|
-
if (!manager.
|
|
26261
|
+
if (!manager.hasEntry(sessionId)) {
|
|
26145
26262
|
res.writeHead(404, { "content-type": "text/plain" });
|
|
26146
26263
|
res.end("no such session");
|
|
26147
26264
|
return;
|
|
@@ -26263,6 +26380,13 @@ function findProfile(profiles, profileId) {
|
|
|
26263
26380
|
var MIN_GRID = 1;
|
|
26264
26381
|
var MAX_COLS = 1e3;
|
|
26265
26382
|
var MAX_ROWS = 500;
|
|
26383
|
+
/**
|
|
26384
|
+
* How long an exited session is kept addressable so a GET /out that arrives
|
|
26385
|
+
* AFTER the pty died still delivers the `exit` event (with its code) to the UI,
|
|
26386
|
+
* instead of a bare 404 that renders as a blank terminal. A process that fails
|
|
26387
|
+
* to start (missing binary → exit 127) is the common case this makes visible.
|
|
26388
|
+
*/
|
|
26389
|
+
var EXITED_RETENTION_MS = 1e4;
|
|
26266
26390
|
function clampDimension(value, max) {
|
|
26267
26391
|
if (!Number.isFinite(value)) return MIN_GRID;
|
|
26268
26392
|
const int = Math.floor(value);
|
|
@@ -26276,6 +26400,7 @@ var TerminalSessionManager = class {
|
|
|
26276
26400
|
profiles;
|
|
26277
26401
|
mintId;
|
|
26278
26402
|
now;
|
|
26403
|
+
resolveBinary;
|
|
26279
26404
|
constructor(opts) {
|
|
26280
26405
|
this.opts = opts;
|
|
26281
26406
|
this.profiles = buildProfiles({
|
|
@@ -26285,6 +26410,7 @@ var TerminalSessionManager = class {
|
|
|
26285
26410
|
});
|
|
26286
26411
|
this.mintId = opts.mintId ?? (() => crypto.randomUUID());
|
|
26287
26412
|
this.now = opts.now ?? (() => Date.now());
|
|
26413
|
+
this.resolveBinary = opts.resolveBinary ?? ((file) => file);
|
|
26288
26414
|
}
|
|
26289
26415
|
/** Re-derive the allowlist after a settings change. */
|
|
26290
26416
|
reconfigureProfiles(config) {
|
|
@@ -26307,8 +26433,10 @@ var TerminalSessionManager = class {
|
|
|
26307
26433
|
const cols = clampDimension(input.cols, MAX_COLS);
|
|
26308
26434
|
const rows = clampDimension(input.rows, MAX_ROWS);
|
|
26309
26435
|
const sessionId = this.mintId();
|
|
26436
|
+
const resolvedFile = this.resolveBinary(profile.file);
|
|
26437
|
+
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
26438
|
const pty = this.opts.spawn({
|
|
26311
|
-
file:
|
|
26439
|
+
file: resolvedFile,
|
|
26312
26440
|
args: profile.args,
|
|
26313
26441
|
cols,
|
|
26314
26442
|
rows,
|
|
@@ -26353,7 +26481,11 @@ var TerminalSessionManager = class {
|
|
|
26353
26481
|
} catch {}
|
|
26354
26482
|
session.sinks.clear();
|
|
26355
26483
|
session.screen.dispose();
|
|
26356
|
-
|
|
26484
|
+
const retire = setTimeout(() => {
|
|
26485
|
+
this.sessions.delete(sessionId);
|
|
26486
|
+
}, EXITED_RETENTION_MS);
|
|
26487
|
+
retire.unref?.();
|
|
26488
|
+
session.retireTimer = retire;
|
|
26357
26489
|
});
|
|
26358
26490
|
this.opts.logger.info("terminal: session opened", { meta: {
|
|
26359
26491
|
sessionId,
|
|
@@ -26394,6 +26526,15 @@ var TerminalSessionManager = class {
|
|
|
26394
26526
|
return s !== void 0 && !s.exited;
|
|
26395
26527
|
}
|
|
26396
26528
|
/**
|
|
26529
|
+
* True when the session is still addressable — including an EXITED one inside
|
|
26530
|
+
* its retention window. `handleOut` gates on this (not `hasSession`) so the
|
|
26531
|
+
* exit event reaches the UI; live-only paths (`writeInput`) gate on
|
|
26532
|
+
* `hasSession`.
|
|
26533
|
+
*/
|
|
26534
|
+
hasEntry(sessionId) {
|
|
26535
|
+
return this.sessions.has(sessionId);
|
|
26536
|
+
}
|
|
26537
|
+
/**
|
|
26397
26538
|
* Attach an output sink. Immediately pushes a full-screen repaint (the current
|
|
26398
26539
|
* authoritative buffer) so a fresh xterm.js is in sync, then streams live
|
|
26399
26540
|
* output. Returns a detach function.
|
|
@@ -26427,6 +26568,7 @@ var TerminalSessionManager = class {
|
|
|
26427
26568
|
/** Kill every live session — called on addon shutdown. */
|
|
26428
26569
|
disposeAll() {
|
|
26429
26570
|
for (const session of this.sessions.values()) {
|
|
26571
|
+
if (session.retireTimer !== void 0) clearTimeout(session.retireTimer);
|
|
26430
26572
|
try {
|
|
26431
26573
|
session.pty.kill();
|
|
26432
26574
|
} catch {}
|
|
@@ -31289,6 +31431,7 @@ var TerminalAddon = class extends BaseAddon {
|
|
|
31289
31431
|
const manager = new TerminalSessionManager({
|
|
31290
31432
|
spawn: createNodePtySpawner(),
|
|
31291
31433
|
screenFactory: createXtermScreen,
|
|
31434
|
+
resolveBinary: resolveExecutable,
|
|
31292
31435
|
logger: this.ctx.logger,
|
|
31293
31436
|
btmPath: this.config.btmPath,
|
|
31294
31437
|
allowShell: this.config.allowShell,
|