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.
- 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 +50 -0
- package/dist/core/config/studio-config.js +2 -0
- package/dist/core/ports/helper-ports.js +86 -0
- package/dist/core/routes/env-lines.js +72 -0
- package/dist/server/application/port-discovery.js +36 -4
- package/dist/server/application/studio-runtime.js +36 -17
- package/dist/server/infrastructure/system/ephemeral-ports.js +62 -0
- package/dist/server/infrastructure/system/system-port-scanner.js +64 -4
- package/dist/server/main.js +2 -0
- package/dist/web/assets/index-9WPxv6pu.js +17 -0
- package/dist/web/assets/{index-C9wkL1J8.css → index-Bckc4cIr.css} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-BDEwEBPS.js +0 -15
package/dist/cli/cli-args.js
CHANGED
|
@@ -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
|
|
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 === "
|
|
156
|
-
?
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
: {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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" ||
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
|
151
|
-
this.#lastDetected = JSON.stringify(
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
344
|
-
// Each step reads the routes again after the previous one changed them.
|
|
345
|
-
|
|
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
|
-
|
|
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(
|
|
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) {
|