@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.
Files changed (186) hide show
  1. package/README.md +12 -0
  2. package/README.zh.md +14 -2
  3. package/cordis.patch.yml +58 -0
  4. package/dist/approval-reasons.d.ts +3 -4
  5. package/dist/approval-reasons.js +40 -49
  6. package/dist/approval-reasons.test.js +43 -15
  7. package/dist/approval.js +27 -10
  8. package/dist/approval.test.js +165 -34
  9. package/dist/card-route.d.ts +3 -2
  10. package/dist/card-route.js +103 -35
  11. package/dist/card-route.test.js +60 -43
  12. package/dist/card-test-support.js +39 -66
  13. package/dist/client/ContainerCard.d.ts +1 -1
  14. package/dist/client/ContainerCard.js +76 -70
  15. package/dist/client/card-protocol.d.ts +3 -3
  16. package/dist/client/container-card-caches.js +11 -6
  17. package/dist/client/container-card-controller.d.ts +13 -18
  18. package/dist/client/container-card-controller.js +86 -40
  19. package/dist/client/container-card-create-modal.d.ts +2 -2
  20. package/dist/client/container-card-create-modal.js +13 -5
  21. package/dist/client/container-card-default-env.d.ts +10 -0
  22. package/dist/client/container-card-default-env.js +69 -0
  23. package/dist/client/container-card-directory.d.ts +2 -2
  24. package/dist/client/container-card-directory.js +12 -4
  25. package/dist/client/container-card-editors.d.ts +3 -3
  26. package/dist/client/container-card-editors.js +104 -32
  27. package/dist/client/container-card-images.d.ts +4 -4
  28. package/dist/client/container-card-images.js +19 -6
  29. package/dist/client/container-card-paths.d.ts +3 -3
  30. package/dist/client/container-card-paths.js +18 -11
  31. package/dist/client/container-card-row.d.ts +10 -6
  32. package/dist/client/container-card-row.js +82 -13
  33. package/dist/client/container-card-secrets.d.ts +3 -3
  34. package/dist/client/container-card-secrets.js +8 -6
  35. package/dist/client/container-card-shared.d.ts +4 -17
  36. package/dist/client/container-card-shared.js +9 -25
  37. package/dist/client/container-card-styles.d.ts +2 -8
  38. package/dist/client/container-card-styles.js +14 -52
  39. package/dist/client/container-card-volumes.d.ts +2 -2
  40. package/dist/client/container-card-volumes.js +9 -5
  41. package/dist/client/container-card-workspace.d.ts +8 -6
  42. package/dist/client/container-card-workspace.js +9 -3
  43. package/dist/client/directory-picker.js +2 -1
  44. package/dist/client/index.js +13109 -2117
  45. package/dist/client/locales.d.ts +6 -1
  46. package/dist/client/locales.js +88 -38
  47. package/dist/client/podman-terminal-guide.d.ts +11 -0
  48. package/dist/client/podman-terminal-guide.js +208 -0
  49. package/dist/client/podman-terminal-title.d.ts +7 -0
  50. package/dist/client/podman-terminal-title.js +30 -0
  51. package/dist/client/podman-terminal.d.ts +26 -0
  52. package/dist/client/podman-terminal.js +412 -0
  53. package/dist/client/read-only-approval.d.ts +2 -2
  54. package/dist/client/read-only-approval.js +13 -3
  55. package/dist/client/slot-contract.d.ts +0 -11
  56. package/dist/client/terminal-preference.d.ts +26 -0
  57. package/dist/client/terminal-preference.js +67 -0
  58. package/dist/client/terminal-protocol.d.ts +85 -0
  59. package/dist/client/terminal-protocol.js +14 -0
  60. package/dist/client/terminal-tab.d.ts +60 -0
  61. package/dist/client/terminal-tab.js +35 -0
  62. package/dist/client/terminal-targets.d.ts +33 -0
  63. package/dist/client/terminal-targets.js +65 -0
  64. package/dist/client/terminal-titles.d.ts +23 -0
  65. package/dist/client/terminal-titles.js +52 -0
  66. package/dist/client/terminal-transport.d.ts +63 -0
  67. package/dist/client/terminal-transport.js +207 -0
  68. package/dist/client/tool-views.js +215 -49
  69. package/dist/container-env.d.ts +11 -0
  70. package/dist/container-env.js +96 -0
  71. package/dist/container-env.test.d.ts +1 -0
  72. package/dist/container-env.test.js +93 -0
  73. package/dist/containers.test.js +196 -18
  74. package/dist/daemons.test.js +18 -4
  75. package/dist/dsh-version.js +2 -1
  76. package/dist/env-rows.d.ts +14 -0
  77. package/dist/env-rows.js +52 -0
  78. package/dist/env-rows.test.d.ts +1 -0
  79. package/dist/env-rows.test.js +43 -0
  80. package/dist/fs-provider.d.ts +1 -0
  81. package/dist/fs-provider.js +26 -12
  82. package/dist/fs-provider.test.js +51 -10
  83. package/dist/generated/version.d.ts +2 -2
  84. package/dist/generated/version.js +2 -2
  85. package/dist/grpc/proto/dshctl/v1/control.proto +8 -1
  86. package/dist/grpc/proto/dshguest/v1/guest.proto +5 -0
  87. package/dist/grpc/runtime-client.d.ts +3 -1
  88. package/dist/grpc/runtime-client.js +90 -17
  89. package/dist/guest-rpc.d.ts +15 -19
  90. package/dist/guest-rpc.js +289 -70
  91. package/dist/guest-rpc.test.d.ts +1 -0
  92. package/dist/guest-rpc.test.js +241 -0
  93. package/dist/guest-terminal.d.ts +30 -0
  94. package/dist/guest-terminal.js +196 -0
  95. package/dist/images.test.js +10 -3
  96. package/dist/index.d.ts +11 -16
  97. package/dist/index.js +42 -36
  98. package/dist/locales.test.d.ts +1 -0
  99. package/dist/locales.test.js +34 -0
  100. package/dist/misc.test.js +38 -10
  101. package/dist/mount-enums.js +2 -1
  102. package/dist/mount-input.d.ts +2 -0
  103. package/dist/mount-input.js +38 -22
  104. package/dist/mounts.test.js +81 -11
  105. package/dist/naming.test.d.ts +1 -0
  106. package/dist/naming.test.js +28 -0
  107. package/dist/output-reader.js +36 -4
  108. package/dist/package-deps.test.d.ts +1 -0
  109. package/dist/package-deps.test.js +49 -0
  110. package/dist/paths.test.js +4 -2
  111. package/dist/plugin-meta.test.d.ts +1 -0
  112. package/dist/plugin-meta.test.js +29 -0
  113. package/dist/project-path.js +33 -7
  114. package/dist/project-path.test.js +14 -0
  115. package/dist/prompts.d.ts +0 -4
  116. package/dist/prompts.js +15 -84
  117. package/dist/read-only-shell.js +3 -1
  118. package/dist/read-only-shell.test.js +80 -25
  119. package/dist/runtime-client.test.d.ts +1 -0
  120. package/dist/runtime-client.test.js +53 -0
  121. package/dist/secrets.test.js +21 -6
  122. package/dist/settings-commands.test.js +61 -14
  123. package/dist/settings-create.test.js +32 -10
  124. package/dist/settings-default-env.test.d.ts +1 -0
  125. package/dist/settings-default-env.test.js +175 -0
  126. package/dist/settings-mounts.test.js +46 -7
  127. package/dist/settings-schema.d.ts +6 -11
  128. package/dist/settings-schema.js +4 -8
  129. package/dist/settings-schema.test.d.ts +1 -0
  130. package/dist/settings-schema.test.js +35 -0
  131. package/dist/settings-workspaces.test.js +36 -63
  132. package/dist/spill-store.d.ts +1 -0
  133. package/dist/spill-store.js +17 -4
  134. package/dist/spill-store.test.js +31 -8
  135. package/dist/subprocess.d.ts +4 -0
  136. package/dist/subprocess.js +148 -162
  137. package/dist/subprocess.test.js +118 -8
  138. package/dist/terminal-preference.test.d.ts +1 -0
  139. package/dist/terminal-preference.test.js +62 -0
  140. package/dist/terminal-route.d.ts +10 -0
  141. package/dist/terminal-route.js +270 -0
  142. package/dist/terminal-route.test.d.ts +1 -0
  143. package/dist/terminal-route.test.js +265 -0
  144. package/dist/terminal-sessions.d.ts +69 -0
  145. package/dist/terminal-sessions.js +252 -0
  146. package/dist/terminal-shells.d.ts +22 -0
  147. package/dist/terminal-shells.js +99 -0
  148. package/dist/terminal-tab.test.d.ts +1 -0
  149. package/dist/terminal-tab.test.js +58 -0
  150. package/dist/terminal-targets.test.d.ts +1 -0
  151. package/dist/terminal-targets.test.js +43 -0
  152. package/dist/terminal-titles.test.d.ts +1 -0
  153. package/dist/terminal-titles.test.js +36 -0
  154. package/dist/test-support.d.ts +35 -5
  155. package/dist/test-support.js +91 -29
  156. package/dist/tool-defs.js +19 -4
  157. package/dist/tool-handlers.js +80 -28
  158. package/dist/tool-params.js +19 -5
  159. package/dist/volumes.test.js +1 -1
  160. package/dist/workspace-binding.d.ts +18 -9
  161. package/dist/workspace-binding.js +244 -77
  162. package/dist/workspace-binding.test.js +189 -25
  163. package/docs/architecture.md +62 -21
  164. package/docs/architecture.zh.md +57 -13
  165. package/docs/configuration.md +61 -20
  166. package/docs/configuration.zh.md +53 -19
  167. package/docs/development-prompt.md +72 -0
  168. package/docs/development-prompt.zh.md +69 -0
  169. package/docs/development.md +141 -21
  170. package/docs/development.zh.md +125 -17
  171. package/docs/install-dsh-and-dsh-podman.md +21 -8
  172. package/docs/install-dsh-and-dsh-podman.zh.md +18 -8
  173. package/docs/uninstall.md +83 -0
  174. package/docs/uninstall.zh.md +79 -0
  175. package/docs/update.md +120 -0
  176. package/docs/update.zh.md +114 -0
  177. package/docs/usage.md +108 -61
  178. package/docs/usage.zh.md +76 -38
  179. package/locale/en.json +6 -0
  180. package/locale/zh.json +6 -0
  181. package/package.json +58 -25
  182. package/quadlet/dsh-podman-orchestrator.container +46 -0
  183. package/quadlet/dsh.container +35 -0
  184. package/LICENSE.pkg +0 -17103
  185. package/dist/preferences.d.ts +0 -6
  186. 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
+ });
@@ -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;