portless-studio 0.1.0 → 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 +78 -192
- package/dist/cli/cli-args.js +29 -3
- package/dist/cli/index.js +10 -15
- package/dist/cli/login-service.js +16 -0
- package/dist/cli/mcp/studio-tools.js +20 -7
- package/dist/cli/studio-items.js +5 -2
- package/dist/core/api/contract.js +19 -1
- package/dist/core/config/studio-config.js +6 -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/core/updates/version.js +62 -0
- package/dist/server/application/auto-namer.js +14 -0
- package/dist/server/application/container-source.js +19 -0
- package/dist/server/application/doctor.js +225 -9
- package/dist/server/application/port-discovery.js +49 -6
- package/dist/server/application/proxy-start-check.js +83 -0
- 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/application/update-check.js +66 -0
- package/dist/server/infrastructure/config/json-update-cache.js +35 -0
- package/dist/server/infrastructure/portless/portless-registry.js +31 -4
- package/dist/server/infrastructure/portless/state-files.js +23 -7
- package/dist/server/infrastructure/system/docker-containers.js +64 -0
- package/dist/server/infrastructure/system/doctor-facts.js +44 -1
- package/dist/server/infrastructure/system/npm-registry.js +20 -0
- 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 +43 -2
- package/dist/server/interface/http/json-io.js +1 -0
- package/dist/server/main.js +62 -8
- 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 +2 -2
- package/dist/web/assets/index-CA7HpYbk.css +0 -1
- package/dist/web/assets/index-CC6BquZf.js +0 -15
|
@@ -2,7 +2,10 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import { err, ok } from "../../../core/result.js";
|
|
3
3
|
import { appError } from "../../application/app-error.js";
|
|
4
4
|
import { addAlias, detectPortless, installPortless, removeAlias, startProxy, stopProxy, } from "./portless-cli.js";
|
|
5
|
-
import { readProxyPid, readProxyStatus,
|
|
5
|
+
import { readProxyPid, readProxyStatus, readRouteEntries, routesFromEntries, } from "./state-files.js";
|
|
6
|
+
/** How long a write waits for a half-written `routes.json` to be whole again. */
|
|
7
|
+
const TORN_RETRIES = 10;
|
|
8
|
+
const TORN_RETRY_MS = 50;
|
|
6
9
|
/** Reads portless state files directly and writes through the portless CLI, which owns the lock. */
|
|
7
10
|
export class PortlessRegistry {
|
|
8
11
|
#stateDir;
|
|
@@ -16,6 +19,8 @@ export class PortlessRegistry {
|
|
|
16
19
|
// Bumped by an install, so a detection that started before it cannot cache a stale answer.
|
|
17
20
|
#detectGeneration = 0;
|
|
18
21
|
#installing = false;
|
|
22
|
+
/** The entries of the last `routes.json` that parsed, shown while portless rewrites it. */
|
|
23
|
+
#lastEntries = [];
|
|
19
24
|
/** One per watcher: compares the installation with what that watcher last saw. */
|
|
20
25
|
#installationChecks = new Set();
|
|
21
26
|
constructor(options) {
|
|
@@ -29,7 +34,7 @@ export class PortlessRegistry {
|
|
|
29
34
|
async readSnapshot() {
|
|
30
35
|
const portless = await this.#detect();
|
|
31
36
|
const proxy = readProxyStatus(this.#stateDir);
|
|
32
|
-
return { proxy, portless, routes:
|
|
37
|
+
return { proxy, portless, routes: this.#routes(proxy), readAt: Date.now() };
|
|
33
38
|
}
|
|
34
39
|
watch(onChange) {
|
|
35
40
|
let last = this.#fingerprint();
|
|
@@ -103,8 +108,9 @@ export class PortlessRegistry {
|
|
|
103
108
|
}
|
|
104
109
|
async createAlias(name, port, force) {
|
|
105
110
|
// `portless alias` silently replaces an existing alias, so the conflict check lives here.
|
|
111
|
+
await this.#waitForWholeRoutesFile();
|
|
106
112
|
const proxy = readProxyStatus(this.#stateDir);
|
|
107
|
-
const existing =
|
|
113
|
+
const existing = this.#routes(proxy).find((route) => route.name === name);
|
|
108
114
|
if (existing && !force) {
|
|
109
115
|
if (existing.port === port)
|
|
110
116
|
return ok(undefined);
|
|
@@ -149,6 +155,27 @@ export class PortlessRegistry {
|
|
|
149
155
|
/** The snapshot without its read time, so two reads of the same state compare equal. */
|
|
150
156
|
#fingerprint() {
|
|
151
157
|
const proxy = readProxyStatus(this.#stateDir);
|
|
152
|
-
return JSON.stringify({ proxy, routes:
|
|
158
|
+
return JSON.stringify({ proxy, routes: this.#routes(proxy) });
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Routes from `routes.json`. portless rewrites the file in place, so a read can land on an empty
|
|
162
|
+
* or cut-off file; that read keeps the last entries that parsed, and the next file event or poll
|
|
163
|
+
* reads it again. A missing file, or one in a format Studio does not know, means no routes.
|
|
164
|
+
*/
|
|
165
|
+
#routes(proxy) {
|
|
166
|
+
const read = readRouteEntries(this.#stateDir);
|
|
167
|
+
if (read.kind === "ok")
|
|
168
|
+
this.#lastEntries = read.entries;
|
|
169
|
+
else if (read.kind !== "torn")
|
|
170
|
+
this.#lastEntries = [];
|
|
171
|
+
return routesFromEntries(this.#lastEntries, proxy);
|
|
172
|
+
}
|
|
173
|
+
/** Gives portless a moment to finish a write, so a name check never runs on a torn file. */
|
|
174
|
+
async #waitForWholeRoutesFile() {
|
|
175
|
+
for (let attempt = 0; attempt < TORN_RETRIES; attempt += 1) {
|
|
176
|
+
if (readRouteEntries(this.#stateDir).kind !== "torn")
|
|
177
|
+
return;
|
|
178
|
+
await new Promise((resolve) => setTimeout(resolve, TORN_RETRY_MS));
|
|
179
|
+
}
|
|
153
180
|
}
|
|
154
181
|
}
|
|
@@ -34,21 +34,29 @@ export function readProxyPid(dir) {
|
|
|
34
34
|
const pid = Number.parseInt(readText(dir, PID_FILE) ?? "", 10);
|
|
35
35
|
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
36
36
|
}
|
|
37
|
-
|
|
38
|
-
export function readRoutes(dir, proxy) {
|
|
37
|
+
export function readRouteEntries(dir) {
|
|
39
38
|
const raw = readText(dir, ROUTES_FILE);
|
|
40
39
|
if (raw === null)
|
|
41
|
-
return
|
|
40
|
+
return { kind: "missing" };
|
|
41
|
+
if (raw.trim() === "")
|
|
42
|
+
return { kind: "torn" };
|
|
42
43
|
let parsed;
|
|
43
44
|
try {
|
|
44
45
|
parsed = JSON.parse(raw);
|
|
45
46
|
}
|
|
46
47
|
catch {
|
|
47
|
-
return
|
|
48
|
+
return { kind: "torn" };
|
|
48
49
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
return Array.isArray(parsed) ? { kind: "ok", entries: parsed } : { kind: "unexpected" };
|
|
51
|
+
}
|
|
52
|
+
/** Live routes from `routes.json`. Invalid entries and apps whose process died are left out. */
|
|
53
|
+
export function readRoutes(dir, proxy) {
|
|
54
|
+
const read = readRouteEntries(dir);
|
|
55
|
+
return read.kind === "ok" ? routesFromEntries(read.entries, proxy) : [];
|
|
56
|
+
}
|
|
57
|
+
/** The routes in a parsed `routes.json`, without invalid entries and apps whose process died. */
|
|
58
|
+
export function routesFromEntries(entries, proxy) {
|
|
59
|
+
return entries
|
|
52
60
|
.flatMap((entry) => {
|
|
53
61
|
const route = toRoute(entry, proxy);
|
|
54
62
|
return route ? [route] : [];
|
|
@@ -56,6 +64,14 @@ export function readRoutes(dir, proxy) {
|
|
|
56
64
|
.filter((route) => route.pid === null || isProcessAlive(route.pid))
|
|
57
65
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
58
66
|
}
|
|
67
|
+
export function inspectRoutesFile(dir) {
|
|
68
|
+
const read = readRouteEntries(dir);
|
|
69
|
+
if (read.kind !== "ok")
|
|
70
|
+
return { state: read.kind, entries: 0, unreadable: 0 };
|
|
71
|
+
const proxy = { running: false, port: null, https: false, tlds: [DEFAULT_TLD] };
|
|
72
|
+
const unreadable = read.entries.filter((entry) => toRoute(entry, proxy) === null).length;
|
|
73
|
+
return { state: "ok", entries: read.entries.length, unreadable };
|
|
74
|
+
}
|
|
59
75
|
/** Signal 0 checks existence without touching the process; EPERM means it exists. */
|
|
60
76
|
export function isProcessAlive(pid) {
|
|
61
77
|
try {
|
|
@@ -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,
|
|
@@ -127,8 +131,47 @@ export async function collectDoctorFacts(options) {
|
|
|
127
131
|
studioPort: { port: config.port, holder: await portHolder(config.port, options) },
|
|
128
132
|
loginService,
|
|
129
133
|
...(systemNotifier === undefined ? {} : { systemNotifier }),
|
|
134
|
+
...(options.routesFile ? { routesFile: options.routesFile() } : {}),
|
|
135
|
+
...(docker === undefined ? {} : { docker }),
|
|
136
|
+
...(port80 === undefined ? {} : { port80 }),
|
|
137
|
+
...(shortUrls === undefined ? {} : { shortUrls }),
|
|
130
138
|
};
|
|
131
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
|
+
}
|
|
132
175
|
/** Paths are built for the platform the facts describe, not the one running the code. */
|
|
133
176
|
function pathsOf(platform) {
|
|
134
177
|
return platform === "win32" ? path.win32 : path.posix;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Public npm metadata of the latest release; Studio sends nothing but this GET. */
|
|
2
|
+
export const LATEST_RELEASE_URL = "https://registry.npmjs.org/portless-studio/latest";
|
|
3
|
+
const TIMEOUT_MS = 3000;
|
|
4
|
+
/** The `version` npm reports as latest, or null on any failure, including the 3 s timeout. */
|
|
5
|
+
export async function fetchLatestVersion(options = {}) {
|
|
6
|
+
const fetchImpl = options.fetch ?? ((url, init) => fetch(url, init));
|
|
7
|
+
try {
|
|
8
|
+
const response = await fetchImpl(options.url ?? LATEST_RELEASE_URL, {
|
|
9
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? TIMEOUT_MS),
|
|
10
|
+
});
|
|
11
|
+
if (!response.ok)
|
|
12
|
+
return null;
|
|
13
|
+
const body = await response.json();
|
|
14
|
+
const version = typeof body === "object" && body !== null ? Reflect.get(body, "version") : undefined;
|
|
15
|
+
return typeof version === "string" && /^\d+\.\d+\.\d+\S*$/.test(version) ? version : null;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -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("'", `'\\''`)}'`;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createServer } from "node:http";
|
|
2
|
-
import { API_PATHS, STUDIO_HEADER, TOKEN_HEADER, TOKEN_PARAM, } from "../../../core/api/contract.js";
|
|
2
|
+
import { API_PATHS, INCLUDE_STUDIO_PARAM, STUDIO_HEADER, TOKEN_HEADER, TOKEN_PARAM, } from "../../../core/api/contract.js";
|
|
3
3
|
import { createAlias, installPortless, removeAlias, renameAlias, } from "../../application/route-use-cases.js";
|
|
4
4
|
import { FocusBroker, focusQuery, resolveFocusTarget } from "./focus-broker.js";
|
|
5
5
|
import { MAX_BODY_BYTES, readJsonBody, sendError, sendJson } from "./json-io.js";
|
|
@@ -13,6 +13,7 @@ const DOCTOR_FIX = /^\/api\/doctor\/fix\/([^/]+)$/;
|
|
|
13
13
|
const PROXY_ACTION = /^\/api\/proxy\/(start|stop|restart)$/;
|
|
14
14
|
const STUDIO_ACTION = /^\/api\/studio\/(restart|stop)$/;
|
|
15
15
|
const STOP_PROCESS = /^\/api\/processes\/([^/]+)\/stop$/;
|
|
16
|
+
const STOP_CONTAINER = /^\/api\/containers\/([^/]+)\/stop$/;
|
|
16
17
|
const TERMINAL = /^\/api\/terminals\/([^/]+)(?:\/(output|stream|input|stop|restart))?$/;
|
|
17
18
|
/** Lets the 202 reach the browser before Studio restarts or exits. */
|
|
18
19
|
const STUDIO_ACTION_DELAY_MS = 200;
|
|
@@ -99,9 +100,15 @@ export function createHttpServer(options) {
|
|
|
99
100
|
return;
|
|
100
101
|
}
|
|
101
102
|
if (pathname === API_PATHS.snapshot && method === "GET") {
|
|
102
|
-
|
|
103
|
+
const includeStudio = query.get(INCLUDE_STUDIO_PARAM) === "1";
|
|
104
|
+
sendJson(res, 200, await options.studio.readSnapshot({ includeStudio }));
|
|
103
105
|
return;
|
|
104
106
|
}
|
|
107
|
+
if (pathname === API_PATHS.update && method === "GET") {
|
|
108
|
+
if (!options.updates)
|
|
109
|
+
return sendAppError(res, unsupported);
|
|
110
|
+
return sendJson(res, 200, await options.updates.status());
|
|
111
|
+
}
|
|
105
112
|
if (pathname === API_PATHS.config && method === "GET") {
|
|
106
113
|
const result = await options.studio.readConfig();
|
|
107
114
|
if (!result.ok)
|
|
@@ -171,6 +178,16 @@ export function createHttpServer(options) {
|
|
|
171
178
|
res.writeHead(204, { "Cache-Control": "no-store" }).end();
|
|
172
179
|
return;
|
|
173
180
|
}
|
|
181
|
+
const stopBox = STOP_CONTAINER.exec(pathname);
|
|
182
|
+
if (stopBox?.[1] && method === "POST") {
|
|
183
|
+
if (!options.stopContainer)
|
|
184
|
+
return sendAppError(res, unsupported);
|
|
185
|
+
const result = await options.stopContainer(decodeName(stopBox[1]) ?? "");
|
|
186
|
+
if (!result.ok)
|
|
187
|
+
return sendAppError(res, result.error);
|
|
188
|
+
res.writeHead(204, { "Cache-Control": "no-store" }).end();
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
174
191
|
if (pathname.startsWith(`${API_PATHS.openWith}/`) || pathname === API_PATHS.openWith) {
|
|
175
192
|
return handleOpenWith(req, res, pathname, method);
|
|
176
193
|
}
|
|
@@ -230,6 +247,18 @@ export function createHttpServer(options) {
|
|
|
230
247
|
terminalStreams.list(res);
|
|
231
248
|
return;
|
|
232
249
|
}
|
|
250
|
+
if (pathname === API_PATHS.terminalsInFolder && method === "POST") {
|
|
251
|
+
const body = await readJsonBody(req);
|
|
252
|
+
if (!body.ok)
|
|
253
|
+
return sendBodyError(res, body.error);
|
|
254
|
+
const created = await terminals.create(toTerminalRequest(body.value), {
|
|
255
|
+
trusted: isLocalProgram(req),
|
|
256
|
+
picked: true,
|
|
257
|
+
});
|
|
258
|
+
if (!created.ok)
|
|
259
|
+
return sendAppError(res, created.error);
|
|
260
|
+
return sendJson(res, 201, created.value);
|
|
261
|
+
}
|
|
233
262
|
const match = TERMINAL.exec(pathname);
|
|
234
263
|
const id = match?.[1] ? decodeName(match[1]) : null;
|
|
235
264
|
if (id === null)
|
|
@@ -241,6 +270,18 @@ export function createHttpServer(options) {
|
|
|
241
270
|
return sendError(res, "not_found", `No terminal has the id "${id}".`);
|
|
242
271
|
return sendJson(res, 200, info);
|
|
243
272
|
}
|
|
273
|
+
if (action === undefined && method === "PATCH") {
|
|
274
|
+
const body = await readJsonBody(req);
|
|
275
|
+
if (!body.ok)
|
|
276
|
+
return sendBodyError(res, body.error);
|
|
277
|
+
const title = asRecord(body.value).title;
|
|
278
|
+
if (typeof title !== "string")
|
|
279
|
+
return sendError(res, "invalid_request", "title must be text.");
|
|
280
|
+
const renamed = terminals.rename(id, title);
|
|
281
|
+
if (!renamed.ok)
|
|
282
|
+
return sendAppError(res, renamed.error);
|
|
283
|
+
return sendJson(res, 200, renamed.value);
|
|
284
|
+
}
|
|
244
285
|
if (action === undefined && method === "DELETE") {
|
|
245
286
|
return sendResult(res, terminals.remove(id), 204);
|
|
246
287
|
}
|