portless-studio 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/README.md +60 -218
  2. package/dist/cli/mcp/studio-tools.js +20 -7
  3. package/dist/cli/studio-items.js +5 -2
  4. package/dist/core/api/contract.js +17 -1
  5. package/dist/core/config/studio-config.js +4 -0
  6. package/dist/core/doctor/platform-commands.js +5 -2
  7. package/dist/core/ports/direct-url.js +7 -0
  8. package/dist/core/ports/docker-container.js +85 -0
  9. package/dist/core/routes/route.js +14 -2
  10. package/dist/server/application/container-source.js +19 -0
  11. package/dist/server/application/doctor.js +199 -9
  12. package/dist/server/application/port-discovery.js +49 -6
  13. package/dist/server/application/short-urls.js +136 -0
  14. package/dist/server/application/studio-runtime.js +46 -7
  15. package/dist/server/application/terminals.js +142 -15
  16. package/dist/server/infrastructure/system/docker-containers.js +64 -0
  17. package/dist/server/infrastructure/system/doctor-facts.js +43 -1
  18. package/dist/server/infrastructure/system/port-forwarder.js +78 -0
  19. package/dist/server/infrastructure/system/process-runner.js +2 -1
  20. package/dist/server/infrastructure/terminal/process-tree.js +71 -0
  21. package/dist/server/infrastructure/terminal/terminal-spawner.js +44 -3
  22. package/dist/server/interface/http/http-server.js +38 -2
  23. package/dist/server/main.js +41 -6
  24. package/dist/web/assets/index-BDEwEBPS.js +15 -0
  25. package/dist/web/assets/index-C9wkL1J8.css +1 -0
  26. package/dist/web/index.html +2 -2
  27. package/package.json +1 -1
  28. package/dist/web/assets/index-B3KMqQ-l.js +0 -15
  29. package/dist/web/assets/index-CWujmTe4.css +0 -1
