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.
Files changed (41) hide show
  1. package/README.md +78 -192
  2. package/dist/cli/cli-args.js +29 -3
  3. package/dist/cli/index.js +10 -15
  4. package/dist/cli/login-service.js +16 -0
  5. package/dist/cli/mcp/studio-tools.js +20 -7
  6. package/dist/cli/studio-items.js +5 -2
  7. package/dist/core/api/contract.js +19 -1
  8. package/dist/core/config/studio-config.js +6 -0
  9. package/dist/core/doctor/platform-commands.js +5 -2
  10. package/dist/core/ports/direct-url.js +7 -0
  11. package/dist/core/ports/docker-container.js +85 -0
  12. package/dist/core/routes/route.js +14 -2
  13. package/dist/core/updates/version.js +62 -0
  14. package/dist/server/application/auto-namer.js +14 -0
  15. package/dist/server/application/container-source.js +19 -0
  16. package/dist/server/application/doctor.js +225 -9
  17. package/dist/server/application/port-discovery.js +49 -6
  18. package/dist/server/application/proxy-start-check.js +83 -0
  19. package/dist/server/application/short-urls.js +136 -0
  20. package/dist/server/application/studio-runtime.js +46 -7
  21. package/dist/server/application/terminals.js +142 -15
  22. package/dist/server/application/update-check.js +66 -0
  23. package/dist/server/infrastructure/config/json-update-cache.js +35 -0
  24. package/dist/server/infrastructure/portless/portless-registry.js +31 -4
  25. package/dist/server/infrastructure/portless/state-files.js +23 -7
  26. package/dist/server/infrastructure/system/docker-containers.js +64 -0
  27. package/dist/server/infrastructure/system/doctor-facts.js +44 -1
  28. package/dist/server/infrastructure/system/npm-registry.js +20 -0
  29. package/dist/server/infrastructure/system/port-forwarder.js +78 -0
  30. package/dist/server/infrastructure/system/process-runner.js +2 -1
  31. package/dist/server/infrastructure/terminal/process-tree.js +71 -0
  32. package/dist/server/infrastructure/terminal/terminal-spawner.js +44 -3
  33. package/dist/server/interface/http/http-server.js +43 -2
  34. package/dist/server/interface/http/json-io.js +1 -0
  35. package/dist/server/main.js +62 -8
  36. package/dist/web/assets/index-BDEwEBPS.js +15 -0
  37. package/dist/web/assets/index-C9wkL1J8.css +1 -0
  38. package/dist/web/index.html +2 -2
  39. package/package.json +2 -2
  40. package/dist/web/assets/index-CA7HpYbk.css +0 -1
  41. package/dist/web/assets/index-CC6BquZf.js +0 -15
@@ -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
+ }
@@ -19,10 +19,22 @@ export function nameFromHostname(hostname, tlds) {
19
19
  }
20
20
  return hostname;
21
21
  }
