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
|
@@ -29,6 +29,8 @@ export const API_PATHS = {
|
|
|
29
29
|
studio: (action) => `/api/studio/${action}`,
|
|
30
30
|
/** POST: stops a dev server listening on a detected or routed port. */
|
|
31
31
|
stopProcess: (pid) => `/api/processes/${pid}/stop`,
|
|
32
|
+
/** POST: runs `docker stop` for a container the last `docker ps` listed. */
|
|
33
|
+
stopContainer: (name) => `/api/containers/${encodeURIComponent(name)}/stop`,
|
|
32
34
|
/** GET: the editors found on this machine, the file manager and the custom apps. */
|
|
33
35
|
openWithApps: "/api/open-with/apps",
|
|
34
36
|
/** POST `OpenWithRequest`: opens a folder Studio knows with one of those apps. */
|
|
@@ -43,7 +45,16 @@ export const API_PATHS = {
|
|
|
43
45
|
terminals: "/api/terminals",
|
|
44
46
|
/** Server-sent events: `terminals` with the whole list on connect and after every change. */
|
|
45
47
|
terminalsEvents: "/api/terminals/events",
|
|
46
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* POST `CreateTerminalRequest` from the page's folder picker: `cwd` may be any existing folder,
|
|
50
|
+
* typed by the person (`~` is the home folder). Every other page request runs only in folders
|
|
51
|
+
* Studio shows.
|
|
52
|
+
*/
|
|
53
|
+
terminalsInFolder: "/api/terminals/in-folder",
|
|
54
|
+
/**
|
|
55
|
+
* GET returns `TerminalInfo`; PATCH `RenameTerminalRequest` renames it; DELETE stops the command
|
|
56
|
+
* if it runs and closes the terminal.
|
|
57
|
+
*/
|
|
47
58
|
terminal: (id) => `/api/terminals/${encodeURIComponent(id)}`,
|
|
48
59
|
/** GET `?from=<seq>&max=<chars>` returns `TerminalOutputResponse`. */
|
|
49
60
|
terminalOutput: (id) => `/api/terminals/${encodeURIComponent(id)}/output`,
|
|
@@ -70,6 +81,11 @@ export const API_PATHS = {
|
|
|
70
81
|
* no custom headers.
|
|
71
82
|
*/
|
|
72
83
|
export const TOKEN_PARAM = "token";
|
|
84
|
+
/**
|
|
85
|
+
* `GET /api/snapshot?includeStudio=1` keeps Studio's own route in `routes`, which the snapshot
|
|
86
|
+
* leaves out unless `showStudioRoute` is on.
|
|
87
|
+
*/
|
|
88
|
+
export const INCLUDE_STUDIO_PARAM = "includeStudio";
|
|
73
89
|
export const SNAPSHOT_EVENT = "snapshot";
|
|
74
90
|
/** Server-sent event type that carries one `StudioEvent`. */
|
|
75
91
|
export const STUDIO_EVENT = "event";
|
|
@@ -36,6 +36,9 @@ export const DEFAULT_CONFIG = {
|
|
|
36
36
|
projectIcons: {},
|
|
37
37
|
nameTemplate: DEFAULT_NAME_TEMPLATE,
|
|
38
38
|
checkForUpdates: true,
|
|
39
|
+
shortUrls: false,
|
|
40
|
+
showStudioRoute: false,
|
|
41
|
+
showEphemeralPorts: false,
|
|
39
42
|
};
|
|
40
43
|
const MIN_SCAN_INTERVAL_MS = 1000;
|
|
41
44
|
/**
|
|
@@ -57,6 +60,9 @@ export function parseConfig(input) {
|
|
|
57
60
|
"desktopNotifications",
|
|
58
61
|
"systemNotifications",
|
|
59
62
|
"checkForUpdates",
|
|
63
|
+
"shortUrls",
|
|
64
|
+
"showStudioRoute",
|
|
65
|
+
"showEphemeralPorts",
|
|
60
66
|
]) {
|
|
61
67
|
const value = raw[key];
|
|
62
68
|
if (value === undefined)
|
|
@@ -27,9 +27,12 @@ export function killCommand(platform, pids, admin) {
|
|
|
27
27
|
export function adminWords(platform) {
|
|
28
28
|
return platform === "win32" ? "from a terminal opened with Run as Administrator" : "with sudo";
|
|
29
29
|
}
|
|
30
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* Whether portless asks for sudo to start its proxy on this port. It does on Linux and macOS for
|
|
32
|
+
* every port below 1024, even where the system would let this user bind it; Windows never asks.
|
|
33
|
+
*/
|
|
31
34
|
export function portNeedsRoot(platform, port) {
|
|
32
|
-
return port < 1024 && platform !== "win32"
|
|
35
|
+
return port < 1024 && platform !== "win32";
|
|
33
36
|
}
|
|
34
37
|
/** Trusts the portless certificate authority in the store this system's browsers read. */
|
|
35
38
|
export function trustCertificateCommand(platform, caFile) {
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Docker's own rule for container names; it also keeps names from reading as flags.
|
|
2
|
+
const CONTAINER_NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
|
|
3
|
+
// A host binding such as `0.0.0.0:8000-8002->8000-8002/tcp` or `[::]:8080->80/tcp`.
|
|
4
|
+
const BINDING = /:(\d+)(?:-(\d+))?->\d+(?:-\d+)?\/(tcp|udp|sctp)$/;
|
|
5
|
+
// A range wider than this is a mistake in the compose file, not something to probe port by port.
|
|
6
|
+
const MAX_RANGE = 1000;
|
|
7
|
+
export function isContainerName(value) {
|
|
8
|
+
return CONTAINER_NAME.test(value);
|
|
9
|
+
}
|
|
10
|
+
/** Host TCP ports from the `Ports` column; unpublished ports (`80/tcp`) and UDP are skipped. */
|
|
11
|
+
export function parsePublishedPorts(text) {
|
|
12
|
+
const ports = new Set();
|
|
13
|
+
for (const part of text.split(",")) {
|
|
14
|
+
const match = BINDING.exec(part.trim());
|
|
15
|
+
if (match?.[3] !== "tcp")
|
|
16
|
+
continue;
|
|
17
|
+
const first = Number(match[1]);
|
|
18
|
+
const last = match[2] === undefined ? first : Number(match[2]);
|
|
19
|
+
if (last < first || last - first >= MAX_RANGE)
|
|
20
|
+
continue;
|
|
21
|
+
for (let port = first; port <= last; port += 1) {
|
|
22
|
+
if (port >= 1 && port <= 65535)
|
|
23
|
+
ports.add(port);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return [...ports].sort((a, b) => a - b);
|
|
27
|
+
}
|
|
28
|
+
/** Reads `docker ps --format '{{json .}}'`: one JSON object per line. Bad lines are skipped. */
|
|
29
|
+
export function parseDockerPs(text) {
|
|
30
|
+
const containers = [];
|
|
31
|
+
for (const raw of text.split("\n")) {
|
|
32
|
+
const row = parseLine(raw.trim());
|
|
33
|
+
if (row === null)
|
|
34
|
+
continue;
|
|
35
|
+
const name = (stringField(row, "Names") ?? "").split(",")[0]?.trim() ?? "";
|
|
36
|
+
if (!isContainerName(name))
|
|
37
|
+
continue;
|
|
38
|
+
const labels = parseLabels(stringField(row, "Labels") ?? "");
|
|
39
|
+
containers.push({
|
|
40
|
+
name,
|
|
41
|
+
image: stringField(row, "Image") ?? "",
|
|
42
|
+
composeProject: labels.get("com.docker.compose.project") || null,
|
|
43
|
+
composeService: labels.get("com.docker.compose.service") || null,
|
|
44
|
+
ports: parsePublishedPorts(stringField(row, "Ports") ?? ""),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return containers;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* What the name template reads for a container: the compose service as the package and the
|
|
51
|
+
* compose project as the repository (`api.acme`), else the container name as the folder.
|
|
52
|
+
*/
|
|
53
|
+
export function containerProjectInfo(container) {
|
|
54
|
+
return {
|
|
55
|
+
packageName: container.composeService,
|
|
56
|
+
repoName: container.composeProject,
|
|
57
|
+
folderName: container.name,
|
|
58
|
+
worktreeName: null,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function parseLine(line) {
|
|
62
|
+
if (!line.startsWith("{"))
|
|
63
|
+
return null;
|
|
64
|
+
try {
|
|
65
|
+
const value = JSON.parse(line);
|
|
66
|
+
return typeof value === "object" && value !== null ? value : null;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function stringField(row, key) {
|
|
73
|
+
const value = row[key];
|
|
74
|
+
return typeof value === "string" ? value : null;
|
|
75
|
+
}
|
|
76
|
+
/** `a=1,b=2`; label values with commas are rare and only the compose labels matter here. */
|
|
77
|
+
function parseLabels(text) {
|
|
78
|
+
const labels = new Map();
|
|
79
|
+
for (const pair of text.split(",")) {
|
|
80
|
+
const index = pair.indexOf("=");
|
|
81
|
+
if (index > 0)
|
|
82
|
+
labels.set(pair.slice(0, index).trim(), pair.slice(index + 1).trim());
|
|
83
|
+
}
|
|
84
|
+
return labels;
|
|
85
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/** Where the operating systems hand out ports for port 0 when nobody changed the setting. */
|
|
2
|
+
export const DEFAULT_EPHEMERAL_RANGES = {
|
|
3
|
+
linux: { first: 32768, last: 60999 },
|
|
4
|
+
darwin: { first: 49152, last: 65535 },
|
|
5
|
+
win32: { first: 49152, last: 65535 },
|
|
6
|
+
};
|
|
7
|
+
/** Node (`--inspect`) and Chrome (`--remote-debugging-port`) debugger defaults. */
|
|
8
|
+
export const DEBUGGER_PORTS = [9229, 9222];
|
|
9
|
+
/** True for the `/json/version` answer of a V8 inspector or a Chrome DevTools endpoint. */
|
|
10
|
+
export function isDebuggerVersionInfo(value) {
|
|
11
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
12
|
+
return false;
|
|
13
|
+
return ["V8-Version", "Browser", "Protocol-Version"].some((key) => Object.hasOwn(value, key));
|
|
14
|
+
}
|
|
15
|
+
export function isInRange(port, range) {
|
|
16
|
+
return range !== null && port >= range.first && port <= range.last;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Splits detected ports into the main list and "Other ports". A debugger, another port of the
|
|
20
|
+
* process that serves a named route, and a port in the ephemeral range are helpers, not
|
|
21
|
+
* servers a person opens. The rest is grouped per process: the lowest port outside the ephemeral
|
|
22
|
+
* range is the row, the others are its `extraPorts`.
|
|
23
|
+
*/
|
|
24
|
+
export function arrangeDetected(input) {
|
|
25
|
+
const main = [];
|
|
26
|
+
const otherPorts = [];
|
|
27
|
+
for (const item of input.detected) {
|
|
28
|
+
const route = siblingRoute(item, input.routes);
|
|
29
|
+
const reason = otherReason(item, route, input);
|
|
30
|
+
if (reason === null) {
|
|
31
|
+
main.push(item);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
otherPorts.push({ ...item, reason, ...(route === null ? {} : { routeName: route.name }) });
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
detected: groupByProcess(main, input.ephemeralRange),
|
|
38
|
+
otherPorts: otherPorts.sort((a, b) => a.port - b.port),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function otherReason(item, route, input) {
|
|
42
|
+
const probe = input.debuggers.get(item.port) ?? "unknown";
|
|
43
|
+
if (probe === "debugger" || (probe === "unknown" && DEBUGGER_PORTS.includes(item.port))) {
|
|
44
|
+
return "debugger";
|
|
45
|
+
}
|
|
46
|
+
if (route !== null)
|
|
47
|
+
return "sibling";
|
|
48
|
+
// Docker picks a host port in this range for `-P`; people do open those.
|
|
49
|
+
if (!item.container && !input.showEphemeral && isInRange(item.port, input.ephemeralRange)) {
|
|
50
|
+
return "ephemeral";
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The named route whose process also listens on this port, like an HMR socket. Another process
|
|
56
|
+
* in the same folder is a separate server (an app and its Storybook) and stays in the main list.
|
|
57
|
+
*/
|
|
58
|
+
function siblingRoute(item, routes) {
|
|
59
|
+
if (item.pid === null)
|
|
60
|
+
return null;
|
|
61
|
+
return routes.find((route) => route.pid === item.pid) ?? null;
|
|
62
|
+
}
|
|
63
|
+
function groupByProcess(items, range) {
|
|
64
|
+
const byPid = new Map();
|
|
65
|
+
const rows = [];
|
|
66
|
+
for (const item of items) {
|
|
67
|
+
if (item.pid === null) {
|
|
68
|
+
rows.push(item);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const group = byPid.get(item.pid);
|
|
72
|
+
if (group)
|
|
73
|
+
group.push(item);
|
|
74
|
+
else
|
|
75
|
+
byPid.set(item.pid, [item]);
|
|
76
|
+
}
|
|
77
|
+
for (const group of byPid.values()) {
|
|
78
|
+
const sorted = [...group].sort((a, b) => a.port - b.port);
|
|
79
|
+
const head = sorted.find((item) => !isInRange(item.port, range)) ?? sorted[0];
|
|
80
|
+
if (head === undefined)
|
|
81
|
+
continue;
|
|
82
|
+
const extraPorts = sorted.filter((item) => item !== head).map((item) => item.port);
|
|
83
|
+
rows.push(extraPorts.length === 0 ? head : { ...head, extraPorts });
|
|
84
|
+
}
|
|
85
|
+
return rows.sort((a, b) => a.port - b.port);
|
|
86
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { directUrl } from "../ports/direct-url.js";
|
|
2
|
+
import { err, ok } from "../result.js";
|
|
3
|
+
const VARIABLE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
4
|
+
/** `api.acme` -> `API_ACME_URL`, with the strip suffix removed and the prefix in front. */
|
|
5
|
+
export function envKey(name, options = {}) {
|
|
6
|
+
const suffix = stripSuffix(options.strip);
|
|
7
|
+
const base = suffix !== null && name.endsWith(suffix) ? name.slice(0, -suffix.length) : name;
|
|
8
|
+
const words = base
|
|
9
|
+
.toUpperCase()
|
|
10
|
+
.replace(/[^A-Z0-9]+/g, "_")
|
|
11
|
+
.replace(/^_+|_+$/g, "");
|
|
12
|
+
const key = `${words === "" ? "" : `${words}_`}URL`;
|
|
13
|
+
const prefix = options.prefix ?? "";
|
|
14
|
+
if (prefix === "")
|
|
15
|
+
return /^[0-9]/.test(key) ? `_${key}` : key;
|
|
16
|
+
return `${prefix.endsWith("_") ? prefix : `${prefix}_`}${key}`;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Keeps the routes the names point at: a name matches itself and every route ending in `.<name>`,
|
|
20
|
+
* so `acme` picks `api.acme` and `web.acme`. No names keeps every route.
|
|
21
|
+
*/
|
|
22
|
+
export function selectEnvRoutes(routes, names) {
|
|
23
|
+
if (names.length === 0)
|
|
24
|
+
return ok([...routes]);
|
|
25
|
+
const matches = (route, name) => route.name === name || route.name.endsWith(`.${name}`);
|
|
26
|
+
const missing = names.find((name) => !routes.some((route) => matches(route, name)));
|
|
27
|
+
if (missing !== undefined)
|
|
28
|
+
return err(`No route matches "${missing}".`);
|
|
29
|
+
return ok(routes.filter((route) => names.some((name) => matches(route, name))));
|
|
30
|
+
}
|
|
31
|
+
/** One key and value per route, sorted by route name; fails on a bad prefix or a repeated key. */
|
|
32
|
+
export function envEntries(routes, options = {}) {
|
|
33
|
+
const prefix = options.prefix ?? "";
|
|
34
|
+
if (prefix !== "" && !VARIABLE_NAME.test(prefix)) {
|
|
35
|
+
return err(`The prefix "${prefix}" must use letters, digits and _, and not start with a digit.`);
|
|
36
|
+
}
|
|
37
|
+
const sorted = [...routes].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
38
|
+
const entries = [];
|
|
39
|
+
for (const route of sorted) {
|
|
40
|
+
const key = envKey(route.name, options);
|
|
41
|
+
const taken = entries.find((entry) => entry.key === key);
|
|
42
|
+
if (taken) {
|
|
43
|
+
return err(`${taken.name} and ${route.name} both become ${key}. Pass only one of them, or change the strip suffix.`);
|
|
44
|
+
}
|
|
45
|
+
entries.push({
|
|
46
|
+
name: route.name,
|
|
47
|
+
key,
|
|
48
|
+
value: options.direct ? directUrl(route.port) : route.url,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return ok(entries);
|
|
52
|
+
}
|
|
53
|
+
/** `KEY=value` lines for a `.env` file, one per route. */
|
|
54
|
+
export function envLines(routes, options = {}) {
|
|
55
|
+
const entries = envEntries(routes, options);
|
|
56
|
+
if (!entries.ok)
|
|
57
|
+
return entries;
|
|
58
|
+
return ok(entries.value.map(formatEnvLine));
|
|
59
|
+
}
|
|
60
|
+
export function formatEnvLine(entry) {
|
|
61
|
+
return `${entry.key}=${quoteEnvValue(entry.value)}`;
|
|
62
|
+
}
|
|
63
|
+
/** Double quotes only when a `.env` reader would otherwise cut or change the value. */
|
|
64
|
+
function quoteEnvValue(value) {
|
|
65
|
+
if (value !== "" && !/[\s#"'`$\\]/.test(value))
|
|
66
|
+
return value;
|
|
67
|
+
return `"${value.replace(/[\\"]/g, (char) => `\\${char}`)}"`;
|
|
68
|
+
}
|
|
69
|
+
function stripSuffix(strip) {
|
|
70
|
+
const trimmed = strip?.trim().replace(/^\.+/, "") ?? "";
|
|
71
|
+
return trimmed === "" ? null : `.${trimmed}`;
|
|
72
|
+
}
|
|
@@ -19,10 +19,22 @@ export function nameFromHostname(hostname, tlds) {
|
|
|
19
19
|
}
|
|
20
20
|
return hostname;
|
|
21
21
|
}
|
|
22
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* Builds the browser URL on the port names open on: the public port when there is one, else the
|
|
24
|
+
* proxy's. Omits the port when it is the protocol default.
|
|
25
|
+
*/
|
|
23
26
|
export function routeUrl(hostname, proxy) {
|
|
24
27
|
const scheme = proxy.https ? "https" : "http";
|
|
25
28
|
const defaultPort = proxy.https ? 443 : 80;
|
|
26
|
-
const
|
|
29
|
+
const open = proxy.publicPort ?? proxy.port;
|
|
30
|
+
const port = open === null || open === defaultPort ? "" : `:${open}`;
|
|
27
31
|
return `${scheme}://${hostname}${port}`;
|
|
28
32
|
}
|
|
33
|
+
/** Points every route URL at `publicPort`, the port Studio forwards to the proxy; null keeps them. */
|
|
34
|
+
export function withPublicPort(state, publicPort) {
|
|
35
|
+
if (publicPort === null)
|
|
36
|
+
return state;
|
|
37
|
+
const proxy = { ...state.proxy, publicPort };
|
|
38
|
+
const routes = state.routes.map((route) => ({ ...route, url: routeUrl(route.hostname, proxy) }));
|
|
39
|
+
return { ...state, proxy, routes };
|
|
40
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { isContainerName } from "../../core/ports/docker-container.js";
|
|
2
|
+
import { err, ok } from "../../core/result.js";
|
|
3
|
+
import { appError } from "./app-error.js";
|
|
4
|
+
/**
|
|
5
|
+
* Stops a container from its row. Only a container the last `docker ps` listed can be stopped, so
|
|
6
|
+
* the endpoint cannot be used to stop anything else.
|
|
7
|
+
*/
|
|
8
|
+
export async function stopContainer(name, source) {
|
|
9
|
+
if (!isContainerName(name)) {
|
|
10
|
+
return err(appError("invalid_request", "That is not a Docker container name."));
|
|
11
|
+
}
|
|
12
|
+
const reading = await source.read();
|
|
13
|
+
const listed = reading.state === "ok" && reading.containers.some((item) => item.name === name);
|
|
14
|
+
if (!listed) {
|
|
15
|
+
return err(appError("process_not_allowed", `Container ${name} is not one Studio shows; it may have stopped.`));
|
|
16
|
+
}
|
|
17
|
+
const stopped = await source.stop(name);
|
|
18
|
+
return stopped.ok ? ok({ name }) : stopped;
|
|
19
|
+
}
|
|
@@ -10,11 +10,13 @@ export function diagnose(facts) {
|
|
|
10
10
|
pushIf(checks, proxyCountCheck(facts));
|
|
11
11
|
pushIf(checks, httpsTrustCheck(facts));
|
|
12
12
|
pushIf(checks, routesFileCheck(facts));
|
|
13
|
+
pushIf(checks, shorterUrlsCheck(facts));
|
|
13
14
|
}
|
|
14
15
|
checks.push(studioPortCheck(facts));
|
|
15
16
|
pushIf(checks, loginServiceCheck(facts));
|
|
16
17
|
pushIf(checks, safariCheck(facts));
|
|
17
18
|
pushIf(checks, notificationsCheck(facts));
|
|
19
|
+
pushIf(checks, dockerCheck(facts));
|
|
18
20
|
return checks;
|
|
19
21
|
}
|
|
20
22
|
export function createDoctorService(deps) {
|
|
@@ -42,15 +44,59 @@ async function runAction(action, facts, deps) {
|
|
|
42
44
|
return deps.controlProxy("start");
|
|
43
45
|
if (action === "restart-proxy")
|
|
44
46
|
return deps.controlProxy("restart");
|
|
45
|
-
|
|
47
|
+
if (action === "use-short-urls")
|
|
48
|
+
return turnOnShortUrls(facts, deps);
|
|
49
|
+
const http = { ...facts.config, proxyPort: 1355, proxyHttps: false };
|
|
50
|
+
return applyProxyConfig(http, "HTTP on port 1355", facts, deps);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Turns `shortUrls` on and checks that Studio really listens on port 80. The proxy keeps its
|
|
54
|
+
* port and keeps running; when the listen fails, the previous config comes back.
|
|
55
|
+
*/
|
|
56
|
+
async function turnOnShortUrls(facts, deps) {
|
|
57
|
+
const config = { ...facts.config, shortUrls: true };
|
|
58
|
+
const saved = await deps.updateConfig(config);
|
|
46
59
|
if (!saved.ok)
|
|
47
60
|
return saved;
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
61
|
+
const status = await deps.syncShortUrls(config);
|
|
62
|
+
if (status.state === "listening") {
|
|
63
|
+
return ok("Names now open as http://name.localhost: Studio forwards port " +
|
|
64
|
+
`${status.port} to the proxy on port ${status.target}.`);
|
|
65
|
+
}
|
|
66
|
+
await deps.updateConfig(facts.config);
|
|
67
|
+
await deps.syncShortUrls(facts.config);
|
|
68
|
+
const why = status.state === "failed" ? `${status.message} ` : "";
|
|
69
|
+
const code = status.state === "failed" && status.reason === "in-use" ? "port_in_use" : "command_failed";
|
|
70
|
+
return err(appError(code, `${why}Studio kept the previous settings, so names still open with ` +
|
|
71
|
+
`:${facts.config.proxyPort}.`));
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Saves the proxy settings, then starts or restarts the proxy with them when Studio may. When
|
|
75
|
+
* that start fails, the previous settings come back and a proxy that was running starts again on
|
|
76
|
+
* them, so names keep working.
|
|
77
|
+
*/
|
|
78
|
+
async function applyProxyConfig(config, what, facts, deps) {
|
|
79
|
+
const saved = await deps.updateConfig(config);
|
|
80
|
+
if (!saved.ok)
|
|
81
|
+
return saved;
|
|
82
|
+
if (facts.proxy.running && facts.proxyOwner !== "you") {
|
|
83
|
+
return ok(`Saved ${what}. Stop the current proxy with \`${stopProxyAsAdminCommand(facts.platform)}\` ` +
|
|
84
|
+
`${adminWords(facts.platform)}, then start the new one from the proxy menu.`);
|
|
85
|
+
}
|
|
86
|
+
const started = await deps.controlProxy(facts.proxy.running ? "restart" : "start");
|
|
87
|
+
if (started.ok)
|
|
88
|
+
return started;
|
|
89
|
+
await deps.updateConfig(facts.config);
|
|
90
|
+
let back = "Studio went back to the previous settings.";
|
|
91
|
+
if (facts.proxy.running) {
|
|
92
|
+
const again = await deps.controlProxy("start");
|
|
93
|
+
if (again.ok) {
|
|
94
|
+
back =
|
|
95
|
+
"Studio went back to the previous settings and started the proxy on port " +
|
|
96
|
+
`${facts.config.proxyPort} again.`;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return err({ ...started.error, message: `${started.error.message} ${back}` });
|
|
54
100
|
}
|
|
55
101
|
function portlessCheck({ portless }) {
|
|
56
102
|
if (portless.installed) {
|
|
@@ -106,8 +152,9 @@ function proxyRunningCheck(facts) {
|
|
|
106
152
|
status: "warn",
|
|
107
153
|
title: "The proxy is not running",
|
|
108
154
|
detail: needsSudo
|
|
109
|
-
? `Names do not open until it runs.
|
|
110
|
-
|
|
155
|
+
? `Names do not open until it runs. portless asks for sudo below port 1024, so Studio ` +
|
|
156
|
+
`cannot start it on port ${config.proxyPort}; HTTP on port 1355 needs nothing, and short ` +
|
|
157
|
+
"URLs drop the port from names."
|
|
111
158
|
: "Names do not open until it runs.",
|
|
112
159
|
fix: needsSudo
|
|
113
160
|
? { label: "Use HTTP on port 1355", action: "use-http-1355" }
|
|
@@ -327,6 +374,149 @@ function notificationsCheck({ config, systemNotifier }) {
|
|
|
327
374
|
fix: { label: "Install notify-send", command: "sudo apt install libnotify-bin" },
|
|
328
375
|
};
|
|
329
376
|
}
|
|
377
|
+
const UNPRIVILEGED_PORT_SETTING = "net.ipv4.ip_unprivileged_port_start=80";
|
|
378
|
+
/**
|
|
379
|
+
* Short URLs drop the port number from every name (`http://name.localhost`). Studio itself listens
|
|
380
|
+
* on port 80 and forwards to the proxy, only when a test bind shows this user may listen there
|
|
381
|
+
* without sudo; it never changes the system.
|
|
382
|
+
*/
|
|
383
|
+
function shorterUrlsCheck(facts) {
|
|
384
|
+
const { proxy, config, platform, port80, shortUrls } = facts;
|
|
385
|
+
const https = proxy.running ? proxy.https : config.proxyHttps;
|
|
386
|
+
if (https) {
|
|
387
|
+
if (!config.shortUrls)
|
|
388
|
+
return null;
|
|
389
|
+
return {
|
|
390
|
+
id: "shorter-urls",
|
|
391
|
+
status: "ok",
|
|
392
|
+
title: "Short URLs work over HTTP only",
|
|
393
|
+
detail: "The proxy serves HTTPS, so names keep their port. Switch the proxy to HTTP to open " +
|
|
394
|
+
"them as http://name.localhost.",
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
const port = (proxy.running ? proxy.port : null) ?? config.proxyPort;
|
|
398
|
+
if (port === 80) {
|
|
399
|
+
return {
|
|
400
|
+
id: "shorter-urls",
|
|
401
|
+
status: "ok",
|
|
402
|
+
title: "Names open without a port number",
|
|
403
|
+
detail: "The proxy uses port 80, so names open as http://name.localhost.",
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
if (shortUrls?.state === "listening") {
|
|
407
|
+
return {
|
|
408
|
+
id: "shorter-urls",
|
|
409
|
+
status: "ok",
|
|
410
|
+
title: "Names open without a port number",
|
|
411
|
+
detail: `Studio listens on port ${shortUrls.port} and forwards to the proxy on port ` +
|
|
412
|
+
`${shortUrls.target}, so names open as http://name.localhost.`,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
const now = `Names open as http://name.localhost:${port}`;
|
|
416
|
+
if (config.shortUrls)
|
|
417
|
+
return failedShortUrlsCheck(facts, now);
|
|
418
|
+
if (port80 === undefined)
|
|
419
|
+
return null;
|
|
420
|
+
if (port80 === "free") {
|
|
421
|
+
return {
|
|
422
|
+
id: "shorter-urls",
|
|
423
|
+
status: "ok",
|
|
424
|
+
title: "Shorter URLs: port 80 is available",
|
|
425
|
+
detail: `${now}. This user can listen on port 80 without sudo, so Studio can listen there and ` +
|
|
426
|
+
`forward to the proxy, and names open as http://name.localhost. The proxy stays on ` +
|
|
427
|
+
`port ${port}.`,
|
|
428
|
+
fix: { label: "Use short URLs", action: "use-short-urls" },
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
if (port80 === "in-use") {
|
|
432
|
+
return {
|
|
433
|
+
id: "shorter-urls",
|
|
434
|
+
status: "ok",
|
|
435
|
+
title: "Port 80 is taken by another program",
|
|
436
|
+
detail: `${now}. Stop the program on port 80 to drop the port number from names.`,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
if (platform !== "linux") {
|
|
440
|
+
return {
|
|
441
|
+
id: "shorter-urls",
|
|
442
|
+
status: "ok",
|
|
443
|
+
title: "Shorter URLs: this system does not allow port 80",
|
|
444
|
+
detail: `${now}. This user cannot listen on port 80 here, so names keep the port.`,
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
return {
|
|
448
|
+
id: "shorter-urls",
|
|
449
|
+
status: "ok",
|
|
450
|
+
title: "Shorter URLs need a one-time system setting",
|
|
451
|
+
detail: `${now}. ${SYSCTL_ADVICE} Then run the checks again and choose Use short URLs.`,
|
|
452
|
+
fix: SYSCTL_FIX,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
/** `shortUrls` is on and Studio does not listen: names still work, with the port. */
|
|
456
|
+
function failedShortUrlsCheck(facts, now) {
|
|
457
|
+
const { platform, port80, shortUrls } = facts;
|
|
458
|
+
const reason = shortUrls?.state === "failed" ? shortUrls.reason : port80;
|
|
459
|
+
const retry = port80 === "free"
|
|
460
|
+
? { fix: { label: "Try port 80 again", action: "use-short-urls" } }
|
|
461
|
+
: {};
|
|
462
|
+
if (reason === "in-use") {
|
|
463
|
+
return {
|
|
464
|
+
id: "shorter-urls",
|
|
465
|
+
status: "warn",
|
|
466
|
+
title: "Short URLs are on, but port 80 is taken by another program",
|
|
467
|
+
detail: `${now}. Stop the program on port 80, then run the checks again.`,
|
|
468
|
+
...retry,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
if (reason === "denied" && platform === "linux" && port80 !== "free") {
|
|
472
|
+
return {
|
|
473
|
+
id: "shorter-urls",
|
|
474
|
+
status: "warn",
|
|
475
|
+
title: "Short URLs are on, but Linux does not let this user listen on port 80",
|
|
476
|
+
detail: `${now}. ${SYSCTL_ADVICE} Then run the checks again and choose Try port 80 again.`,
|
|
477
|
+
fix: SYSCTL_FIX,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
return {
|
|
481
|
+
id: "shorter-urls",
|
|
482
|
+
status: "warn",
|
|
483
|
+
title: "Short URLs are on, but Studio does not listen on port 80",
|
|
484
|
+
detail: shortUrls?.state === "failed"
|
|
485
|
+
? `${now}. Studio could not listen there: ${shortUrls.message}`
|
|
486
|
+
: `${now}. Studio is not running, or has not tried port 80 yet.`,
|
|
487
|
+
...retry,
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
const SYSCTL_ADVICE = "Linux lets only root listen on ports below 1024. The command lets every user on this machine " +
|
|
491
|
+
"listen on port 80 and up until the next restart; Studio never runs it. To keep it after a " +
|
|
492
|
+
`restart, also run \`echo '${UNPRIVILEGED_PORT_SETTING}' | sudo tee ` +
|
|
493
|
+
"/etc/sysctl.d/99-portless-studio.conf`.";
|
|
494
|
+
const SYSCTL_FIX = {
|
|
495
|
+
label: "Allow port 80 until the next restart",
|
|
496
|
+
command: `sudo sysctl -w ${UNPRIVILEGED_PORT_SETTING}`,
|
|
497
|
+
};
|
|
498
|
+
/** Information only: without Docker, container ports simply do not show. */
|
|
499
|
+
function dockerCheck({ docker }) {
|
|
500
|
+
if (docker === undefined || docker.state === "missing")
|
|
501
|
+
return null;
|
|
502
|
+
if (docker.state === "ok") {
|
|
503
|
+
const count = docker.containers === 1 ? "1 running container" : `${docker.containers} running containers`;
|
|
504
|
+
return {
|
|
505
|
+
id: "docker",
|
|
506
|
+
status: "ok",
|
|
507
|
+
title: "Studio reads Docker containers",
|
|
508
|
+
detail: `docker ps lists ${count}; the ports they publish show under Detected.`,
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
return {
|
|
512
|
+
id: "docker",
|
|
513
|
+
status: "ok",
|
|
514
|
+
title: "Studio cannot read Docker containers",
|
|
515
|
+
detail: "The docker command is installed, but docker ps did not answer: Docker is not running, " +
|
|
516
|
+
"or this user may not use it (on Linux, the docker group). Ports of containers do not " +
|
|
517
|
+
"show under Detected until it answers.",
|
|
518
|
+
};
|
|
519
|
+
}
|
|
330
520
|
function startCommand(config) {
|
|
331
521
|
const tls = config.proxyHttps ? "--https" : "--no-tls";
|
|
332
522
|
return `portless proxy start --port ${config.proxyPort} ${tls}`;
|