@@ -23,6 +23,8 @@ export class TerminalManager {
23
23
  #graceMs;
24
24
  #maxTerminals;
25
25
  #namingTimeoutMs;
26
+ #processTree;
27
+ #homeDir;
26
28
  #entries = new Map();
27
29
  #watchers = new Set();
28
30
  /** Folders a local program ran a terminal in. */
@@ -30,6 +32,8 @@ export class TerminalManager {
30
32
  #nextId = 1;
31
33
  #stopWorkspaceWatch;
32
34
  #naming = false;
35
+ #refreshingPorts = false;
36
+ #portsAgain = false;
33
37
  constructor(options) {
34
38
  this.#spawner = options.spawner;
35
39
  this.#workspace = options.workspace;
@@ -39,6 +43,8 @@ export class TerminalManager {
39
43
  this.#graceMs = options.graceMs ?? DEFAULT_GRACE_MS;
40
44
  this.#maxTerminals = options.maxTerminals ?? DEFAULT_MAX_TERMINALS;
41
45
  this.#namingTimeoutMs = options.namingTimeoutMs ?? DEFAULT_NAMING_TIMEOUT_MS;
46
+ this.#processTree = options.processTree;
47
+ this.#homeDir = options.homeDir;
42
48
  }
43
49
  get mode() {
44
50
  return this.#spawner.mode;
@@ -53,7 +59,21 @@ export class TerminalManager {
53
59
  /** Calls `onChange` when a terminal starts, ends, is named or closes. */
54
60
  watch(onChange) {
55
61
  this.#watchers.add(onChange);
56
- return () => this.#watchers.delete(onChange);
62
+ this.#updateWorkspaceWatch();
63
+ return () => {
64
+ this.#watchers.delete(onChange);
65
+ this.#updateWorkspaceWatch();
66
+ };
67
+ }
68
+ /** Gives the terminal a name of its own; an empty one gives back the default title. */
69
+ rename(id, title) {
70
+ const entry = this.#entries.get(id);
71
+ if (!entry)
72
+ return notFound(id);
73
+ const wanted = title.trim().slice(0, MAX_TITLE_CHARS);
74
+ entry.info = { ...entry.info, title: wanted || entry.defaultTitle };
75
+ this.#publishStatus(entry);
76
+ return ok({ ...entry.info });
57
77
  }
58
78
  async create(request, origin) {
59
79
  if (this.#entries.size >= this.#maxTerminals) {
@@ -71,14 +91,20 @@ export class TerminalManager {
71
91
  const cols = readSize(request.cols, DEFAULT_COLS);
72
92
  const rows = readSize(request.rows, DEFAULT_ROWS);
73
93
  const naming = readNaming(request.name);
74
- const title = (request.title?.trim() || command.value || "shell").slice(0, MAX_TITLE_CHARS);
94
+ const defaultTitle = (command.value || this.#spawner.shellName || "shell").slice(0, MAX_TITLE_CHARS);
95
+ const title = request.title?.trim().slice(0, MAX_TITLE_CHARS) || defaultTitle;
96
+ const project = await this.#projectOf(cwd.value);
75
97
  const entry = {
98
+ defaultTitle,
76
99
  info: {
77
100
  id,
78
101
  title,
79
102
  cwd: cwd.value,
80
103
  command: command.value ?? "",
81
- project: await this.#projectOf(cwd.value),
104
+ project: project?.name ?? null,
105
+ worktree: project?.worktree ?? null,
106
+ branch: project?.branch ?? null,
107
+ ports: [],
82
108
  status: "running",
83
109
  exitCode: null,
84
110
  startedAt: this.#now(),
@@ -117,7 +143,11 @@ export class TerminalManager {
117
143
  this.#entries.set(id, entry);
118
144
  if (naming)
119
145
  this.#startNaming(entry);
146
+ else
147
+ this.#updateWorkspaceWatch();
120
148
  this.#changed();
149
+ if (this.#watchers.size > 0)
150
+ void this.#refreshPorts();
121
151
  return ok({ ...entry.info });
122
152
  }
123
153
  input(id, text) {
@@ -157,6 +187,9 @@ export class TerminalManager {
157
187
  entry.info = { ...entry.info, status: "running", exitCode: null, endedAt: null };
158
188
  entry.info.startedAt = this.#now();
159
189
  this.#publishStatus(entry);
190
+ this.#updateWorkspaceWatch();
191
+ if (this.#watchers.size > 0)
192
+ void this.#refreshPorts();
160
193
  return ok({ ...entry.info });
161
194
  }
162
195
  /** Closes the terminal; its command, if still running, is killed. */
@@ -170,7 +203,7 @@ export class TerminalManager {
170
203
  entry.process.kill();
171
204
  entry.process = null;
172
205
  entry.watchers.clear();
173
- this.#updateNamingWatch();
206
+ this.#updateWorkspaceWatch();
174
207
  this.#changed();
175
208
  return ok(undefined);
176
209
  }
@@ -232,7 +265,9 @@ export class TerminalManager {
232
265
  message: "The command ended before it opened a port.",
233
266
  });
234
267
  }
268
+ entry.info.ports = [];
235
269
  this.#publishStatus(entry);
270
+ this.#updateWorkspaceWatch();
236
271
  });
237
272
  return ok(undefined);
238
273
  }
@@ -270,6 +305,8 @@ export class TerminalManager {
270
305
  watcher();
271
306
  }
272
307
  async #resolveFolder(request, origin) {
308
+ if (origin.picked)
309
+ return this.#pickedFolder(request.cwd);
273
310
  if (typeof request.project === "string" && request.project !== "" && !request.cwd) {
274
311
  const folder = await this.#projectFolder(request.project);
275
312
  if (folder === null) {
@@ -294,6 +331,24 @@ export class TerminalManager {
294
331
  return ok(folder);
295
332
  return err(appError("path_not_allowed", "Terminals run only in project folders Studio shows. Start it from the CLI to use another folder."));
296
333
  }
334
+ /** A folder typed in the page's picker: any existing folder, with `~` as the home folder. */
335
+ #pickedFolder(typed) {
336
+ if (typeof typed !== "string" || typed.trim() === "") {
337
+ return err(appError("invalid_request", "Give the folder to open."));
338
+ }
339
+ let wanted = typed.trim();
340
+ if (this.#homeDir && (wanted === "~" || /^~[\\/]/.test(wanted))) {
341
+ wanted = `${this.#homeDir}${wanted.slice(1)}`;
342
+ }
343
+ if (!isAbsolute(wanted)) {
344
+ return err(appError("invalid_request", "The folder must be an absolute path or start with ~."));
345
+ }
346
+ const folder = this.#folders.directory(wanted);
347
+ if (folder === null) {
348
+ return err(appError("path_not_allowed", `${wanted} is not a folder on this machine.`));
349
+ }
350
+ return ok(folder);
351
+ }
297
352
  async #projectFolder(name) {
298
353
  const { routes, detected } = await this.#workspace.snapshot();
299
354
  for (const item of [...routes, ...detected]) {
@@ -303,6 +358,7 @@ export class TerminalManager {
303
358
  }
304
359
  return null;
305
360
  }
361
+ /** The project whose folder holds `cwd`, the innermost one when several do. */
306
362
  async #projectOf(cwd) {
307
363
  const { routes, detected } = await this.#workspace.snapshot();
308
364
  let best = null;
@@ -310,10 +366,70 @@ export class TerminalManager {
310
366
  const path = item.project?.path;
311
367
  if (!item.project || !path || !this.#folders.contains(path, cwd))
312
368
  continue;
313
- if (!best || path.length > best.length)
314
- best = { name: item.project.name, length: path.length };
369
+ if (!best?.path || path.length > best.path.length)
370
+ best = item.project;
371
+ }
372
+ return best;
373
+ }
374
+ /**
375
+ * Links every listening process to the running terminal it descends from, and publishes the
376
+ * terminals whose ports changed. Calls that arrive while one runs make it run once more.
377
+ */
378
+ async #refreshPorts() {
379
+ const tree = this.#processTree;
380
+ if (!tree)
381
+ return;
382
+ if (this.#refreshingPorts) {
383
+ this.#portsAgain = true;
384
+ return;
385
+ }
386
+ this.#refreshingPorts = true;
387
+ try {
388
+ do {
389
+ this.#portsAgain = false;
390
+ await this.#linkPorts(tree);
391
+ } while (this.#portsAgain);
392
+ }
393
+ finally {
394
+ this.#refreshingPorts = false;
395
+ }
396
+ }
397
+ async #linkPorts(tree) {
398
+ const running = [...this.#entries.values()].filter((entry) => entry.info.status === "running" && entry.info.pid !== null);
399
+ if (running.length === 0)
400
+ return;
401
+ const { routes, detected } = await this.#workspace.snapshot();
402
+ const listeners = new Map();
403
+ for (const item of detected) {
404
+ if (item.pid !== null)
405
+ listeners.set(item.port, { port: item.port, route: null, url: null });
406
+ }
407
+ const pids = new Map();
408
+ for (const item of detected)
409
+ if (item.pid !== null)
410
+ pids.set(item.port, item.pid);
411
+ for (const item of routes) {
412
+ if (item.listenerPid === undefined)
413
+ continue;
414
+ pids.set(item.port, item.listenerPid);
415
+ listeners.set(item.port, { port: item.port, route: item.name, url: item.url });
416
+ }
417
+ const ancestry = new Map();
418
+ for (const pid of new Set(pids.values())) {
419
+ ancestry.set(pid, await tree.ancestors(pid).catch(() => []));
420
+ }
421
+ for (const entry of running) {
422
+ const pid = entry.info.pid;
423
+ const ports = [...pids]
424
+ .filter(([, listener]) => listener === pid || ancestry.get(listener)?.includes(pid ?? -1))
425
+ .map(([port]) => listeners.get(port))
426
+ .filter((port) => port !== undefined)
427
+ .sort((a, b) => a.port - b.port);
428
+ if (JSON.stringify(ports) === JSON.stringify(entry.info.ports))
429
+ continue;
430
+ entry.info = { ...entry.info, ports };
431
+ this.#publishStatus(entry);
315
432
  }
316
- return best?.name ?? null;
317
433
  }
318
434
  async #portsInUse() {
319
435
  const { routes, detected } = await this.#workspace.snapshot();
@@ -330,18 +446,29 @@ export class TerminalManager {
330
446
  this.#publishStatus(entry);
331
447
  }, this.#namingTimeoutMs);
332
448
  entry.namingTimer.unref?.();
333
- this.#updateNamingWatch();
449
+ this.#updateWorkspaceWatch();
334
450
  }
335
451
  #waitingEntries() {
336
452
  return [...this.#entries.values()].filter((entry) => entry.info.naming?.state === "waiting");
337
453
  }
338
- /** Watches the workspace, which keeps Studio scanning, only while a terminal waits for a port. */
339
- #updateNamingWatch() {
340
- const waiting = this.#waitingEntries().length > 0;
341
- if (waiting && !this.#stopWorkspaceWatch) {
342
- this.#stopWorkspaceWatch = this.#workspace.watch(() => void this.#nameNewPorts());
454
+ /**
455
+ * Watches the workspace, which keeps Studio scanning, only while a terminal waits for a port,
456
+ * or while someone watches the list and a terminal runs, so the ports it opened stay current.
457
+ */
458
+ #updateWorkspaceWatch() {
459
+ const linking = this.#processTree !== undefined &&
460
+ this.#watchers.size > 0 &&
461
+ [...this.#entries.values()].some((entry) => entry.info.status === "running");
462
+ const needed = linking || this.#waitingEntries().length > 0;
463
+ if (needed && !this.#stopWorkspaceWatch) {
464
+ this.#stopWorkspaceWatch = this.#workspace.watch(() => {
465
+ if (this.#waitingEntries().length > 0)
466
+ void this.#nameNewPorts();
467
+ if (this.#watchers.size > 0)
468
+ void this.#refreshPorts();
469
+ });
343
470
  }
344
- else if (!waiting && this.#stopWorkspaceWatch) {
471
+ else if (!needed && this.#stopWorkspaceWatch) {
345
472
  this.#stopWorkspaceWatch();
346
473
  this.#stopWorkspaceWatch = undefined;
347
474
  }
@@ -357,7 +484,7 @@ export class TerminalManager {
357
484
  }
358
485
  finally {
359
486
  this.#naming = false;
360
- this.#updateNamingWatch();
487
+ this.#updateWorkspaceWatch();
361
488
  }
362
489
  }
363
490
  async #nameFor(entry, routes, detected) {
@@ -0,0 +1,64 @@
1
+ import { parseDockerPs } from "../../../core/ports/docker-container.js";
2
+ import { err, ok } from "../../../core/result.js";
3
+ import { appError } from "../../application/app-error.js";
4
+ import { execute } from "../portless/portless-cli.js";
5
+ /** A scan runs every few seconds; one `docker ps` serves the scans in between. */
6
+ const FRESH_MS = 3000;
7
+ /** Docker that is stopped or refuses this user is asked again less often. */
8
+ const UNAVAILABLE_MS = 15_000;
9
+ /** No `docker` on PATH: asked again in case it gets installed. */
10
+ const MISSING_MS = 60_000;
11
+ const LIST_TIMEOUT_MS = 2000;
12
+ /** `docker stop` waits up to 10 seconds for the container before it kills it. */
13
+ const STOP_TIMEOUT_MS = 30_000;
14
+ const LIST_ARGS = ["ps", "--format", "{{json .}}"];
15
+ const defaultRun = (args, timeoutMs) => execute("docker", args, { timeoutMs });
16
+ /**
17
+ * Reads running containers with `docker ps`, which works the same with Docker Engine on Linux and
18
+ * with Docker Desktop, OrbStack or Colima on macOS and Windows. Any failure reads as no containers.
19
+ */
20
+ export class DockerContainers {
21
+ #run;
22
+ #now;
23
+ #last = null;
24
+ #reading = null;
25
+ constructor(options = {}) {
26
+ this.#run = options.run ?? defaultRun;
27
+ this.#now = options.now ?? Date.now;
28
+ }
29
+ read() {
30
+ const last = this.#last;
31
+ if (last && this.#now() - last.at < maxAge(last.reading))
32
+ return Promise.resolve(last.reading);
33
+ this.#reading ??= this.#list().then((reading) => {
34
+ this.#last = { reading, at: this.#now() };
35
+ this.#reading = null;
36
+ return reading;
37
+ });
38
+ return this.#reading;
39
+ }
40
+ async stop(name) {
41
+ const outcome = await this.#run(["stop", name], STOP_TIMEOUT_MS).catch(() => null);
42
+ // The next read lists the containers again instead of showing the stopped one.
43
+ this.#last = null;
44
+ if (outcome?.exitCode === 0)
45
+ return ok(undefined);
46
+ const printed = outcome?.combined.trim() ?? "";
47
+ return err(appError("command_failed", printed || `docker stop ${name} did not finish.`, `docker stop ${name}`));
48
+ }
49
+ async #list() {
50
+ const outcome = await this.#run(LIST_ARGS, LIST_TIMEOUT_MS).catch(() => null);
51
+ if (outcome === null)
52
+ return { state: "unavailable" };
53
+ if (outcome.missing)
54
+ return { state: "missing" };
55
+ if (outcome.exitCode !== 0)
56
+ return { state: "unavailable" };
57
+ return { state: "ok", containers: parseDockerPs(outcome.stdout) };
58
+ }
59
+ }
60
+ function maxAge(reading) {
61
+ if (reading.state === "ok")
62
+ return FRESH_MS;
63
+ return reading.state === "missing" ? MISSING_MS : UNAVAILABLE_MS;
64
+ }
@@ -1,3 +1,4 @@
1
+ import { request } from "node:http";
1
2
  import { createServer } from "node:net";
2
3
  import * as path from "node:path";
3
4
  import { DEFAULT_CONFIG } from "../../../core/config/studio-config.js";
@@ -99,19 +100,22 @@ export async function proxyPidsAmong(platform, io, pids) {
99
100
  /** Gathers the facts the doctor checks. Each part is read once; nothing is changed. */
100
101
  export async function collectDoctorFacts(options) {
101
102
  const { platform, registry, configStore } = options;
102
- const [state, proxyPid, stored, proxyProcesses, loginService, systemNotifier] = await Promise.all([
103
+ const [state, proxyPid, stored, proxyProcesses, loginService, systemNotifier, docker, port80] = await Promise.all([
103
104
  registry.readSnapshot(),
104
105
  registry.proxyPid(),
105
106
  configStore.read(),
106
107
  listProxyProcesses(platform, options.io, options.uid),
107
108
  options.loginService ? options.loginService() : Promise.resolve(null),
108
109
  options.systemNotifier ? options.systemNotifier() : Promise.resolve(undefined),
110
+ options.docker ? dockerFacts(options.docker) : Promise.resolve(undefined),
111
+ options.lowPort ? options.lowPort(80) : Promise.resolve(undefined),
109
112
  ]);
110
113
  const config = stored.ok ? stored.value : DEFAULT_CONFIG;
111
114
  const proxyOwner = state.proxy.running && proxyPid !== null
112
115
  ? await options.processes.ownerOf(proxyPid)
113
116
  : "unknown";
114
117
  const https = state.proxy.running ? state.proxy.https : config.proxyHttps;
118
+ const shortUrls = await options.shortUrls?.(config).catch(() => undefined);
115
119
  return {
116
120
  platform,
117
121
  portless: state.portless,
@@ -128,8 +132,46 @@ export async function collectDoctorFacts(options) {
128
132
  loginService,
129
133
  ...(systemNotifier === undefined ? {} : { systemNotifier }),
130
134
  ...(options.routesFile ? { routesFile: options.routesFile() } : {}),
135
+ ...(docker === undefined ? {} : { docker }),
136
+ ...(port80 === undefined ? {} : { port80 }),
137
+ ...(shortUrls === undefined ? {} : { shortUrls }),
131
138
  };
132
139
  }
140
+ /** Only how many containers run reaches the doctor, never their names. */
141
+ async function dockerFacts(read) {
142
+ const reading = await read().catch(() => ({ state: "unavailable" }));
143
+ return reading.state === "ok"
144
+ ? { state: "ok", containers: reading.containers.length }
145
+ : { state: reading.state };
146
+ }
147
+ /**
148
+ * Tries to listen on a loopback port and closes it at once, without sudo: `denied` when the system
149
+ * refuses this user (Linux below `ip_unprivileged_port_start`), `in-use` when a program holds it.
150
+ */
151
+ export function testBind(port) {
152
+ return new Promise((resolve) => {
153
+ const server = createServer();
154
+ server.once("error", (error) => resolve(error.code === "EADDRINUSE" ? "in-use" : "denied"));
155
+ server.listen({ port, host: "127.0.0.1", exclusive: true }, () => server.close(() => resolve("free")));
156
+ });
157
+ }
158
+ /**
159
+ * Whether a request to this loopback port reaches a portless proxy (it sends `X-Portless`), as it
160
+ * does through Studio's short-URL forwarder.
161
+ */
162
+ export function reachesPortless(port, timeoutMs = 1000) {
163
+ return new Promise((resolve) => {
164
+ const req = request({ host: "127.0.0.1", port, method: "HEAD", path: "/", timeout: timeoutMs, agent: false }, (res) => {
165
+ res.resume();
166
+ req.destroy();
167
+ resolve(res.headers["x-portless"] !== undefined);
168
+ });
169
+ req.on("timeout", () => req.destroy());
170
+ req.on("error", () => resolve(false));
171
+ req.on("close", () => resolve(false));
172
+ req.end();
173
+ });
174
+ }
133
175
  /** Paths are built for the platform the facts describe, not the one running the code. */
134
176
  function pathsOf(platform) {
135
177
  return platform === "win32" ? path.win32 : path.posix;
@@ -0,0 +1,78 @@
1
+ import { createServer, connect as tcpConnect } from "node:net";
2
+ import { err, ok } from "../../../core/result.js";
3
+ /** Loopback on IPv4 and IPv6, because browsers may resolve `name.localhost` to either. */
4
+ export const LOOPBACK_HOSTS = ["127.0.0.1", "::1"];
5
+ /** A system without IPv6 cannot bind `::1`; that is not a failure when IPv4 works. */
6
+ const NO_ADDRESS = new Set(["EADDRNOTAVAIL", "EAFNOSUPPORT"]);
7
+ /**
8
+ * Listens on loopback and pipes every TCP connection to the target, byte for byte. Piping the
9
+ * connection rather than re-sending requests keeps the Host header, the method, streamed bodies,
10
+ * keep-alive and WebSocket upgrades exactly as the browser sent them; portless then adds its own
11
+ * `X-Forwarded-*` headers from that Host. Never asks for more rights: a refused bind is reported.
12
+ */
13
+ export async function startForwarder(options) {
14
+ const hosts = options.hosts ?? LOOPBACK_HOSTS;
15
+ const targetHost = options.targetHost ?? "127.0.0.1";
16
+ const sockets = new Set();
17
+ const servers = [];
18
+ let port = options.port;
19
+ const handle = (client) => {
20
+ const upstream = tcpConnect({ host: targetHost, port: options.targetPort });
21
+ sockets.add(client);
22
+ sockets.add(upstream);
23
+ client.setNoDelay(true);
24
+ upstream.setNoDelay(true);
25
+ const finish = () => {
26
+ client.destroy();
27
+ upstream.destroy();
28
+ sockets.delete(client);
29
+ sockets.delete(upstream);
30
+ };
31
+ client.on("error", finish);
32
+ upstream.on("error", finish);
33
+ client.on("close", finish);
34
+ upstream.on("close", finish);
35
+ client.pipe(upstream);
36
+ upstream.pipe(client);
37
+ };
38
+ const close = async () => {
39
+ for (const socket of sockets)
40
+ socket.destroy();
41
+ sockets.clear();
42
+ await Promise.all(servers.map((server) => new Promise((resolve) => server.close(() => resolve()))));
43
+ };
44
+ for (const [index, host] of hosts.entries()) {
45
+ const server = createServer(handle);
46
+ const bound = await new Promise((resolve) => {
47
+ server.once("error", resolve);
48
+ server.listen({ port, host, exclusive: true }, () => {
49
+ server.off("error", resolve);
50
+ resolve(null);
51
+ });
52
+ });
53
+ if (bound === null) {
54
+ servers.push(server);
55
+ // Port 0 picks a free port on the first host; the others use that same one.
56
+ if (index === 0)
57
+ port = server.address().port;
58
+ continue;
59
+ }
60
+ if (index > 0 && NO_ADDRESS.has(bound.code ?? ""))
61
+ continue;
62
+ await close();
63
+ return err(failureOf(bound, port));
64
+ }
65
+ return ok({ port, close });
66
+ }
67
+ function failureOf(error, port) {
68
+ if (error.code === "EADDRINUSE") {
69
+ return { reason: "in-use", message: `port ${port} is in use by another program.` };
70
+ }
71
+ if (error.code === "EACCES" || error.code === "EPERM") {
72
+ return {
73
+ reason: "denied",
74
+ message: `this system does not let this user listen on port ${port} without sudo.`,
75
+ };
76
+ }
77
+ return { reason: "failed", message: `could not listen on port ${port}: ${error.message}` };
78
+ }
@@ -64,7 +64,8 @@ export function resolveWindowsCommand(bin, lookup) {
64
64
  return null;
65
65
  }
66
66
  const found = new Map();
67
- function nodeWindowsLookup() {
67
+ /** PATH, PATHEXT and file checks of this machine, for `resolveWindowsCommand`. */
68
+ export function nodeWindowsLookup() {
68
69
  const pathValue = process.env.PATH ?? process.env.Path ?? "";
69
70
  return {
70
71
  pathDirs: pathValue
@@ -0,0 +1,71 @@
1
+ import * as fs from "node:fs";
2
+ import { runProcess } from "../system/process-runner.js";
3
+ /** Deeper than any real chain of shells and tools; a guard against a bad read. */
4
+ const MAX_DEPTH = 64;
5
+ /** One `ps` answer serves every pid of a scan on macOS. */
6
+ const PS_CACHE_MS = 2_000;
7
+ /** The parent pid in the text of `/proc/<pid>/stat`; the name in parentheses may hold anything. */
8
+ export function parseStatParent(stat) {
9
+ const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
10
+ const parent = Number(fields[1]);
11
+ return stat.includes(")") && Number.isInteger(parent) ? parent : null;
12
+ }
13
+ /** Every pid's parent from `ps -A -o pid=,ppid=`. */
14
+ export function parsePsParents(output) {
15
+ const parents = new Map();
16
+ for (const line of output.split("\n")) {
17
+ const match = /^\s*(\d+)\s+(\d+)\s*$/.exec(line);
18
+ if (match)
19
+ parents.set(Number(match[1]), Number(match[2]));
20
+ }
21
+ return parents;
22
+ }
23
+ /** The parent, its parent and so on, ending before pid 0 or at a pid already seen. */
24
+ export function walkAncestors(pid, parentOf) {
25
+ const ancestors = [];
26
+ const seen = new Set([pid]);
27
+ let current = parentOf(pid);
28
+ while (current !== null && current > 0 && !seen.has(current) && ancestors.length < MAX_DEPTH) {
29
+ ancestors.push(current);
30
+ seen.add(current);
31
+ current = parentOf(current);
32
+ }
33
+ return ancestors;
34
+ }
35
+ /**
36
+ * Parent processes on this machine: `/proc` on Linux, one `ps` on macOS. Windows answers nothing,
37
+ * so terminals there list no ports.
38
+ */
39
+ export function systemProcessTree(platform = process.platform) {
40
+ if (platform === "linux") {
41
+ return {
42
+ ancestors: async (pid) => walkAncestors(pid, (current) => {
43
+ try {
44
+ return parseStatParent(fs.readFileSync(`/proc/${current}/stat`, "utf8"));
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }),
50
+ };
51
+ }
52
+ if (platform === "darwin") {
53
+ let cached = null;
54
+ const table = () => {
55
+ if (!cached || Date.now() - cached.at > PS_CACHE_MS) {
56
+ cached = {
57
+ at: Date.now(),
58
+ parents: runProcess("ps", ["-A", "-o", "pid=,ppid="], { timeoutMs: 3_000 }).then((outcome) => parsePsParents(outcome.exitCode === 0 ? outcome.stdout : "")),
59
+ };
60
+ }
61
+ return cached.parents;
62
+ };
63
+ return {
64
+ ancestors: async (pid) => {
65
+ const parents = await table();
66
+ return walkAncestors(pid, (current) => parents.get(current) ?? null);
67
+ },
68
+ };
69
+ }
70
+ return { ancestors: async () => [] };
71
+ }
@@ -1,7 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
- import { runProcess, stopProcessTree } from "../system/process-runner.js";
4
+ import { nodeWindowsLookup, resolveWindowsCommand, runProcess, stopProcessTree, } from "../system/process-runner.js";
5
5
  /** Output still in the pipes when a process exits arrives right after; a child holding them does not. */
6
6
  const DRAIN_AFTER_EXIT_MS = 500;
7
7
  /**
@@ -15,6 +15,16 @@ export function planTerminal(request, context) {
15
15
  const env = { ...request.env };
16
16
  if (context.platform === "win32") {
17
17
  env.FORCE_COLOR = "1";
18
+ if (request.command === null && context.windowsShell) {
19
+ // With pipes PowerShell shows no prompt, but reads and runs each line it is given.
20
+ return {
21
+ file: context.windowsShell,
22
+ args: ["-NoLogo", "-Command", "-"],
23
+ env,
24
+ detached: false,
25
+ windowsVerbatimArguments: false,
26
+ };
27
+ }
18
28
  const args = request.command === null ? ["/d"] : ["/d", "/s", "/c", `"${request.command}"`];
19
29
  return {
20
30
  file: context.comSpec,
@@ -27,11 +37,12 @@ export function planTerminal(request, context) {
27
37
  if (context.mode === "pipes") {
28
38
  env.FORCE_COLOR = "1";
29
39
  const args = request.command === null
30
- ? ["-c", `exec ${quote(context.shell)} -i`]
40
+ ? ["-c", `exec ${quote(context.shell)} -l -i`]
31
41
  : ["-c", request.command];
32
42
  return { file: "/bin/sh", args, env, detached: true, windowsVerbatimArguments: false };
33
43
  }
34
- const run = request.command ?? `exec ${quote(context.shell)}`;
44
+ // A login shell reads the profile the person's own terminal reads (PATH, nvm and the like).
45
+ const run = request.command ?? `exec ${quote(context.shell)} -l`;
35
46
  const inner = `stty cols ${request.cols} rows ${request.rows} 2>/dev/null; ${run}`;
36
47
  // util-linux script runs `$SHELL -c`; /bin/sh keeps the command's meaning the same everywhere.
37
48
  env.SHELL = "/bin/sh";
@@ -40,6 +51,15 @@ export function planTerminal(request, context) {
40
51
  : ["-q", "-f", "-e", "-c", inner, "/dev/null"];
41
52
  return { file: "script", args, env, detached: true, windowsVerbatimArguments: false };
42
53
  }
54
+ /** The interactive shell's short name, e.g. `zsh` or `pwsh`: the default title of a shell terminal. */
55
+ export function shellName(context) {
56
+ if (context.platform === "win32") {
57
+ if (!context.windowsShell)
58
+ return "cmd";
59
+ return path.win32.basename(context.windowsShell).replace(/\.exe$/i, "");
60
+ }
61
+ return path.posix.basename(context.shell) || "shell";
62
+ }
43
63
  /**
44
64
  * Whether this machine can run pseudo-terminals through `script`: util-linux's on Linux (BusyBox
45
65
  * and others take different flags), the system one on macOS, never on Windows.
@@ -62,9 +82,15 @@ export function createTerminalSpawner(options) {
62
82
  mode,
63
83
  shell: baseEnv.SHELL || "/bin/sh",
64
84
  comSpec: baseEnv.ComSpec ?? "cmd.exe",
85
+ windowsShell: platform === "win32"
86
+ ? options.windowsShell === undefined
87
+ ? findWindowsShell()
88
+ : options.windowsShell
89
+ : null,
65
90
  };
66
91
  return {
67
92
  mode,
93
+ shellName: shellName(context),
68
94
  spawn: async (request) => {
69
95
  const plan = planTerminal(request, context);
70
96
  const child = spawn(plan.file, plan.args, {
@@ -171,6 +197,21 @@ export function nodeTerminalFolders(platform = process.platform) {
171
197
  },
172
198
  };
173
199
  }
200
+ /** The short name of the shell a terminal with no command starts on this machine. */
201
+ export function interactiveShellName(platform = process.platform, env = process.env) {
202
+ return shellName({
203
+ platform,
204
+ mode: "pipes",
205
+ shell: env.SHELL || "/bin/sh",
206
+ comSpec: env.ComSpec ?? "cmd.exe",
207
+ windowsShell: platform === "win32" ? findWindowsShell() : null,
208
+ });
209
+ }
210
+ /** `pwsh` when installed, else Windows PowerShell, else null for cmd.exe. */
211
+ function findWindowsShell() {
212
+ const lookup = nodeWindowsLookup();
213
+ return resolveWindowsCommand("pwsh", lookup) ?? resolveWindowsCommand("powershell", lookup);
214
+ }
174
215
  /** Single quotes for sh, with any single quote inside closed, escaped and reopened. */
175
216
  function quote(value) {
176
217
  return `'${value.replaceAll("'", `'\\''`)}'`;