22
- /** Builds the browser URL; omits the port when it is the protocol default. */
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 port = proxy.port === null || proxy.port === defaultPort ? "" : `:${proxy.port}`;
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,62 @@
1
+ /** The command that installs the latest release globally, shown with every update notice. */
2
+ export const UPDATE_COMMAND = "npm i -g portless-studio";
3
+ const VERSION = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
4
+ /**
5
+ * Compares two semver versions: negative when `a` is older, positive when newer, 0 when equal.
6
+ * A prerelease sorts before its release. Returns null when either is not a version.
7
+ */
8
+ export function compareVersions(a, b) {
9
+ const left = VERSION.exec(a.trim());
10
+ const right = VERSION.exec(b.trim());
11
+ if (!left || !right)
12
+ return null;
13
+ for (let index = 1; index <= 3; index++) {
14
+ const difference = Number(left[index]) - Number(right[index]);
15
+ if (difference !== 0)
16
+ return Math.sign(difference);
17
+ }
18
+ return comparePrereleases(left[4], right[4]);
19
+ }
20
+ /** True when `latest` is a newer version than `current`; false for anything unreadable. */
21
+ export function isNewerVersion(latest, current) {
22
+ const order = compareVersions(latest, current);
23
+ return order !== null && order > 0;
24
+ }
25
+ /** The line Studio prints on start when a newer release exists. */
26
+ export function updateMessage(current, latest) {
27
+ return (`A new version of portless-studio is available: ${current} -> ${latest}. ` +
28
+ `Update with: ${UPDATE_COMMAND}`);
29
+ }
30
+ function comparePrereleases(a, b) {
31
+ if (a === b)
32
+ return 0;
33
+ if (a === undefined)
34
+ return 1;
35
+ if (b === undefined)
36
+ return -1;
37
+ const left = a.split(".");
38
+ const right = b.split(".");
39
+ for (let index = 0; index < Math.max(left.length, right.length); index++) {
40
+ const x = left[index];
41
+ const y = right[index];
42
+ if (x === undefined)
43
+ return -1;
44
+ if (y === undefined)
45
+ return 1;
46
+ const xNumeric = /^\d+$/.test(x);
47
+ const yNumeric = /^\d+$/.test(y);
48
+ if (xNumeric && yNumeric) {
49
+ const difference = Number(x) - Number(y);
50
+ if (difference !== 0)
51
+ return Math.sign(difference);
52
+ }
53
+ else if (xNumeric !== yNumeric) {
54
+ // Numeric identifiers sort before alphanumeric ones.
55
+ return xNumeric ? -1 : 1;
56
+ }
57
+ else if (x !== y) {
58
+ return x < y ? -1 : 1;
59
+ }
60
+ }
61
+ return 0;
62
+ }
@@ -8,6 +8,8 @@ export class AutoNamer {
8
8
  #log;
9
9
  #hooks;
10
10
  #attempted = new Set();
11
+ /** In how many scans in a row each port and process pair showed up unnamed. */
12
+ #streaks = new Map();
11
13
  constructor(registry, log, hooks = {}) {
12
14
  this.#registry = registry;
13
15
  this.#log = log;
@@ -15,6 +17,8 @@ export class AutoNamer {
15
17
  }
16
18
  /** Returns how many ports got a name. */
17
19
  async name(detected) {
20
+ const stableScans = this.#hooks.stableScans ?? 2;
21
+ const streaks = new Map();
18
22
  let named = 0;
19
23
  for (const port of detected) {
20
24
  if (port.suggestedName === null)
@@ -22,13 +26,23 @@ export class AutoNamer {
22
26
  const key = `${port.port}:${port.pid}`;
23
27
  if (this.#attempted.has(key))
24
28
  continue;
29
+ const streak = (this.#streaks.get(key) ?? 0) + 1;
30
+ streaks.set(key, streak);
31
+ if (streak < stableScans)
32
+ continue;
25
33
  this.#attempted.add(key);
26
34
  if (await this.#nameOne(port.port, port.suggestedName))
27
35
  named += 1;
28
36
  }
37
+ // A port missing from this scan starts counting again when it comes back.
38
+ this.#streaks = streaks;
29
39
  return named;
30
40
  }
31
41
  async #nameOne(port, suggested) {
42
+ // The scan is a few seconds old; portless may have registered the port since.
43
+ const { routes } = await this.#registry.readSnapshot();
44
+ if (routes.some((route) => route.port === port))
45
+ return false;
32
46
  let lastError = "";
33
47
  // The second name keeps two servers of the same project apart: `web.acme-4321`.
34
48
  for (const candidate of [suggested, `${suggested}-${port}`]) {
@@ -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
+ }
@@ -9,11 +9,14 @@ export function diagnose(facts) {
9
9
  pushIf(checks, proxyOwnerCheck(facts));
10
10
  pushIf(checks, proxyCountCheck(facts));
11
11
  pushIf(checks, httpsTrustCheck(facts));
12
+ pushIf(checks, routesFileCheck(facts));
13
+ pushIf(checks, shorterUrlsCheck(facts));
12
14
  }
13
15
  checks.push(studioPortCheck(facts));
14
16
  pushIf(checks, loginServiceCheck(facts));
15
17
  pushIf(checks, safariCheck(facts));
16
18
  pushIf(checks, notificationsCheck(facts));
19
+ pushIf(checks, dockerCheck(facts));
17
20
  return checks;
18
21
  }
19
22
  export function createDoctorService(deps) {
@@ -41,15 +44,59 @@ async function runAction(action, facts, deps) {
41
44
  return deps.controlProxy("start");
42
45
  if (action === "restart-proxy")
43
46
  return deps.controlProxy("restart");
44
- const saved = await deps.updateConfig({ ...facts.config, proxyPort: 1355, proxyHttps: false });
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);
45
59
  if (!saved.ok)
46
60
  return saved;
47
- if (!facts.proxy.running)
48
- return deps.controlProxy("start");
49
- if (facts.proxyOwner === "you")
50
- return deps.controlProxy("restart");
51
- return ok(`Saved HTTP on port 1355. Stop the current proxy with \`${stopProxyAsAdminCommand(facts.platform)}\` ` +
52
- `${adminWords(facts.platform)}, then start the new one from the proxy menu.`);
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}` });
53
100
  }
54
101
  function portlessCheck({ portless }) {
55
102
  if (portless.installed) {
@@ -105,8 +152,9 @@ function proxyRunningCheck(facts) {
105
152
  status: "warn",
106
153
  title: "The proxy is not running",
107
154
  detail: needsSudo
108
- ? `Names do not open until it runs. Port ${config.proxyPort} needs sudo, so Studio cannot ` +
109
- "start it; HTTP on port 1355 needs nothing."
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."
110
158
  : "Names do not open until it runs.",
111
159
  fix: needsSudo
112
160
  ? { label: "Use HTTP on port 1355", action: "use-http-1355" }
@@ -145,6 +193,31 @@ function proxyOwnerCheck({ proxy, proxyPid, proxyOwner, platform, }) {
145
193
  },
146
194
  };
147
195
  }
196
+ /** portless is before 1.0 and may change its state files; Studio says so instead of hiding routes. */
197
+ function routesFileCheck({ routesFile, portless }) {
198
+ if (!routesFile)
199
+ return null;
200
+ const version = portless.version ? `portless ${portless.version}` : "This portless";
201
+ const advice = "Update portless-studio, or report it with the portless version.";
202
+ if (routesFile.state === "unexpected") {
203
+ return {
204
+ id: "routes-file",
205
+ status: "warn",
206
+ title: "Studio cannot read routes.json",
207
+ detail: `${version} saved it in a format this version of Studio does not know, so no route is ` +
208
+ `shown. ${advice}`,
209
+ };
210
+ }
211
+ if (routesFile.state !== "ok" || routesFile.unreadable === 0)
212
+ return null;
213
+ return {
214
+ id: "routes-file",
215
+ status: "warn",
216
+ title: `Studio cannot read ${routesFile.unreadable} of the ${routesFile.entries} routes portless saved`,
217
+ detail: `${version} may have changed the format of routes.json, so those routes are not shown. ` +
218
+ advice,
219
+ };
220
+ }
148
221
  function proxyCountCheck({ proxyProcesses, platform }) {
149
222
  if (proxyProcesses === null || proxyProcesses.length < 2)
150
223
  return null;
@@ -301,6 +374,149 @@ function notificationsCheck({ config, systemNotifier }) {
301
374
  fix: { label: "Install notify-send", command: "sudo apt install libnotify-bin" },
302
375
  };
303
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
+ }
304
520
  function startCommand(config) {
305
521
  const tls = config.proxyHttps ? "--https" : "--no-tls";
306
522
  return `portless proxy start --port ${config.proxyPort} ${tls}`;
@@ -1,4 +1,5 @@
1
1
  import { projectOf, suggestName, } from "../../core/ports/detected-port.js";
2
+ import { containerProjectInfo } from "../../core/ports/docker-container.js";
2
3
  /**
3
4
  * Listening ports a person would want to name: they answer HTTP, have no portless route, and are
4
5
  * not Studio, the proxy or something the config ignores. Sorted by port.
@@ -10,17 +11,59 @@ export async function discoverPorts(scanner, projects, context) {
10
11
  ...context.config.ignoredPorts,
11
12
  ]);
12
13
  const ignored = context.config.ignoredProcesses.map((name) => name.toLowerCase());
14
+ const published = containerPorts(context.containers ?? [], skippedPorts, ignored);
13
15
  // A port whose process we cannot read belongs to another user or the system (printing, DNS),
14
- // never to a dev server this person started.
16
+ // never to a dev server this person started. A container's port is shown as the container.
15
17
  const candidates = (await scanner.scan()).filter((port) => port.pid !== null &&
16
18
  !skippedPorts.has(port.port) &&
19
+ !published.has(port.port) &&
17
20
  !isIgnoredProcess(port.processName, ignored));
21
+ const fromContainers = [...published];
18
22
  // Probes run in parallel; a port that never answers costs its timeout once, not per port.
19
- const answers = await Promise.all(candidates.map((port) => scanner.answersHttp(port.port)));
20
- return candidates
21
- .filter((port, index) => answers[index] && isDevServer(port, projects))
22
- .map((port) => toDetected(port, projects, context.config.nameTemplate))
23
- .sort((a, b) => a.port - b.port);
23
+ const [answers, containerAnswers] = await Promise.all([
24
+ Promise.all(candidates.map((port) => scanner.answersHttp(port.port))),
25
+ Promise.all(fromContainers.map(([port]) => scanner.answersHttp(port))),
26
+ ]);
27
+ const template = context.config.nameTemplate;
28
+ return [
29
+ ...candidates
30
+ .filter((port, index) => answers[index] && isDevServer(port, projects))
31
+ .map((port) => toDetected(port, projects, template)),
32
+ ...fromContainers
33
+ .filter((_, index) => containerAnswers[index])
34
+ .map(([port, container]) => containerDetected(port, container, template)),
35
+ ].sort((a, b) => a.port - b.port);
36
+ }
37
+ /** Published port -> container, without the ports the filters skip. */
38
+ function containerPorts(containers, skippedPorts, ignored) {
39
+ const ports = new Map();
40
+ if (isIgnoredProcess(CONTAINER_PROCESS, ignored))
41
+ return ports;
42
+ for (const container of containers) {
43
+ for (const port of container.ports) {
44
+ if (!skippedPorts.has(port) && !ports.has(port))
45
+ ports.set(port, container);
46
+ }
47
+ }
48
+ return ports;
49
+ }
50
+ /** What a container's row shows as its process, and what `ignoredProcesses` matches. */
51
+ const CONTAINER_PROCESS = "docker";
52
+ function containerDetected(port, container, nameTemplate) {
53
+ const project = container.composeProject;
54
+ return {
55
+ port,
56
+ pid: null,
57
+ processName: CONTAINER_PROCESS,
58
+ cwd: null,
59
+ suggestedName: suggestName(containerProjectInfo(container), nameTemplate, port),
60
+ ...(project === null ? {} : { project: { name: project, worktree: null } }),
61
+ container: {
62
+ name: container.name,
63
+ image: container.image,
64
+ composeProject: project,
65
+ },
66
+ };
24
67
  }
25
68
  // Interpreters dev servers run on. Anything else counts only when it was built inside the
26
69
  // project, like `target/debug/api`, so editors, chat apps and background tools stay out.
@@ -0,0 +1,83 @@
1
+ import { err, ok } from "../../core/result.js";
2
+ import { appError } from "./app-error.js";
3
+ const DEFAULT_ATTEMPTS = 12;
4
+ const DEFAULT_INTERVAL_MS = 250;
5
+ /**
6
+ * The same registry, but a proxy start counts only once the proxy portless recorded is alive and
7
+ * the port listens. `portless proxy start` can exit 0 while another proxy, with another state
8
+ * directory, already holds the port; without this check Studio would report a start that never
9
+ * happened.
10
+ */
11
+ export function confirmProxyStarts(registry, deps) {
12
+ return {
13
+ readSnapshot: () => registry.readSnapshot(),
14
+ watch: (onChange) => registry.watch(onChange),
15
+ createAlias: (name, port, force) => registry.createAlias(name, port, force),
16
+ removeAlias: (name) => registry.removeAlias(name),
17
+ installPortless: () => registry.installPortless(),
18
+ stopProxy: () => registry.stopProxy(),
19
+ proxyPid: () => registry.proxyPid(),
20
+ startProxy: (port, https) => startConfirmed(registry, deps, port, https),
21
+ };
22
+ }
23
+ async function startConfirmed(registry, deps, port, https) {
24
+ const ownPid = await registry.proxyPid().catch(() => null);
25
+ const before = await holderOf(deps, port);
26
+ // Our own proxy may still hold the port for a moment after a stop, during a restart.
27
+ if (before !== null && before !== "unknown" && (before.pid === null || before.pid !== ownPid)) {
28
+ return err(await inUse(deps, port, before));
29
+ }
30
+ const started = await registry.startProxy(port, https);
31
+ if (!started.ok)
32
+ return started;
33
+ const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
34
+ const attempts = deps.attempts ?? DEFAULT_ATTEMPTS;
35
+ let holder = null;
36
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
37
+ if (attempt > 0)
38
+ await sleep(deps.intervalMs ?? DEFAULT_INTERVAL_MS);
39
+ holder = await holderOf(deps, port);
40
+ const { proxy } = await registry.readSnapshot();
41
+ if (!proxy.running)
42
+ continue;
43
+ // Without a port list, a live recorded pid is all there is to go on.
44
+ if (holder === "unknown")
45
+ return ok(undefined);
46
+ if (holder !== null && (await isOurs(registry, deps, holder)))
47
+ return ok(undefined);
48
+ }
49
+ if (holder !== null && holder !== "unknown")
50
+ return err(await inUse(deps, port, holder));
51
+ return err(appError("command_failed", `portless reported a start, but no proxy listens on port ${port}. Run \`portless proxy ` +
52
+ "start` in a terminal to see why."));
53
+ }
54
+ /** The listener is the proxy portless recorded, or a portless proxy it forked. */
55
+ async function isOurs(registry, deps, holder) {
56
+ if (holder.pid === null)
57
+ return true;
58
+ if (holder.pid === (await registry.proxyPid().catch(() => null)))
59
+ return true;
60
+ return (await proxyPidsAmong(deps, [holder.pid])).has(holder.pid);
61
+ }
62
+ async function holderOf(deps, port) {
63
+ try {
64
+ return (await deps.scan()).find((item) => item.port === port) ?? null;
65
+ }
66
+ catch {
67
+ return "unknown";
68
+ }
69
+ }
70
+ async function inUse(deps, port, holder) {
71
+ const { pid } = holder;
72
+ if (pid !== null && (await proxyPidsAmong(deps, [pid])).has(pid)) {
73
+ return appError("port_in_use", `port ${port} is already used by another portless proxy (pid ${pid}) that keeps its state ` +
74
+ "in a different folder. Stop that proxy, or set proxyPort to a free port.");
75
+ }
76
+ const who = [holder.processName, pid === null ? "" : `pid ${pid}`].filter(Boolean).join(", ");
77
+ return appError("port_in_use", `port ${port} is in use by another process${who === "" ? "" : ` (${who})`}.`);
78
+ }
79
+ async function proxyPidsAmong(deps, pids) {
80
+ if (!deps.proxyPids)
81
+ return new Set();
82
+ return deps.proxyPids(pids).catch(() => new Set());
83
+ }