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.
- package/README.md +60 -218
- package/dist/cli/mcp/studio-tools.js +20 -7
- package/dist/cli/studio-items.js +5 -2
- package/dist/core/api/contract.js +17 -1
- package/dist/core/config/studio-config.js +4 -0
- package/dist/core/doctor/platform-commands.js +5 -2
- package/dist/core/ports/direct-url.js +7 -0
- package/dist/core/ports/docker-container.js +85 -0
- package/dist/core/routes/route.js +14 -2
- package/dist/server/application/container-source.js +19 -0
- package/dist/server/application/doctor.js +199 -9
- package/dist/server/application/port-discovery.js +49 -6
- package/dist/server/application/short-urls.js +136 -0
- package/dist/server/application/studio-runtime.js +46 -7
- package/dist/server/application/terminals.js +142 -15
- package/dist/server/infrastructure/system/docker-containers.js +64 -0
- package/dist/server/infrastructure/system/doctor-facts.js +43 -1
- package/dist/server/infrastructure/system/port-forwarder.js +78 -0
- package/dist/server/infrastructure/system/process-runner.js +2 -1
- package/dist/server/infrastructure/terminal/process-tree.js +71 -0
- package/dist/server/infrastructure/terminal/terminal-spawner.js +44 -3
- package/dist/server/interface/http/http-server.js +38 -2
- package/dist/server/main.js +41 -6
- package/dist/web/assets/index-BDEwEBPS.js +15 -0
- package/dist/web/assets/index-C9wkL1J8.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-B3KMqQ-l.js +0 -15
- 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
|
-
|
|
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
|
|
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:
|
|
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.#
|
|
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 =
|
|
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.#
|
|
449
|
+
this.#updateWorkspaceWatch();
|
|
334
450
|
}
|
|
335
451
|
#waitingEntries() {
|
|
336
452
|
return [...this.#entries.values()].filter((entry) => entry.info.naming?.state === "waiting");
|
|
337
453
|
}
|
|
338
|
-
/**
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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 (!
|
|
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.#
|
|
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
|
-
|
|
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
|
-
|
|
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("'", `'\\''`)}'`;
|