portless-studio 0.1.2 → 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.
@@ -17,6 +17,10 @@ Usage:
17
17
  what is wrong (exit code 1 when something fails)
18
18
  portless-studio list [--json] List named routes and ports without a name, from the running
19
19
  Studio (or read directly when it is not running)
20
+ portless-studio env [--prefix P] [--strip S] [--direct] [--json] [name...]
21
+ Print KEY=url lines for a .env file, one per route
22
+ (api.acme -> API_ACME_URL); a name also picks its *.name
23
+ routes (docs/cli.md)
20
24
  portless-studio mcp Run an MCP server on stdio so AI agents can list, name and
21
25
  stop dev servers through the running Studio (docs/mcp.md)
22
26
  portless-studio run [--name <name>] [--cwd <dir>] -- <command...>
@@ -32,7 +36,10 @@ Options:
32
36
  --no-open Do not open the browser
33
37
  --no-register Do not register the "studio" portless alias for the dashboard
34
38
  -y, --yes With init and uninstall: accept every default without asking
35
- --json With doctor, config and list: print JSON for scripts and agents
39
+ --json With doctor, config, list and env: print JSON for scripts and agents
40
+ --prefix <p> With env: put p in front of every key, e.g. VITE_ or NEXT_PUBLIC_
41
+ --strip <s> With env: drop the .s suffix from names before they become keys
42
+ --direct With env: use http://localhost:<port> instead of the named URL
36
43
  --name <name> With run: name for the port the command opens (default: the suggested one)
37
44
  --cwd <dir> With run: folder to run in (default: the current folder)
38
45
  -h, --help Show this help
