portless-studio 0.1.1 → 0.1.3
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/cli-args.js +41 -12
- package/dist/cli/env-command.js +34 -0
- package/dist/cli/index.js +19 -0
- package/dist/cli/mcp/studio-tools.js +70 -7
- package/dist/cli/studio-items.js +5 -2
- package/dist/core/api/contract.js +17 -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/ports/helper-ports.js +86 -0
- package/dist/core/routes/env-lines.js +72 -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 +84 -9
- package/dist/server/application/short-urls.js +136 -0
- package/dist/server/application/studio-runtime.js +82 -24
- 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/ephemeral-ports.js +62 -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/system/system-port-scanner.js +64 -4
- 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 +43 -6
- package/dist/web/assets/index-9WPxv6pu.js +17 -0
- package/dist/web/assets/index-Bckc4cIr.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
|
@@ -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,7 +100,8 @@ 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
|
}
|
|
105
107
|
if (pathname === API_PATHS.update && method === "GET") {
|
|
@@ -176,6 +178,16 @@ export function createHttpServer(options) {
|
|
|
176
178
|
res.writeHead(204, { "Cache-Control": "no-store" }).end();
|
|
177
179
|
return;
|
|
178
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
|
+
}
|
|
179
191
|
if (pathname.startsWith(`${API_PATHS.openWith}/`) || pathname === API_PATHS.openWith) {
|
|
180
192
|
return handleOpenWith(req, res, pathname, method);
|
|
181
193
|
}
|
|
@@ -235,6 +247,18 @@ export function createHttpServer(options) {
|
|
|
235
247
|
terminalStreams.list(res);
|
|
236
248
|
return;
|
|
237
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
|
+
}
|
|
238
262
|
const match = TERMINAL.exec(pathname);
|
|
239
263
|
const id = match?.[1] ? decodeName(match[1]) : null;
|
|
240
264
|
if (id === null)
|
|
@@ -246,6 +270,18 @@ export function createHttpServer(options) {
|
|
|
246
270
|
return sendError(res, "not_found", `No terminal has the id "${id}".`);
|
|
247
271
|
return sendJson(res, 200, info);
|
|
248
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
|
+
}
|
|
249
285
|
if (action === undefined && method === "DELETE") {
|
|
250
286
|
return sendResult(res, terminals.remove(id), 204);
|
|
251
287
|
}
|
package/dist/server/main.js
CHANGED
|
@@ -2,11 +2,13 @@ import { randomBytes } from "node:crypto";
|
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import * as os from "node:os";
|
|
4
4
|
import * as path from "node:path";
|
|
5
|
+
import { stopContainer } from "./application/container-source.js";
|
|
5
6
|
import { createDoctorService } from "./application/doctor.js";
|
|
6
7
|
import { OpenWith } from "./application/open-with.js";
|
|
7
8
|
import { controlProxy } from "./application/proxy-control.js";
|
|
8
9
|
import { confirmProxyStarts } from "./application/proxy-start-check.js";
|
|
9
10
|
import { createAlias, renameAlias } from "./application/route-use-cases.js";
|
|
11
|
+
import { publicUrls, SHORT_URL_PORT, ShortUrls } from "./application/short-urls.js";
|
|
10
12
|
import { stopProcess } from "./application/stop-process.js";
|
|
11
13
|
import { StudioRuntime } from "./application/studio-runtime.js";
|
|
12
14
|
import { TerminalManager } from "./application/terminals.js";
|
|
@@ -20,19 +22,29 @@ import { JsonUpdateCache, updateCheckPathFor } from "./infrastructure/config/jso
|
|
|
20
22
|
import { proxyStartRecordPathFor, readProxyStartRecord, recordProxyStarts, } from "./infrastructure/config/proxy-start-record.js";
|
|
21
23
|
import { PortlessRegistry } from "./infrastructure/portless/portless-registry.js";
|
|
22
24
|
import { inspectRoutesFile, resolveStateDir } from "./infrastructure/portless/state-files.js";
|
|
23
|
-
import {
|
|
25
|
+
import { DockerContainers } from "./infrastructure/system/docker-containers.js";
|
|
26
|
+
import { collectDoctorFacts, proxyPidsAmong, reachesPortless, testBind, } from "./infrastructure/system/doctor-facts.js";
|
|
27
|
+
import { ephemeralRangeReader } from "./infrastructure/system/ephemeral-ports.js";
|
|
24
28
|
import { FaviconFetcher } from "./infrastructure/system/favicon-fetcher.js";
|
|
25
29
|
import { fetchLatestVersion } from "./infrastructure/system/npm-registry.js";
|
|
26
30
|
import { OsNotifier } from "./infrastructure/system/os-notifier.js";
|
|
31
|
+
import { startForwarder } from "./infrastructure/system/port-forwarder.js";
|
|
27
32
|
import { ProjectInfoReader } from "./infrastructure/system/project-info.js";
|
|
28
33
|
import { nodeSystemIo } from "./infrastructure/system/system-io.js";
|
|
29
34
|
import { createPortScanner, listPortsFor, probeHttpResponse, } from "./infrastructure/system/system-port-scanner.js";
|
|
30
35
|
import { SystemProcessControl } from "./infrastructure/system/system-process-control.js";
|
|
31
|
-
import {
|
|
36
|
+
import { systemProcessTree } from "./infrastructure/terminal/process-tree.js";
|
|
37
|
+
import { createTerminalSpawner, detectTerminalMode, interactiveShellName, nodeTerminalFolders, } from "./infrastructure/terminal/terminal-spawner.js";
|
|
32
38
|
import { createHttpServer } from "./interface/http/http-server.js";
|
|
33
39
|
export { defaultConfigPath } from "./infrastructure/config/json-config-store.js";
|
|
34
40
|
/** Only this machine can connect; see docs/architecture.md, Security. */
|
|
35
41
|
const LOOPBACK = "127.0.0.1";
|
|
42
|
+
/** `PORTLESS_STUDIO_SHORT_URL_PORT`, when it is a valid port; port 80 otherwise. */
|
|
43
|
+
export function shortUrlPortFrom(env) {
|
|
44
|
+
const raw = env.PORTLESS_STUDIO_SHORT_URL_PORT;
|
|
45
|
+
const port = raw === undefined ? Number.NaN : Number(raw);
|
|
46
|
+
return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : SHORT_URL_PORT;
|
|
47
|
+
}
|
|
36
48
|
// Lets startServer reuse the runtime behind services built here, so both share one scan cache.
|
|
37
49
|
const runtimes = new WeakMap();
|
|
38
50
|
export function createStudioServices(options = {}) {
|
|
@@ -45,11 +57,18 @@ export function createStudioServices(options = {}) {
|
|
|
45
57
|
});
|
|
46
58
|
const scanner = createPortScanner();
|
|
47
59
|
const proxyPids = (pids) => proxyPidsAmong(process.platform, nodeSystemIo, pids);
|
|
60
|
+
const shortUrls = new ShortUrls({
|
|
61
|
+
forwarding: { listen: (port, target) => startForwarder({ port, targetPort: target }) },
|
|
62
|
+
port: options.shortUrlPort ?? shortUrlPortFrom(process.env),
|
|
63
|
+
log: (line) => console.log(line),
|
|
64
|
+
reachesProxy: (port) => reachesPortless(port),
|
|
65
|
+
});
|
|
48
66
|
// A start counts only once the proxy listens, and every proxy Studio starts is written down,
|
|
49
|
-
// so `uninstall` stops only Studio's own.
|
|
50
|
-
const registry = recordProxyStarts(confirmProxyStarts(portless, { scan: () => scanner.scan(), proxyPids }), proxyStartRecordPathFor(configPath));
|
|
67
|
+
// so `uninstall` stops only Studio's own. Route URLs drop the port while Studio forwards it.
|
|
68
|
+
const registry = publicUrls(recordProxyStarts(confirmProxyStarts(portless, { scan: () => scanner.scan(), proxyPids }), proxyStartRecordPathFor(configPath)), () => shortUrls.publicPort());
|
|
51
69
|
const configStore = new JsonConfigStore(configPath);
|
|
52
70
|
const notifier = new OsNotifier({ platform: process.platform });
|
|
71
|
+
const containers = new DockerContainers();
|
|
53
72
|
const runtime = new StudioRuntime({
|
|
54
73
|
registry,
|
|
55
74
|
scanner,
|
|
@@ -62,6 +81,9 @@ export function createStudioServices(options = {}) {
|
|
|
62
81
|
notifier,
|
|
63
82
|
icons: new FaviconFetcher(),
|
|
64
83
|
routeProjectStore: new JsonRouteProjectStore(routeProjectsPathFor(configPath)),
|
|
84
|
+
containers,
|
|
85
|
+
shortUrls,
|
|
86
|
+
ephemeralRange: ephemeralRangeReader(process.platform, nodeSystemIo),
|
|
65
87
|
});
|
|
66
88
|
const openWith = new OpenWith({
|
|
67
89
|
catalog: systemAppCatalog(),
|
|
@@ -72,6 +94,8 @@ export function createStudioServices(options = {}) {
|
|
|
72
94
|
const terminals = new TerminalManager({
|
|
73
95
|
spawner: lazySpawner(),
|
|
74
96
|
folders: nodeTerminalFolders(),
|
|
97
|
+
processTree: systemProcessTree(),
|
|
98
|
+
homeDir: os.homedir(),
|
|
75
99
|
workspace: {
|
|
76
100
|
openableFolders: () => runtime.openableFolders(),
|
|
77
101
|
snapshot: () => runtime.readSnapshot(),
|
|
@@ -104,9 +128,13 @@ export function createStudioServices(options = {}) {
|
|
|
104
128
|
...(studioService ? { loginService: () => studioService.status() } : {}),
|
|
105
129
|
systemNotifier: () => notifier.availability(),
|
|
106
130
|
routesFile: () => inspectRoutesFile(stateDir),
|
|
131
|
+
docker: () => containers.read(),
|
|
132
|
+
lowPort: testBind,
|
|
133
|
+
shortUrls: (config) => shortUrls.inspect(config),
|
|
107
134
|
}),
|
|
108
135
|
controlProxy: proxy,
|
|
109
136
|
updateConfig: (config) => runtime.updateConfig(config),
|
|
137
|
+
syncShortUrls: (config) => shortUrls.sync(config),
|
|
110
138
|
});
|
|
111
139
|
const version = options.version;
|
|
112
140
|
const updates = version === undefined
|
|
@@ -129,11 +157,13 @@ export function createStudioServices(options = {}) {
|
|
|
129
157
|
stoppablePids: () => runtime.stoppablePids(),
|
|
130
158
|
selfPid: process.pid,
|
|
131
159
|
}),
|
|
160
|
+
stopContainer: (name) => stopContainer(name, containers),
|
|
132
161
|
studioService,
|
|
133
162
|
openWith,
|
|
134
163
|
terminals,
|
|
135
164
|
openUrl: options.openUrl,
|
|
136
165
|
updates,
|
|
166
|
+
shortUrls,
|
|
137
167
|
};
|
|
138
168
|
runtimes.set(services, runtime);
|
|
139
169
|
return services;
|
|
@@ -178,6 +208,7 @@ export async function startServer(options) {
|
|
|
178
208
|
scanner: services.scanner,
|
|
179
209
|
configStore: services.configStore,
|
|
180
210
|
projects: new ProjectInfoReader(),
|
|
211
|
+
...(services.shortUrls ? { shortUrls: services.shortUrls } : {}),
|
|
181
212
|
});
|
|
182
213
|
const token = randomBytes(32).toString("base64url");
|
|
183
214
|
const http = createHttpServer({
|
|
@@ -188,6 +219,7 @@ export async function startServer(options) {
|
|
|
188
219
|
doctor: services.doctor,
|
|
189
220
|
controlProxy: services.controlProxy,
|
|
190
221
|
stopProcess: services.stopProcess,
|
|
222
|
+
stopContainer: services.stopContainer,
|
|
191
223
|
...(services.studioService ? { studioService: services.studioService } : {}),
|
|
192
224
|
...(services.openWith ? { openWith: services.openWith } : {}),
|
|
193
225
|
...(services.terminals ? { terminals: services.terminals } : {}),
|
|
@@ -204,6 +236,8 @@ export async function startServer(options) {
|
|
|
204
236
|
const { port } = http.server.address();
|
|
205
237
|
studio.setStudioPort(port);
|
|
206
238
|
studio.start();
|
|
239
|
+
// The short-URL forwarder settles before anyone prints or registers a URL.
|
|
240
|
+
await studio.ready();
|
|
207
241
|
return {
|
|
208
242
|
url: `http://${LOOPBACK}:${port}`,
|
|
209
243
|
port,
|
|
@@ -214,8 +248,10 @@ export async function startServer(options) {
|
|
|
214
248
|
studio.stop();
|
|
215
249
|
services.terminals?.closeAll();
|
|
216
250
|
http.closeStreams();
|
|
217
|
-
|
|
218
|
-
|
|
251
|
+
void (services.shortUrls?.close() ?? Promise.resolve()).finally(() => {
|
|
252
|
+
http.server.close(() => resolve());
|
|
253
|
+
http.server.closeAllConnections();
|
|
254
|
+
});
|
|
219
255
|
}),
|
|
220
256
|
};
|
|
221
257
|
}
|
|
@@ -230,6 +266,7 @@ function lazySpawner() {
|
|
|
230
266
|
get mode() {
|
|
231
267
|
return mode;
|
|
232
268
|
},
|
|
269
|
+
shellName: interactiveShellName(),
|
|
233
270
|
spawn: async (request) => {
|
|
234
271
|
real ??= detectTerminalMode(process.platform).then((detected) => createTerminalSpawner({ mode: detected }));
|
|
235
272
|
const spawner = await real;
|