@exagone313/dsh-podman 0.2.0-rc.4 → 0.2.0
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 +12 -0
- package/README.zh.md +14 -2
- package/cordis.patch.yml +58 -0
- package/dist/approval-reasons.d.ts +3 -4
- package/dist/approval-reasons.js +40 -49
- package/dist/approval-reasons.test.js +43 -15
- package/dist/approval.js +27 -10
- package/dist/approval.test.js +165 -34
- package/dist/card-route.d.ts +3 -2
- package/dist/card-route.js +103 -35
- package/dist/card-route.test.js +60 -43
- package/dist/card-test-support.js +39 -66
- package/dist/client/ContainerCard.d.ts +1 -1
- package/dist/client/ContainerCard.js +76 -70
- package/dist/client/card-protocol.d.ts +3 -3
- package/dist/client/container-card-caches.js +11 -6
- package/dist/client/container-card-controller.d.ts +13 -18
- package/dist/client/container-card-controller.js +86 -40
- package/dist/client/container-card-create-modal.d.ts +2 -2
- package/dist/client/container-card-create-modal.js +13 -5
- package/dist/client/container-card-default-env.d.ts +10 -0
- package/dist/client/container-card-default-env.js +69 -0
- package/dist/client/container-card-directory.d.ts +2 -2
- package/dist/client/container-card-directory.js +12 -4
- package/dist/client/container-card-editors.d.ts +3 -3
- package/dist/client/container-card-editors.js +104 -32
- package/dist/client/container-card-images.d.ts +4 -4
- package/dist/client/container-card-images.js +19 -6
- package/dist/client/container-card-paths.d.ts +3 -3
- package/dist/client/container-card-paths.js +18 -11
- package/dist/client/container-card-row.d.ts +10 -6
- package/dist/client/container-card-row.js +82 -13
- package/dist/client/container-card-secrets.d.ts +3 -3
- package/dist/client/container-card-secrets.js +8 -6
- package/dist/client/container-card-shared.d.ts +4 -17
- package/dist/client/container-card-shared.js +9 -25
- package/dist/client/container-card-styles.d.ts +2 -8
- package/dist/client/container-card-styles.js +14 -52
- package/dist/client/container-card-volumes.d.ts +2 -2
- package/dist/client/container-card-volumes.js +9 -5
- package/dist/client/container-card-workspace.d.ts +8 -6
- package/dist/client/container-card-workspace.js +9 -3
- package/dist/client/directory-picker.js +2 -1
- package/dist/client/index.js +13109 -2117
- package/dist/client/locales.d.ts +6 -1
- package/dist/client/locales.js +88 -38
- package/dist/client/podman-terminal-guide.d.ts +11 -0
- package/dist/client/podman-terminal-guide.js +208 -0
- package/dist/client/podman-terminal-title.d.ts +7 -0
- package/dist/client/podman-terminal-title.js +30 -0
- package/dist/client/podman-terminal.d.ts +26 -0
- package/dist/client/podman-terminal.js +412 -0
- package/dist/client/read-only-approval.d.ts +2 -2
- package/dist/client/read-only-approval.js +13 -3
- package/dist/client/slot-contract.d.ts +0 -11
- package/dist/client/terminal-preference.d.ts +26 -0
- package/dist/client/terminal-preference.js +67 -0
- package/dist/client/terminal-protocol.d.ts +85 -0
- package/dist/client/terminal-protocol.js +14 -0
- package/dist/client/terminal-tab.d.ts +60 -0
- package/dist/client/terminal-tab.js +35 -0
- package/dist/client/terminal-targets.d.ts +33 -0
- package/dist/client/terminal-targets.js +65 -0
- package/dist/client/terminal-titles.d.ts +23 -0
- package/dist/client/terminal-titles.js +52 -0
- package/dist/client/terminal-transport.d.ts +63 -0
- package/dist/client/terminal-transport.js +207 -0
- package/dist/client/tool-views.js +215 -49
- package/dist/container-env.d.ts +11 -0
- package/dist/container-env.js +96 -0
- package/dist/container-env.test.d.ts +1 -0
- package/dist/container-env.test.js +93 -0
- package/dist/containers.test.js +196 -18
- package/dist/daemons.test.js +18 -4
- package/dist/dsh-version.js +2 -1
- package/dist/env-rows.d.ts +14 -0
- package/dist/env-rows.js +52 -0
- package/dist/env-rows.test.d.ts +1 -0
- package/dist/env-rows.test.js +43 -0
- package/dist/fs-provider.d.ts +1 -0
- package/dist/fs-provider.js +26 -12
- package/dist/fs-provider.test.js +51 -10
- package/dist/generated/version.d.ts +2 -2
- package/dist/generated/version.js +2 -2
- package/dist/grpc/proto/dshctl/v1/control.proto +8 -1
- package/dist/grpc/proto/dshguest/v1/guest.proto +5 -0
- package/dist/grpc/runtime-client.d.ts +3 -1
- package/dist/grpc/runtime-client.js +90 -17
- package/dist/guest-rpc.d.ts +15 -19
- package/dist/guest-rpc.js +289 -70
- package/dist/guest-rpc.test.d.ts +1 -0
- package/dist/guest-rpc.test.js +241 -0
- package/dist/guest-terminal.d.ts +30 -0
- package/dist/guest-terminal.js +196 -0
- package/dist/images.test.js +10 -3
- package/dist/index.d.ts +11 -16
- package/dist/index.js +42 -36
- package/dist/locales.test.d.ts +1 -0
- package/dist/locales.test.js +34 -0
- package/dist/misc.test.js +38 -10
- package/dist/mount-enums.js +2 -1
- package/dist/mount-input.d.ts +2 -0
- package/dist/mount-input.js +38 -22
- package/dist/mounts.test.js +81 -11
- package/dist/naming.test.d.ts +1 -0
- package/dist/naming.test.js +28 -0
- package/dist/output-reader.js +36 -4
- package/dist/package-deps.test.d.ts +1 -0
- package/dist/package-deps.test.js +49 -0
- package/dist/paths.test.js +4 -2
- package/dist/plugin-meta.test.d.ts +1 -0
- package/dist/plugin-meta.test.js +29 -0
- package/dist/project-path.js +33 -7
- package/dist/project-path.test.js +14 -0
- package/dist/prompts.d.ts +0 -4
- package/dist/prompts.js +15 -84
- package/dist/read-only-shell.js +3 -1
- package/dist/read-only-shell.test.js +80 -25
- package/dist/runtime-client.test.d.ts +1 -0
- package/dist/runtime-client.test.js +53 -0
- package/dist/secrets.test.js +21 -6
- package/dist/settings-commands.test.js +61 -14
- package/dist/settings-create.test.js +32 -10
- package/dist/settings-default-env.test.d.ts +1 -0
- package/dist/settings-default-env.test.js +175 -0
- package/dist/settings-mounts.test.js +46 -7
- package/dist/settings-schema.d.ts +6 -11
- package/dist/settings-schema.js +4 -8
- package/dist/settings-schema.test.d.ts +1 -0
- package/dist/settings-schema.test.js +35 -0
- package/dist/settings-workspaces.test.js +36 -63
- package/dist/spill-store.d.ts +1 -0
- package/dist/spill-store.js +17 -4
- package/dist/spill-store.test.js +31 -8
- package/dist/subprocess.d.ts +4 -0
- package/dist/subprocess.js +148 -162
- package/dist/subprocess.test.js +118 -8
- package/dist/terminal-preference.test.d.ts +1 -0
- package/dist/terminal-preference.test.js +62 -0
- package/dist/terminal-route.d.ts +10 -0
- package/dist/terminal-route.js +270 -0
- package/dist/terminal-route.test.d.ts +1 -0
- package/dist/terminal-route.test.js +265 -0
- package/dist/terminal-sessions.d.ts +69 -0
- package/dist/terminal-sessions.js +252 -0
- package/dist/terminal-shells.d.ts +22 -0
- package/dist/terminal-shells.js +99 -0
- package/dist/terminal-tab.test.d.ts +1 -0
- package/dist/terminal-tab.test.js +58 -0
- package/dist/terminal-targets.test.d.ts +1 -0
- package/dist/terminal-targets.test.js +43 -0
- package/dist/terminal-titles.test.d.ts +1 -0
- package/dist/terminal-titles.test.js +36 -0
- package/dist/test-support.d.ts +35 -5
- package/dist/test-support.js +91 -29
- package/dist/tool-defs.js +19 -4
- package/dist/tool-handlers.js +80 -28
- package/dist/tool-params.js +19 -5
- package/dist/volumes.test.js +1 -1
- package/dist/workspace-binding.d.ts +18 -9
- package/dist/workspace-binding.js +244 -77
- package/dist/workspace-binding.test.js +189 -25
- package/docs/architecture.md +62 -21
- package/docs/architecture.zh.md +57 -13
- package/docs/configuration.md +61 -20
- package/docs/configuration.zh.md +53 -19
- package/docs/development-prompt.md +72 -0
- package/docs/development-prompt.zh.md +69 -0
- package/docs/development.md +141 -21
- package/docs/development.zh.md +125 -17
- package/docs/install-dsh-and-dsh-podman.md +21 -8
- package/docs/install-dsh-and-dsh-podman.zh.md +18 -8
- package/docs/uninstall.md +83 -0
- package/docs/uninstall.zh.md +79 -0
- package/docs/update.md +120 -0
- package/docs/update.zh.md +114 -0
- package/docs/usage.md +108 -61
- package/docs/usage.zh.md +76 -38
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +58 -25
- package/quadlet/dsh-podman-orchestrator.container +46 -0
- package/quadlet/dsh.container +35 -0
- package/LICENSE.pkg +0 -17103
- package/dist/preferences.d.ts +0 -6
- package/dist/preferences.js +0 -27
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Elouan Martinet <exa@elou.world>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: MIT
|
|
4
|
+
// Live and retained Podman terminals. One guest pty per (session, tab), kept
|
|
5
|
+
// alive while the browser is away and replayed from a headless emulator screen
|
|
6
|
+
// on reattach, so reloading the page does not lose the shell or its scrollback.
|
|
7
|
+
import { openGuestTerminal } from "./guest-terminal.js";
|
|
8
|
+
import { requireShell } from "./terminal-shells.js";
|
|
9
|
+
import { randomUUID } from "node:crypto";
|
|
10
|
+
import { createRequire } from "node:module";
|
|
11
|
+
// Both xterm packages are CommonJS: a default ESM import of their named exports
|
|
12
|
+
// fails under Node's interop rules, so they are required with their own types.
|
|
13
|
+
const require = createRequire(import.meta.url);
|
|
14
|
+
const { Terminal } = require("@xterm/headless");
|
|
15
|
+
const { SerializeAddon } = require("@xterm/addon-serialize");
|
|
16
|
+
// The harness's own browser terminals keep an idle shell for this long.
|
|
17
|
+
export const TERMINAL_RETENTION_MS = 7_200_000;
|
|
18
|
+
const DEFAULT_SCROLLBACK = 1000;
|
|
19
|
+
const REAP_INTERVAL_MS = 60_000;
|
|
20
|
+
export class TerminalSessions {
|
|
21
|
+
options;
|
|
22
|
+
entries = new Map();
|
|
23
|
+
byId = new Map();
|
|
24
|
+
scrollback;
|
|
25
|
+
retentionMs;
|
|
26
|
+
sweep;
|
|
27
|
+
constructor(options) {
|
|
28
|
+
this.options = options;
|
|
29
|
+
this.scrollback = options.scrollback ?? DEFAULT_SCROLLBACK;
|
|
30
|
+
this.retentionMs = options.retentionMs ?? TERMINAL_RETENTION_MS;
|
|
31
|
+
this.sweep = setInterval(() => this.reap(), REAP_INTERVAL_MS);
|
|
32
|
+
this.sweep.unref?.();
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Validate a request and make sure its terminal exists.
|
|
36
|
+
* @param options - session/tab identity, target container and shell, dimensions.
|
|
37
|
+
* @returns the terminal identity and, when reattaching, its serialized screen.
|
|
38
|
+
*/
|
|
39
|
+
async prepare(options) {
|
|
40
|
+
const key = `${options.sessionId}\n${options.tabId}`;
|
|
41
|
+
if (options.reopen === true)
|
|
42
|
+
this.drop(key);
|
|
43
|
+
const existing = this.entries.get(key);
|
|
44
|
+
if (existing !== undefined) {
|
|
45
|
+
existing.lastUsed = Date.now();
|
|
46
|
+
return {
|
|
47
|
+
terminalId: existing.terminalId,
|
|
48
|
+
workspace: existing.workspace,
|
|
49
|
+
container: existing.container,
|
|
50
|
+
shell: existing.shell,
|
|
51
|
+
title: existing.title,
|
|
52
|
+
attached: true,
|
|
53
|
+
snapshot: existing.serializer.serialize(),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
const binding = options.container === "" || options.container === "default"
|
|
57
|
+
? await this.options.resolver.resolveForPath(options.cwd, options.cwd, options.signal)
|
|
58
|
+
: await this.options.resolver.containerBinding(options.cwd, options.container, options.signal);
|
|
59
|
+
const shell = await requireShell(binding, options.cwd, options.shell, options.signal);
|
|
60
|
+
const terminal = openGuestTerminal(binding, {
|
|
61
|
+
argv: [shell.path, "-i"],
|
|
62
|
+
cwd: options.cwd,
|
|
63
|
+
cols: options.cols,
|
|
64
|
+
rows: options.rows,
|
|
65
|
+
terminalType: "xterm-256color",
|
|
66
|
+
});
|
|
67
|
+
const screen = new Terminal({
|
|
68
|
+
cols: options.cols,
|
|
69
|
+
rows: options.rows,
|
|
70
|
+
scrollback: this.scrollback,
|
|
71
|
+
allowProposedApi: true,
|
|
72
|
+
});
|
|
73
|
+
const serializer = new SerializeAddon();
|
|
74
|
+
screen.loadAddon(serializer);
|
|
75
|
+
const entry = {
|
|
76
|
+
key,
|
|
77
|
+
terminalId: randomUUID(),
|
|
78
|
+
sessionId: options.sessionId,
|
|
79
|
+
tabId: options.tabId,
|
|
80
|
+
workspace: options.workspace,
|
|
81
|
+
container: options.container,
|
|
82
|
+
shell: shell.path,
|
|
83
|
+
title: shell.name,
|
|
84
|
+
terminal,
|
|
85
|
+
screen,
|
|
86
|
+
serializer,
|
|
87
|
+
lastUsed: Date.now(),
|
|
88
|
+
listener: undefined,
|
|
89
|
+
};
|
|
90
|
+
terminal.onOutput((chunk) => {
|
|
91
|
+
entry.screen.write(chunk);
|
|
92
|
+
entry.listener?.({
|
|
93
|
+
type: "data",
|
|
94
|
+
data: Buffer.from(chunk).toString("base64"),
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
void terminal.done.then((outcome) => {
|
|
98
|
+
entry.exit = outcome;
|
|
99
|
+
entry.listener?.({
|
|
100
|
+
type: "exit",
|
|
101
|
+
exitCode: outcome.exitCode,
|
|
102
|
+
signal: outcome.signal,
|
|
103
|
+
});
|
|
104
|
+
}, (error) => {
|
|
105
|
+
entry.failure = error instanceof Error ? error.message : String(error);
|
|
106
|
+
entry.listener?.({ type: "error", message: entry.failure });
|
|
107
|
+
});
|
|
108
|
+
try {
|
|
109
|
+
// Surface a startup failure before publishing the entry.
|
|
110
|
+
await terminal.started;
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
void terminal.terminate();
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
this.entries.set(key, entry);
|
|
117
|
+
this.byId.set(entry.terminalId, entry);
|
|
118
|
+
return {
|
|
119
|
+
terminalId: entry.terminalId,
|
|
120
|
+
workspace: entry.workspace,
|
|
121
|
+
container: entry.container,
|
|
122
|
+
shell: entry.shell,
|
|
123
|
+
title: entry.title,
|
|
124
|
+
attached: false,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Attach one browser stream as the terminal's input owner.
|
|
129
|
+
* @param terminalId - a prepared terminal.
|
|
130
|
+
* @param write - frame sink for this attachment.
|
|
131
|
+
* @param signal - the request lifetime; aborting detaches without closing.
|
|
132
|
+
* @param snapshot - serialized screen to replay before live output.
|
|
133
|
+
* @returns a detach function, idempotent and safe after the entry is gone.
|
|
134
|
+
*/
|
|
135
|
+
listen(terminalId, write, signal, snapshot) {
|
|
136
|
+
const entry = this.byId.get(terminalId);
|
|
137
|
+
if (entry === undefined)
|
|
138
|
+
return () => { };
|
|
139
|
+
// A newer attachment takes over: the previous one is told it is detached.
|
|
140
|
+
entry.listener?.({ type: "detached" });
|
|
141
|
+
entry.listener = write;
|
|
142
|
+
entry.lastUsed = Date.now();
|
|
143
|
+
write({
|
|
144
|
+
type: "ready",
|
|
145
|
+
terminalId,
|
|
146
|
+
workspace: entry.workspace,
|
|
147
|
+
container: entry.container,
|
|
148
|
+
shell: entry.shell,
|
|
149
|
+
title: entry.title,
|
|
150
|
+
attached: snapshot !== undefined,
|
|
151
|
+
});
|
|
152
|
+
if (snapshot !== undefined)
|
|
153
|
+
write({ type: "snapshot", screen: snapshot });
|
|
154
|
+
if (entry.exit !== undefined) {
|
|
155
|
+
write({
|
|
156
|
+
type: "exit",
|
|
157
|
+
exitCode: entry.exit.exitCode,
|
|
158
|
+
signal: entry.exit.signal,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
else if (entry.failure !== undefined) {
|
|
162
|
+
write({ type: "error", message: entry.failure });
|
|
163
|
+
}
|
|
164
|
+
const detach = () => {
|
|
165
|
+
if (entry.listener === write)
|
|
166
|
+
entry.listener = undefined;
|
|
167
|
+
};
|
|
168
|
+
signal.addEventListener("abort", detach, { once: true });
|
|
169
|
+
return detach;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Apply one control request.
|
|
173
|
+
* @param control - input, resize, rename, or close.
|
|
174
|
+
* @returns whether the terminal was found.
|
|
175
|
+
*/
|
|
176
|
+
control(control) {
|
|
177
|
+
const entry = this.byId.get(control.terminalId);
|
|
178
|
+
if (entry === undefined)
|
|
179
|
+
return false;
|
|
180
|
+
entry.lastUsed = Date.now();
|
|
181
|
+
switch (control.kind) {
|
|
182
|
+
case "input": {
|
|
183
|
+
if (!entry.terminal.exited()) {
|
|
184
|
+
entry.terminal.write(Buffer.from(control.data, "base64"));
|
|
185
|
+
}
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
case "resize": {
|
|
189
|
+
if (!entry.terminal.exited()) {
|
|
190
|
+
entry.terminal.resize(control.cols, control.rows);
|
|
191
|
+
entry.screen.resize(control.cols, control.rows);
|
|
192
|
+
}
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
case "rename": {
|
|
196
|
+
entry.title = control.title;
|
|
197
|
+
entry.listener?.({ type: "title", title: control.title });
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
case "close": {
|
|
201
|
+
this.drop(entry.key);
|
|
202
|
+
void entry.terminal.terminate();
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* List the terminals one session still holds.
|
|
209
|
+
* @param sessionId - the owning session.
|
|
210
|
+
* @returns retained terminal metadata, oldest first.
|
|
211
|
+
*/
|
|
212
|
+
retained(sessionId) {
|
|
213
|
+
return [...this.entries.values()]
|
|
214
|
+
.filter((entry) => entry.sessionId === sessionId)
|
|
215
|
+
.map((entry) => ({
|
|
216
|
+
tabId: entry.tabId,
|
|
217
|
+
terminalId: entry.terminalId,
|
|
218
|
+
title: entry.title,
|
|
219
|
+
workspace: entry.workspace,
|
|
220
|
+
container: entry.container,
|
|
221
|
+
shell: entry.shell,
|
|
222
|
+
}));
|
|
223
|
+
}
|
|
224
|
+
/** Close every terminal and stop the retention sweep. */
|
|
225
|
+
dispose() {
|
|
226
|
+
clearInterval(this.sweep);
|
|
227
|
+
for (const entry of [...this.entries.values()]) {
|
|
228
|
+
this.entries.delete(entry.key);
|
|
229
|
+
this.byId.delete(entry.terminalId);
|
|
230
|
+
void entry.terminal.terminate();
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
// drop removes one entry from both indexes without terminating it; the caller
|
|
234
|
+
// owns termination so a replace never races the old shell's teardown.
|
|
235
|
+
drop(key) {
|
|
236
|
+
const entry = this.entries.get(key);
|
|
237
|
+
if (entry === undefined)
|
|
238
|
+
return;
|
|
239
|
+
this.entries.delete(key);
|
|
240
|
+
this.byId.delete(entry.terminalId);
|
|
241
|
+
}
|
|
242
|
+
// reap closes terminals no browser has attached for the retention window.
|
|
243
|
+
reap() {
|
|
244
|
+
const cutoff = Date.now() - this.retentionMs;
|
|
245
|
+
for (const entry of [...this.entries.values()]) {
|
|
246
|
+
if (entry.listener === undefined && entry.lastUsed < cutoff) {
|
|
247
|
+
this.drop(entry.key);
|
|
248
|
+
void entry.terminal.terminate();
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type TerminalShellView } from "./client/terminal-protocol.js";
|
|
2
|
+
import { type WorkspaceBinding } from "./workspace-binding.js";
|
|
3
|
+
export declare const SHELL_CANDIDATES: readonly ["zsh", "bash", "fish", "pwsh", "powershell", "nu", "ksh", "mksh", "dash", "ash", "sh"];
|
|
4
|
+
/**
|
|
5
|
+
* Discover the shells one container really provides.
|
|
6
|
+
* @param binding - guest connection for the target container.
|
|
7
|
+
* @param cwd - an absolute path already mounted in that container.
|
|
8
|
+
* @param signal - cancellation of the probe.
|
|
9
|
+
* @returns each shell's requested name and resolved absolute path, most capable first.
|
|
10
|
+
*/
|
|
11
|
+
export declare function discoverShells(binding: WorkspaceBinding, cwd: string, signal?: AbortSignal): Promise<TerminalShellView[]>;
|
|
12
|
+
/**
|
|
13
|
+
* Require one shell to still exist in the container, so a client cannot ask for
|
|
14
|
+
* an arbitrary executable and a shell that vanished since the picker loaded is
|
|
15
|
+
* refused instead of spawned.
|
|
16
|
+
* @param binding - guest connection for the target container.
|
|
17
|
+
* @param cwd - an absolute path already mounted in that container.
|
|
18
|
+
* @param path - the absolute shell path the client selected.
|
|
19
|
+
* @param signal - cancellation of the probe.
|
|
20
|
+
* @returns the verified shell.
|
|
21
|
+
*/
|
|
22
|
+
export declare function requireShell(binding: WorkspaceBinding, cwd: string, path: string, signal?: AbortSignal): Promise<TerminalShellView>;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Elouan Martinet <exa@elou.world>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: MIT
|
|
4
|
+
// Shell discovery inside one container. The candidate list carries names only:
|
|
5
|
+
// where a shell lives is answered by the container's own PATH (including the
|
|
6
|
+
// deployment's PATH additions), /etc/shells, and $SHELL, so a shell installed
|
|
7
|
+
// anywhere the container can see it is found without hardcoded locations.
|
|
8
|
+
import { fsError, runExec } from "./guest-rpc.js";
|
|
9
|
+
// Names looked up on the container's PATH, ordered most capable first so the
|
|
10
|
+
// picker offers — and preselects — the best shell the container has. The first
|
|
11
|
+
// five mirror the harness's own candidate order; the minimal POSIX shells come
|
|
12
|
+
// last, with `sh` final because /bin/sh is usually a symlink to one of them.
|
|
13
|
+
export const SHELL_CANDIDATES = [
|
|
14
|
+
"zsh",
|
|
15
|
+
"bash",
|
|
16
|
+
"fish",
|
|
17
|
+
"pwsh",
|
|
18
|
+
"powershell",
|
|
19
|
+
"nu",
|
|
20
|
+
"ksh",
|
|
21
|
+
"mksh",
|
|
22
|
+
"dash",
|
|
23
|
+
"ash",
|
|
24
|
+
"sh",
|
|
25
|
+
];
|
|
26
|
+
// POSIX `command -v`, not bash's `type -P`: the probe must not require a shell
|
|
27
|
+
// it is still looking for, and it runs under /bin/sh in every image we build.
|
|
28
|
+
const PROBE_SCRIPT = [
|
|
29
|
+
'for name in "$@"; do',
|
|
30
|
+
' path=$(command -v -- "$name" 2>/dev/null) || continue',
|
|
31
|
+
' case $path in /*) [ -x "$path" ] && printf "%s\\t%s\\n" "$name" "$path" ;; esac',
|
|
32
|
+
"done",
|
|
33
|
+
'for candidate in "$SHELL"; do',
|
|
34
|
+
' case $candidate in /*) [ -x "$candidate" ] && printf "%s\\t%s\\n" "${candidate##*/}" "$candidate" ;; esac',
|
|
35
|
+
"done",
|
|
36
|
+
"if [ -f /etc/shells ]; then",
|
|
37
|
+
" while IFS= read -r candidate; do",
|
|
38
|
+
' case $candidate in ""|\#*) continue ;; esac',
|
|
39
|
+
' case $candidate in /*) [ -x "$candidate" ] && printf "%s\\t%s\\n" "${candidate##*/}" "$candidate" ;; esac',
|
|
40
|
+
" done < /etc/shells",
|
|
41
|
+
"fi",
|
|
42
|
+
].join("\n");
|
|
43
|
+
const PROBE_TIMEOUT_MS = 10_000;
|
|
44
|
+
const PROBE_MAX_BYTES = 64 * 1024;
|
|
45
|
+
// One ordered list drives both the probe and the result order, so the two can
|
|
46
|
+
// never disagree.
|
|
47
|
+
const SHELL_RANK = new Map(SHELL_CANDIDATES.map((name, index) => [name, index]));
|
|
48
|
+
function rank(name) {
|
|
49
|
+
return SHELL_RANK.get(name) ?? SHELL_CANDIDATES.length;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Discover the shells one container really provides.
|
|
53
|
+
* @param binding - guest connection for the target container.
|
|
54
|
+
* @param cwd - an absolute path already mounted in that container.
|
|
55
|
+
* @param signal - cancellation of the probe.
|
|
56
|
+
* @returns each shell's requested name and resolved absolute path, most capable first.
|
|
57
|
+
*/
|
|
58
|
+
export async function discoverShells(binding, cwd, signal) {
|
|
59
|
+
const result = await runExec(binding, ["/bin/sh", "-c", PROBE_SCRIPT, "sh", ...SHELL_CANDIDATES], cwd, undefined, PROBE_TIMEOUT_MS, undefined, { signal, maxBytes: PROBE_MAX_BYTES });
|
|
60
|
+
// The probe prints PATH hits first, then $SHELL and /etc/shells, so keeping
|
|
61
|
+
// the first path per candidate name prefers the PATH answer and drops both
|
|
62
|
+
// symlinked duplicates (/bin/sh vs /usr/bin/sh) and anything /etc/shells
|
|
63
|
+
// lists that is not one of our candidate names (rbash, git-shell, …).
|
|
64
|
+
const candidates = new Set(SHELL_CANDIDATES);
|
|
65
|
+
const byName = new Map();
|
|
66
|
+
for (const line of result.stdout.split("\n")) {
|
|
67
|
+
const [rawName, rawPath] = line.split("\t");
|
|
68
|
+
const name = (rawName ?? "").trim();
|
|
69
|
+
const path = (rawPath ?? "").trim();
|
|
70
|
+
if (name === "" || !path.startsWith("/"))
|
|
71
|
+
continue;
|
|
72
|
+
if (!candidates.has(name))
|
|
73
|
+
continue;
|
|
74
|
+
if (!byName.has(name))
|
|
75
|
+
byName.set(name, path);
|
|
76
|
+
}
|
|
77
|
+
return [...byName]
|
|
78
|
+
.map(([name, path]) => ({ name, path }))
|
|
79
|
+
.sort((left, right) => rank(left.name) - rank(right.name) ||
|
|
80
|
+
left.name.localeCompare(right.name));
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Require one shell to still exist in the container, so a client cannot ask for
|
|
84
|
+
* an arbitrary executable and a shell that vanished since the picker loaded is
|
|
85
|
+
* refused instead of spawned.
|
|
86
|
+
* @param binding - guest connection for the target container.
|
|
87
|
+
* @param cwd - an absolute path already mounted in that container.
|
|
88
|
+
* @param path - the absolute shell path the client selected.
|
|
89
|
+
* @param signal - cancellation of the probe.
|
|
90
|
+
* @returns the verified shell.
|
|
91
|
+
*/
|
|
92
|
+
export async function requireShell(binding, cwd, path, signal) {
|
|
93
|
+
const shells = await discoverShells(binding, cwd, signal);
|
|
94
|
+
const match = shells.find((shell) => shell.path === path);
|
|
95
|
+
if (match === undefined) {
|
|
96
|
+
throw fsError("FS_IO_ERROR", `shell ${JSON.stringify(path)} is not available in this container`);
|
|
97
|
+
}
|
|
98
|
+
return match;
|
|
99
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Elouan Martinet <exa@elou.world>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: MIT
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
// Importing the client module here also typechecks it under the host program,
|
|
7
|
+
// which has no DOM lib: it must stay environment-free data.
|
|
8
|
+
import { PODMAN_TERMINAL_SHORTCUT_DEFAULTS, PODMAN_TERMINAL_SHORTCUT_FALLBACK_DEFAULTS, } from "./client/terminal-tab.js";
|
|
9
|
+
// The harness's shortcuts registry rejects a declared default it does not admit
|
|
10
|
+
// by THROWING from register(), which runs inside apply(): one bad profile fails
|
|
11
|
+
// the client entry and the whole web boot. These assertions pin the profiles
|
|
12
|
+
// that rule allows, so such a break is caught by pnpm test instead of in the UI.
|
|
13
|
+
test("the Podman terminal shortcut prefers the built-in terminal's binding", () => {
|
|
14
|
+
const defaults = PODMAN_TERMINAL_SHORTCUT_DEFAULTS;
|
|
15
|
+
assert.deepEqual(Object.keys(defaults).sort(), [
|
|
16
|
+
"desktop:linux",
|
|
17
|
+
"desktop:macos",
|
|
18
|
+
"desktop:windows",
|
|
19
|
+
"web:macos",
|
|
20
|
+
"web:windows",
|
|
21
|
+
]);
|
|
22
|
+
for (const value of Object.values(PODMAN_TERMINAL_SHORTCUT_DEFAULTS)) {
|
|
23
|
+
assert.deepEqual(value, { code: "Backquote", modifiers: ["control"] });
|
|
24
|
+
}
|
|
25
|
+
// Linux Web admits only Ctrl+/, Ctrl+Shift+, and Ctrl+Shift+.
|
|
26
|
+
assert.equal(defaults["web:linux"], undefined);
|
|
27
|
+
});
|
|
28
|
+
test("the fallback binding is distinct and equally admitted", () => {
|
|
29
|
+
const fallback = PODMAN_TERMINAL_SHORTCUT_FALLBACK_DEFAULTS;
|
|
30
|
+
assert.deepEqual(Object.keys(fallback).sort(), [
|
|
31
|
+
"desktop:linux",
|
|
32
|
+
"desktop:macos",
|
|
33
|
+
"desktop:windows",
|
|
34
|
+
"web:macos",
|
|
35
|
+
"web:windows",
|
|
36
|
+
]);
|
|
37
|
+
// macOS Web admits Ctrl+` and Meta+Shift+`, never Ctrl+Shift+`.
|
|
38
|
+
assert.deepEqual(PODMAN_TERMINAL_SHORTCUT_FALLBACK_DEFAULTS["web:macos"], {
|
|
39
|
+
code: "Backquote",
|
|
40
|
+
modifiers: ["meta", "shift"],
|
|
41
|
+
});
|
|
42
|
+
for (const profile of [
|
|
43
|
+
"desktop:macos",
|
|
44
|
+
"desktop:windows",
|
|
45
|
+
"desktop:linux",
|
|
46
|
+
"web:windows",
|
|
47
|
+
]) {
|
|
48
|
+
assert.deepEqual(PODMAN_TERMINAL_SHORTCUT_FALLBACK_DEFAULTS[profile], {
|
|
49
|
+
code: "Backquote",
|
|
50
|
+
modifiers: ["control", "shift"],
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
// The two bindings can never overlap, so the fallback cannot be the reason a
|
|
54
|
+
// re-enabled built-in terminal breaks the boot.
|
|
55
|
+
for (const profile of Object.keys(fallback)) {
|
|
56
|
+
assert.notDeepEqual(fallback[profile], PODMAN_TERMINAL_SHORTCUT_DEFAULTS[profile]);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Elouan Martinet <exa@elou.world>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: MIT
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { containerOptions, shellName, validContainer, validShell, } from "./client/terminal-targets.js";
|
|
7
|
+
function row(containerName, workspaceSlug) {
|
|
8
|
+
return { containerName, workspaceSlug };
|
|
9
|
+
}
|
|
10
|
+
test("terminal container options are logical names only", () => {
|
|
11
|
+
const containers = [
|
|
12
|
+
row("default", "alpha"),
|
|
13
|
+
row("dev", "alpha"),
|
|
14
|
+
row("db", "beta"),
|
|
15
|
+
// A podman name is never a valid target: the API rejects it.
|
|
16
|
+
row("dsh-podman-alpha-default", "alpha"),
|
|
17
|
+
];
|
|
18
|
+
assert.deepEqual(containerOptions(containers, "alpha"), ["dev"]);
|
|
19
|
+
assert.deepEqual(containerOptions(containers, "beta"), ["db"]);
|
|
20
|
+
assert.deepEqual(containerOptions(containers, "missing"), []);
|
|
21
|
+
assert.deepEqual(containerOptions(containers, undefined), []);
|
|
22
|
+
});
|
|
23
|
+
test("remembered targets are reused only while they are still offered", () => {
|
|
24
|
+
// Containers: the default answer is the workspace's own default.
|
|
25
|
+
assert.equal(validContainer(undefined, ["dev"]), "");
|
|
26
|
+
assert.equal(validContainer("", ["dev"]), "");
|
|
27
|
+
assert.equal(validContainer("dev", ["dev"]), "dev");
|
|
28
|
+
assert.equal(validContainer("gone", ["dev"]), "");
|
|
29
|
+
// Shells: a recreated container may no longer offer the remembered path.
|
|
30
|
+
const shells = [{ name: "bash", path: "/usr/bin/bash" }];
|
|
31
|
+
assert.equal(validShell(undefined, shells), undefined);
|
|
32
|
+
assert.equal(validShell("", shells), undefined);
|
|
33
|
+
assert.equal(validShell("/usr/bin/bash", shells), "/usr/bin/bash");
|
|
34
|
+
assert.equal(validShell("/bin/zsh", shells), undefined);
|
|
35
|
+
});
|
|
36
|
+
test("a shell path is named by its basename", () => {
|
|
37
|
+
assert.equal(shellName("/usr/bin/bash"), "bash");
|
|
38
|
+
assert.equal(shellName("/bin/sh"), "sh");
|
|
39
|
+
assert.equal(shellName("pwsh"), "pwsh");
|
|
40
|
+
assert.equal(shellName("/opt/tools/bin/"), undefined);
|
|
41
|
+
assert.equal(shellName(""), undefined);
|
|
42
|
+
assert.equal(shellName(undefined), undefined);
|
|
43
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Elouan Martinet <exa@elou.world>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: MIT
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { forgetTerminalTitle, publishTerminalTitle, subscribeTerminalTitles, terminalTitle, } from "./client/terminal-titles.js";
|
|
7
|
+
test("published terminal titles are readable, notifying and forgettable", () => {
|
|
8
|
+
let notifications = 0;
|
|
9
|
+
const unsubscribe = subscribeTerminalTitles(() => {
|
|
10
|
+
notifications += 1;
|
|
11
|
+
});
|
|
12
|
+
assert.equal(terminalTitle("s1:t1"), undefined);
|
|
13
|
+
publishTerminalTitle("s1:t1", "bash");
|
|
14
|
+
assert.equal(terminalTitle("s1:t1"), "bash");
|
|
15
|
+
assert.equal(notifications, 1);
|
|
16
|
+
// Re-publishing the same name is a no-op; another terminal is independent.
|
|
17
|
+
publishTerminalTitle("s1:t1", "bash");
|
|
18
|
+
publishTerminalTitle("s1:t1", "");
|
|
19
|
+
assert.equal(notifications, 1);
|
|
20
|
+
publishTerminalTitle("s1:t2", "sh");
|
|
21
|
+
assert.equal(terminalTitle("s1:t2"), "sh");
|
|
22
|
+
assert.equal(notifications, 2);
|
|
23
|
+
// The same tab id in another session never bleeds through.
|
|
24
|
+
publishTerminalTitle("s2:t1", "zsh");
|
|
25
|
+
assert.equal(terminalTitle("s1:t1"), "bash");
|
|
26
|
+
assert.equal(terminalTitle("s2:t1"), "zsh");
|
|
27
|
+
forgetTerminalTitle("s1:t1");
|
|
28
|
+
assert.equal(terminalTitle("s1:t1"), undefined);
|
|
29
|
+
assert.equal(notifications, 4);
|
|
30
|
+
// Forgetting an unknown key changes nothing.
|
|
31
|
+
forgetTerminalTitle("s1:t1");
|
|
32
|
+
assert.equal(notifications, 4);
|
|
33
|
+
unsubscribe();
|
|
34
|
+
publishTerminalTitle("s1:t2", "fish");
|
|
35
|
+
assert.equal(notifications, 4);
|
|
36
|
+
});
|
package/dist/test-support.d.ts
CHANGED
|
@@ -2,6 +2,13 @@ import { EventEmitter } from "node:events";
|
|
|
2
2
|
export declare const WORKSPACE_ID = "2c573001-4171-4900-904b-12a5cc02737a";
|
|
3
3
|
export declare class FakeTerminalCall extends EventEmitter {
|
|
4
4
|
readonly stdinChunks: Buffer[];
|
|
5
|
+
readonly resizes: Array<{
|
|
6
|
+
rows: number;
|
|
7
|
+
cols: number;
|
|
8
|
+
}>;
|
|
9
|
+
start: any;
|
|
10
|
+
closed: boolean;
|
|
11
|
+
ended: boolean;
|
|
5
12
|
write(message: any): void;
|
|
6
13
|
end(): void;
|
|
7
14
|
cancel(): void;
|
|
@@ -283,6 +290,24 @@ export declare function editGuest(stat: Record<string, unknown>, content?: Buffe
|
|
|
283
290
|
token: string;
|
|
284
291
|
};
|
|
285
292
|
export declare function editProvider(binding: unknown): import("./fs-provider.js").FilesystemProvider;
|
|
293
|
+
export declare class FakeExecStream extends EventEmitter {
|
|
294
|
+
private readonly starts;
|
|
295
|
+
private readonly options;
|
|
296
|
+
paused: number;
|
|
297
|
+
resumed: number;
|
|
298
|
+
cancelled: boolean;
|
|
299
|
+
constructor(starts: Array<Record<string, any>>, options: {
|
|
300
|
+
emitExit?: boolean;
|
|
301
|
+
exit?: Record<string, unknown>;
|
|
302
|
+
stdout?: string;
|
|
303
|
+
});
|
|
304
|
+
write(message: any): boolean;
|
|
305
|
+
end(): void;
|
|
306
|
+
pause(): void;
|
|
307
|
+
resume(): void;
|
|
308
|
+
cancel(): void;
|
|
309
|
+
emitData(message: Record<string, unknown>): void;
|
|
310
|
+
}
|
|
286
311
|
export declare function spawnGuest(options?: {
|
|
287
312
|
emitExit?: boolean;
|
|
288
313
|
exit?: Record<string, unknown>;
|
|
@@ -294,14 +319,19 @@ export declare function spawnGuest(options?: {
|
|
|
294
319
|
}[];
|
|
295
320
|
starts: Record<string, any>[];
|
|
296
321
|
deletes: Record<string, unknown>[];
|
|
322
|
+
streams: FakeExecStream[];
|
|
323
|
+
guest: {
|
|
324
|
+
exec: () => FakeExecStream;
|
|
325
|
+
signal: (request: {
|
|
326
|
+
processId: string;
|
|
327
|
+
signal: string;
|
|
328
|
+
}, _metadata: unknown, callback: Function) => void;
|
|
329
|
+
delete: (request: Record<string, unknown>, _metadata: unknown, callback: Function) => void;
|
|
330
|
+
};
|
|
297
331
|
resolver: {
|
|
298
332
|
resolve: () => Promise<{
|
|
299
333
|
guest: {
|
|
300
|
-
exec: () =>
|
|
301
|
-
on(event: string, handler: Function): void;
|
|
302
|
-
write(message: any): void;
|
|
303
|
-
end(): void;
|
|
304
|
-
};
|
|
334
|
+
exec: () => FakeExecStream;
|
|
305
335
|
signal: (request: {
|
|
306
336
|
processId: string;
|
|
307
337
|
signal: string;
|