@@ -52,6 +59,7 @@ const SUBCOMMANDS = new Set([
52
59
  "config",
53
60
  "doctor",
54
61
  "list",
62
+ "env",
55
63
  "mcp",
56
64
  "run",
57
65
  "open",
@@ -91,6 +99,16 @@ export function parseCliArgs(argv) {
91
99
  return ok({ command: "doctor", json });
92
100
  if (subcommand === "list")
93
101
  return ok({ command: "list", json });
102
+ if (subcommand === "env") {
103
+ return ok({
104
+ command: "env",
105
+ json,
106
+ direct: values.direct === true,
107
+ prefix: values.prefix?.trim() || null,
108
+ strip: values.strip?.trim() || null,
109
+ names: positionals.map((name) => name.trim()).filter((name) => name !== ""),
110
+ });
111
+ }
94
112
  if (subcommand === "mcp")
95
113
  return ok({ command: "mcp" });
96
114
  if (subcommand === "run") {
@@ -152,23 +170,34 @@ function parseFlags(args, subcommand) {
152
170
  ? { ...common, edit: { type: "boolean" }, json }
153
171
  : subcommand === "doctor" || subcommand === "list"
154
172
  ? { ...common, json }
155
- : subcommand === "service" || subcommand === "mcp" || subcommand === "open"
156
- ? common
157
- : subcommand === "run"
158
- ? { ...common, name: { type: "string" }, cwd: { type: "string" } }
159
- : {
160
- ...common,
161
- port: { type: "string" },
162
- open: { type: "boolean" },
163
- register: { type: "boolean" },
164
- };
173
+ : subcommand === "env"
174
+ ? {
175
+ ...common,
176
+ json,
177
+ direct: { type: "boolean" },
178
+ prefix: { type: "string" },
179
+ strip: { type: "string" },
180
+ }
181
+ : subcommand === "service" || subcommand === "mcp" || subcommand === "open"
182
+ ? common
183
+ : subcommand === "run"
184
+ ? { ...common, name: { type: "string" }, cwd: { type: "string" } }
185
+ : {
186
+ ...common,
187
+ port: { type: "string" },
188
+ open: { type: "boolean" },
189
+ register: { type: "boolean" },
190
+ };
165
191
  // parseArgs learned `allowNegative` in Node 22.4; reading `--no-<flag>` here keeps every Node 22.
166
192
  const { args: positive, negated } = splitNegations(args, options);
167
193
  const { values, positionals, tokens } = parseArgs({
168
194
  args: positive,
169
195
  options,
170
196
  tokens: true,
171
- allowPositionals: subcommand === "service" || subcommand === "run" || subcommand === "open",
197
+ allowPositionals: subcommand === "service" ||
198
+ subcommand === "run" ||
199
+ subcommand === "open" ||
200
+ subcommand === "env",
172
201
  });
173
202
  for (const token of tokens) {
174
203
  if (token.kind === "option" && options[token.name]?.type === "boolean") {
@@ -0,0 +1,34 @@
1
+ import { envEntries, formatEnvLine, selectEnvRoutes, } from "../core/routes/env-lines.js";
2
+ /** `portless-studio env`: `KEY=url` lines for a `.env` file, one per named route. */
3
+ export async function runEnvCommand(options) {
4
+ const fromStudio = await options.readFromStudio();
5
+ const routes = fromStudio.ok ? fromStudio.value : await options.readLocally();
6
+ const selected = selectEnvRoutes(routes, options.names);
7
+ if (!selected.ok)
8
+ return fail(options, selected.error);
9
+ const entries = envEntries(selected.value, {
10
+ direct: options.direct,
11
+ ...(options.prefix === null ? {} : { prefix: options.prefix }),
12
+ ...(options.strip === null ? {} : { strip: options.strip }),
13
+ });
14
+ if (!entries.ok)
15
+ return fail(options, entries.error);
16
+ if (options.json) {
17
+ const source = fromStudio.ok ? "studio" : "local";
18
+ options.print(JSON.stringify({ source, variables: entries.value }, null, 2));
19
+ return 0;
20
+ }
21
+ if (!fromStudio.ok) {
22
+ options.printError("(Studio is not running: read directly from portless.)");
23
+ }
24
+ if (entries.value.length === 0) {
25
+ options.printError("No named routes yet.");
26
+ return 0;
27
+ }
28
+ options.print(entries.value.map(formatEnvLine).join("\n"));
29
+ return 0;
30
+ }
31
+ function fail(options, message) {
32
+ options.printError(`Error: ${message}`);
33
+ return 1;
34
+ }
package/dist/cli/index.js CHANGED
@@ -11,6 +11,7 @@ import { HELP, parseCliArgs, resolveStartSettings, } from "./cli-args.js";
11
11
  import { runCommand } from "./command-runner.js";
12
12
  import { editConfig, printConfig, printConfigJson } from "./config-command.js";
13
13
  import { runDoctorCommand } from "./doctor-command.js";
14
+ import { runEnvCommand } from "./env-command.js";
14
15
  import { runOpenCommand } from "./focus-command.js";
15
16
  import { runInit } from "./init.js";
16
17
  import { runListCommand } from "./list-command.js";
@@ -65,6 +66,9 @@ async function main() {
65
66
  case "list":
66
67
  await list(options.json);
67
68
  return;
69
+ case "env":
70
+ await env(options);
71
+ return;
68
72
  case "mcp":
69
73
  await mcp();
70
74
  return;
@@ -125,6 +129,21 @@ async function list(json) {
125
129
  // The local fallback may leave scan timers behind; the list is all this command does.
126
130
  process.exit(code);
127
131
  }
132
+ async function env(options) {
133
+ const client = await studioClient();
134
+ const code = await runEnvCommand({
135
+ ...options,
136
+ print,
137
+ printError: (line) => console.error(line),
138
+ readFromStudio: async () => {
139
+ const snapshot = await client.read(API_PATHS.snapshot);
140
+ return snapshot.ok ? { ok: true, value: snapshot.value.routes } : snapshot;
141
+ },
142
+ readLocally: async () => (await createStudioServices().registry.readSnapshot()).routes,
143
+ });
144
+ // The local fallback may leave watchers behind; the lines are all this command prints.
145
+ process.exit(code);
146
+ }
128
147
  /** stdout carries only protocol messages; warnings go to stderr through `console.error`. */
129
148
  async function mcp() {
130
149
  const client = await studioClient();
@@ -1,5 +1,6 @@
1
1
  import { API_PATHS, INCLUDE_STUDIO_PARAM, } from "../../core/api/contract.js";
2
2
  import { err, ok } from "../../core/result.js";
3
+ import { envEntries, formatEnvLine, selectEnvRoutes } from "../../core/routes/env-lines.js";
3
4
  import { plainText } from "../../core/terminals/terminal.js";
4
5
  import { describeProxy, detectedItem, proxyItem, routeItem } from "../studio-items.js";
5
6
  const NAMING_HINT = "Make each name say what the work is: start it with the ticket key, PR number or a short task " +
@@ -15,6 +16,7 @@ export const AGENT_INSTRUCTIONS = [
15
16
  "Start dev servers with run_in_terminal (or `portless-studio run -- <command>` in a shell) instead",
16
17
  "of in your own shell, so the user can watch their output in Studio; read it with read_terminal.",
17
18
  "When the user asks to see something, call focus_studio.",
19
+ "To point an app at another one by name in a .env file, call env_lines and write its lines.",
18
20
  NAMING_HINT,
19
21
  ].join(" ");
20
22
  const PROXY_ACTIONS = ["start", "stop", "restart"];
@@ -61,6 +63,54 @@ export function createStudioTools(client) {
61
63
  };
62
64
  },
63
65
  },
66
+ {
67
+ name: "env_lines",
68
+ title: "Build .env lines from route names",
69
+ description: "Returns KEY=url lines for a .env file, one per route: api.acme becomes " +
70
+ "API_ACME_URL=http://api.acme.localhost:1355, a URL that keeps working when the port " +
71
+ "changes. names keeps only those routes (acme also keeps every *.acme route); prefix puts " +
72
+ "VITE_ or NEXT_PUBLIC_ in front; strip drops a suffix like the project (acme turns api.acme " +
73
+ "into API_URL); direct uses http://localhost:<port> instead. It only returns text: write " +
74
+ "the lines into the user's .env.local yourself when they ask.",
75
+ inputSchema: {
76
+ type: "object",
77
+ properties: {
78
+ names: {
79
+ type: "array",
80
+ items: { type: "string" },
81
+ description: 'Route names to keep, e.g. ["acme"]; all routes when absent',
82
+ },
83
+ prefix: { type: "string", description: "Put in front of every key, e.g. VITE_" },
84
+ strip: { type: "string", description: "Suffix dropped from names, e.g. acme" },
85
+ direct: { type: "boolean", description: "Use http://localhost:<port> (default false)" },
86
+ },
87
+ additionalProperties: false,
88
+ },
89
+ call: async (args) => {
90
+ const names = Array.isArray(args.names)
91
+ ? args.names.filter((name) => typeof name === "string" && name !== "")
92
+ : [];
93
+ const snapshot = await readSnapshot(client);
94
+ if (!snapshot.ok)
95
+ return snapshot.error;
96
+ const selected = selectEnvRoutes(snapshot.value.routes, names);
97
+ if (!selected.ok)
98
+ return { summary: selected.error, isError: true };
99
+ const entries = envEntries(selected.value, {
100
+ direct: args.direct === true,
101
+ ...(typeof args.prefix === "string" ? { prefix: args.prefix.trim() } : {}),
102
+ ...(typeof args.strip === "string" ? { strip: args.strip } : {}),
103
+ });
104
+ if (!entries.ok)
105
+ return { summary: entries.error, isError: true };
106
+ if (entries.value.length === 0)
107
+ return { summary: "No named routes yet.", data: { variables: [] } };
108
+ return {
109
+ summary: entries.value.map(formatEnvLine).join("\n"),
110
+ data: { variables: entries.value },
111
+ };
112
+ },
113
+ },
64
114
  {
65
115
  name: "list_detected_ports",
66
116
  title: "List ports without a name",
@@ -38,6 +38,7 @@ export const DEFAULT_CONFIG = {
38
38
  checkForUpdates: true,
39
39
  shortUrls: false,
40
40
  showStudioRoute: false,
41
+ showEphemeralPorts: false,
41
42
  };
42
43
  const MIN_SCAN_INTERVAL_MS = 1000;
43
44
  /**
@@ -61,6 +62,7 @@ export function parseConfig(input) {
61
62
  "checkForUpdates",
62
63
  "shortUrls",
63
64
  "showStudioRoute",
65
+ "showEphemeralPorts",
64
66
  ]) {
65
67
  const value = raw[key];
66
68
  if (value === undefined)
@@ -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
+ }
@@ -1,10 +1,16 @@
1
1
  import { projectOf, suggestName, } from "../../core/ports/detected-port.js";
2
2
  import { containerProjectInfo } from "../../core/ports/docker-container.js";
3
+ import { arrangeDetected, } from "../../core/ports/helper-ports.js";
4
+ /** Only the main Detected list of `discoverPortGroups`. */
5
+ export async function discoverPorts(scanner, projects, context) {
6
+ return (await discoverPortGroups(scanner, projects, context)).detected;
7
+ }
3
8
  /**
4
9
  * Listening ports a person would want to name: they answer HTTP, have no portless route, and are
5
- * not Studio, the proxy or something the config ignores. Sorted by port.
10
+ * not Studio, the proxy or something the config ignores. Debuggers, ephemeral ports and ports of a
11
+ * server that already has a name go to `otherPorts` (see `arrangeDetected`). Sorted by port.
6
12
  */
7
- export async function discoverPorts(scanner, projects, context) {
13
+ export async function discoverPortGroups(scanner, projects, context) {
8
14
  const skippedPorts = new Set([
9
15
  ...context.routes.map((route) => route.port),
10
16
  ...context.excludedPorts,
@@ -14,7 +20,8 @@ export async function discoverPorts(scanner, projects, context) {
14
20
  const published = containerPorts(context.containers ?? [], skippedPorts, ignored);
15
21
  // A port whose process we cannot read belongs to another user or the system (printing, DNS),
16
22
  // never to a dev server this person started. A container's port is shown as the container.
17
- const candidates = (await scanner.scan()).filter((port) => port.pid !== null &&
23
+ const scanned = await scanner.scan();
24
+ const candidates = scanned.filter((port) => port.pid !== null &&
18
25
  !skippedPorts.has(port.port) &&
19
26
  !published.has(port.port) &&
20
27
  !isIgnoredProcess(port.processName, ignored));
@@ -25,7 +32,7 @@ export async function discoverPorts(scanner, projects, context) {
25
32
  Promise.all(fromContainers.map(([port]) => scanner.answersHttp(port))),
26
33
  ]);
27
34
  const template = context.config.nameTemplate;
28
- return [
35
+ const found = [
29
36
  ...candidates
30
37
  .filter((port, index) => answers[index] && isDevServer(port, projects))
31
38
  .map((port) => toDetected(port, projects, template)),
@@ -33,6 +40,31 @@ export async function discoverPorts(scanner, projects, context) {
33
40
  .filter((_, index) => containerAnswers[index])
34
41
  .map(([port, container]) => containerDetected(port, container, template)),
35
42
  ].sort((a, b) => a.port - b.port);
43
+ return arrangeDetected({
44
+ detected: found,
45
+ debuggers: await debuggerProbes(scanner, found),
46
+ ephemeralRange: context.ephemeralRange ?? null,
47
+ showEphemeral: context.config.showEphemeralPorts,
48
+ routes: listeningRoutes(context.routes, scanned),
49
+ });
50
+ }
51
+ const UNKNOWN = "unknown";
52
+ async function debuggerProbes(scanner, found) {
53
+ const probe = scanner.probeDebugger?.bind(scanner);
54
+ if (!probe)
55
+ return new Map();
56
+ const answers = await Promise.all(found.map((item) => probe(item.port).catch(() => UNKNOWN)));
57
+ return new Map(found.map((item, index) => [item.port, answers[index] ?? UNKNOWN]));
58
+ }
59
+ /** Routes whose port a readable process listens on now; a closed route claims no siblings. */
60
+ function listeningRoutes(routes, scanned) {
61
+ const byPort = new Map(scanned.map((port) => [port.port, port]));
62
+ return routes.flatMap((route) => {
63
+ const listener = byPort.get(route.port);
64
+ if (!listener || listener.pid === null)
65
+ return [];
66
+ return [{ name: route.name, port: route.port, pid: listener.pid, cwd: listener.cwd }];
67
+ });
36
68
  }
37
69
  /** Published port -> container, without the ports the filters skip. */
38
70
  function containerPorts(containers, skippedPorts, ignored) {
@@ -8,7 +8,7 @@ import { appError } from "./app-error.js";
8
8
  import { memoryAutoNamedStore, } from "./auto-named-store.js";
9
9
  import { AutoNamer } from "./auto-namer.js";
10
10
  import { IconResolver } from "./icon-resolver.js";
11
- import { discoverPorts } from "./port-discovery.js";
11
+ import { discoverPortGroups } from "./port-discovery.js";
12
12
  import { EventLog, eventsBetween } from "./studio-events.js";
13
13
  import { SYSTEM_NOTIFICATION_KINDS } from "./system-notifier.js";
14
14
  /** Keeps the projects in memory only; for tests and for `init`, which runs once. */
@@ -43,6 +43,7 @@ export class StudioRuntime {
43
43
  #icons;
44
44
  #routeProjectStore;
45
45
  #containers;
46
+ #ephemeralRange;
46
47
  #shortUrls;
47
48
  #ready = Promise.resolve();
48
49
  /** Last project seen per route name; null until read from the store. */
@@ -85,6 +86,7 @@ export class StudioRuntime {
85
86
  this.#notifier = options.notifier ?? null;
86
87
  this.#routeProjectStore = options.routeProjectStore ?? memoryRouteProjectStore();
87
88
  this.#containers = options.containers ?? null;
89
+ this.#ephemeralRange = options.ephemeralRange ?? null;
88
90
  this.#shortUrls = options.shortUrls ?? null;
89
91
  this.#shortUrls?.onChange(() => this.#notify());
90
92
  const noIcons = {
@@ -147,14 +149,15 @@ export class StudioRuntime {
147
149
  async readSnapshot(options = {}) {
148
150
  const state = await this.#registry.readSnapshot();
149
151
  const config = await this.#currentConfig();
150
- const detected = await this.#detect(state, config, false);
151
- this.#lastDetected = JSON.stringify(detected);
152
+ const found = await this.#detect(state, config, false);
153
+ this.#lastDetected = JSON.stringify(found);
152
154
  const routes = await this.#detailedRoutes(state.routes);
153
155
  const snapshot = {
154
156
  proxy: state.proxy,
155
157
  portless: state.portless,
156
158
  routes,
157
- detected,
159
+ detected: found.detected,
160
+ otherPorts: found.otherPorts,
158
161
  autoName: config.autoName,
159
162
  readAt: state.readAt,
160
163
  };
@@ -183,7 +186,7 @@ export class StudioRuntime {
183
186
  /** One fresh scan with the current config, filtered the same way as the snapshot. */
184
187
  async detectPorts() {
185
188
  const state = await this.#registry.readSnapshot();
186
- return this.#detect(state, await this.#currentConfig(), true);
189
+ return (await this.#detect(state, await this.#currentConfig(), true)).detected;
187
190
  }
188
191
  /** The config in effect: the file as last read, or the defaults while it is broken. */
189
192
  settings() {
@@ -195,11 +198,12 @@ export class StudioRuntime {
195
198
  */
196
199
  async stoppablePids() {
197
200
  const state = await this.#registry.readSnapshot();
198
- const detected = await this.#detect(state, await this.#currentConfig(), true);
201
+ const found = await this.#detect(state, await this.#currentConfig(), true);
199
202
  const pids = new Map();
200
- for (const item of detected)
203
+ for (const item of [...found.detected, ...found.otherPorts]) {
201
204
  if (item.pid !== null)
202
205
  pids.set(item.pid, item.port);
206
+ }
203
207
  const pidByPort = this.#pidByPort();
204
208
  for (const route of state.routes) {
205
209
  const pid = pidByPort.get(route.port);
@@ -215,7 +219,8 @@ export class StudioRuntime {
215
219
  */
216
220
  async openableFolders() {
217
221
  const state = await this.#registry.readSnapshot();
218
- const detected = await this.#detect(state, await this.#currentConfig(), false);
222
+ const found = await this.#detect(state, await this.#currentConfig(), false);
223
+ const detected = [...found.detected, ...found.otherPorts];
219
224
  const routes = await this.#detailedRoutes(state.routes);
220
225
  const folders = new Set();
221
226
  for (const item of [...routes, ...detected]) {
@@ -278,9 +283,11 @@ export class StudioRuntime {
278
283
  }
279
284
  async #detect(state, config, fresh) {
280
285
  const scan = await this.#scanPorts(fresh ? 0 : config.scanIntervalMs);
286
+ const probeDebugger = this.#scanner.probeDebugger?.bind(this.#scanner);
281
287
  const cached = {
282
288
  scan: async () => scan.ports,
283
289
  answersHttp: (port) => this.#scanner.answersHttp(port),
290
+ ...(probeDebugger ? { probeDebugger } : {}),
284
291
  };
285
292
  const forwarderPort = this.#shortUrls?.publicPort() ?? null;
286
293
  const excludedPorts = [
@@ -289,18 +296,23 @@ export class StudioRuntime {
289
296
  state.proxy.port,
290
297
  ...scan.proxyPorts,
291
298
  ].filter((port) => port !== null);
292
- const found = await discoverPorts(cached, this.#projects, {
299
+ const found = await discoverPortGroups(cached, this.#projects, {
293
300
  config,
294
301
  routes: state.routes,
295
302
  excludedPorts,
296
303
  containers: scan.containers,
304
+ ephemeralRange: await this.#readEphemeralRange(),
297
305
  });
298
- return found.map((item) => {
306
+ const withProject = (item) => {
299
307
  if (item.cwd === null || !item.project)
300
308
  return item;
301
309
  const project = itemProjectAt(this.#projects.read(item.cwd), item.cwd);
302
310
  return project === null ? item : { ...item, project };
303
- });
311
+ };
312
+ return {
313
+ detected: found.detected.map(withProject),
314
+ otherPorts: found.otherPorts.map(withProject),
315
+ };
304
316
  }
305
317
  /** Reuses a scan younger than `maxAgeMs`, and shares one scan between concurrent callers. */
306
318
  #scanPorts(maxAgeMs) {
@@ -314,6 +326,12 @@ export class StudioRuntime {
314
326
  });
315
327
  return this.#scanning;
316
328
  }
329
+ /** A range that cannot be read turns the ephemeral rule off rather than failing the scan. */
330
+ async #readEphemeralRange() {
331
+ if (!this.#ephemeralRange)
332
+ return null;
333
+ return this.#ephemeralRange().catch(() => null);
334
+ }
317
335
  /** Docker is optional: any failure to read it means no containers, never a failed scan. */
318
336
  async #readContainers() {
319
337
  if (!this.#containers)
@@ -340,19 +358,20 @@ export class StudioRuntime {
340
358
  try {
341
359
  let state = await this.#registry.readSnapshot();
342
360
  const config = await this.#currentConfig();
343
- let detected = await this.#detect(state, config, true);
344
- // Each step reads the routes again after the previous one changed them.
345
- if (config.autoName && (await this.#namer.name(detected)) > 0) {
361
+ const first = await this.#detect(state, config, true);
362
+ // Each step reads the routes again after the previous one changed them. Other ports are
363
+ // helpers of a named server, debuggers or temporary ports: auto mode never names them.
364
+ if (config.autoName && (await this.#namer.name(first.detected)) > 0) {
346
365
  state = await this.#registry.readSnapshot();
347
366
  }
348
367
  if (await this.#removeAbandonedAutoNames(state, config)) {
349
368
  state = await this.#registry.readSnapshot();
350
369
  }
351
- detected = await this.#detect(state, config, false);
370
+ const found = await this.#detect(state, config, false);
352
371
  const healthChanged = await this.#probeRoutes(state.routes);
353
372
  const routes = await this.#detailedRoutes(state.routes);
354
- this.#observe({ ...state, routes, detected }, config);
355
- if (healthChanged || JSON.stringify(detected) !== this.#lastDetected)
373
+ this.#observe({ ...state, routes, detected: found.detected }, config);
374
+ if (healthChanged || JSON.stringify(found) !== this.#lastDetected)
356
375
  this.#notify();
357
376
  }
358
377
  catch (error) {