portless-studio 0.1.2 → 0.1.4

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 (30) hide show
  1. package/README.md +1 -1
  2. package/dist/cli/cli-args.js +41 -12
  3. package/dist/cli/env-command.js +34 -0
  4. package/dist/cli/index.js +19 -0
  5. package/dist/cli/mcp/studio-tools.js +86 -2
  6. package/dist/cli/studio-items.js +2 -0
  7. package/dist/core/api/contract.js +6 -1
  8. package/dist/core/config/studio-config.js +85 -0
  9. package/dist/core/ports/helper-ports.js +86 -0
  10. package/dist/core/routes/env-lines.js +72 -0
  11. package/dist/core/terminals/terminal-history.js +53 -0
  12. package/dist/core/terminals/terminal.js +0 -4
  13. package/dist/server/application/port-discovery.js +37 -4
  14. package/dist/server/application/studio-runtime.js +37 -17
  15. package/dist/server/application/terminals.js +221 -49
  16. package/dist/server/infrastructure/config/json-terminal-history-store.js +30 -0
  17. package/dist/server/infrastructure/system/ephemeral-ports.js +62 -0
  18. package/dist/server/infrastructure/system/linux-ports.js +31 -1
  19. package/dist/server/infrastructure/system/macos-ports.js +31 -2
  20. package/dist/server/infrastructure/system/system-port-scanner.js +71 -8
  21. package/dist/server/infrastructure/system/windows-ports.js +9 -1
  22. package/dist/server/interface/http/http-server.js +4 -0
  23. package/dist/server/interface/http/static-files.js +6 -2
  24. package/dist/server/main.js +10 -0
  25. package/dist/web/assets/index-CZVCj1cX.css +1 -0
  26. package/dist/web/assets/index-DODZ4DTe.js +23 -0
  27. package/dist/web/index.html +2 -2
  28. package/package.json +1 -1
  29. package/dist/web/assets/index-BDEwEBPS.js +0 -15
  30. package/dist/web/assets/index-C9wkL1J8.css +0 -1
package/README.md CHANGED
@@ -16,7 +16,7 @@ portless replaces port numbers with names: `portless api pnpm dev` serves your a
16
16
  with ten dev servers across several projects you have to remember which names exist and which
17
17
  ports have none. Studio shows both, live, and names a port in one click.
18
18
 
19
- ![Naming port 8000, searching routes with Ctrl+K, filtering with the Filter popover and running pnpm dev in a Studio terminal](docs/images/demo.gif)
19
+ ![Naming port 8000, searching routes with Ctrl+K, filtering with the Filter popover and opening a route's terminal in the right-hand pane](docs/images/demo.gif)
20
20
 
21
21
  The screenshots use the built-in sample data (`?demo`), not a real machine.
22
22
 
@@ -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",
@@ -373,7 +423,9 @@ function terminalTools(client) {
373
423
  name: "list_terminals",
374
424
  title: "List Studio terminals",
375
425
  description: "Lists the terminals Studio runs: id, title, folder, command, project, whether it still " +
376
- "runs, exit code and the port it named.",
426
+ "runs, exit code, the port it named and who started it. Terminals that were running when " +
427
+ "Studio last stopped are listed with stopped_by_restart: true; restart_terminal runs them " +
428
+ "again.",
377
429
  inputSchema: NO_INPUT,
378
430
  call: async () => {
379
431
  const result = await client.readPrivate(API_PATHS.terminals);
@@ -381,8 +433,12 @@ function terminalTools(client) {
381
433
  return failure(result.error);
382
434
  const { terminals } = result.value;
383
435
  const running = terminals.filter((terminal) => terminal.status === "running").length;
436
+ const stopped = terminals.filter((terminal) => terminal.stoppedByRestart === true).length;
437
+ const restorable = stopped > 0
438
+ ? `, ${stopped} stopped by a Studio restart: run ${stopped === 1 ? "it" : "them"} again with restart_terminal`
439
+ : "";
384
440
  return {
385
- summary: `${plural(terminals.length, "terminal")} (${running} running).`,
441
+ summary: `${plural(terminals.length, "terminal")} (${running} running${restorable}).`,
386
442
  data: terminals.map(terminalItem),
387
443
  };
388
444
  },
@@ -506,6 +562,32 @@ function terminalTools(client) {
506
562
  return { summary: `Stopping ${id.value}. list_terminals shows when it has exited.` };
507
563
  },
508
564
  },
565
+ {
566
+ name: "restart_terminal",
567
+ title: "Run a terminal's command again",
568
+ description: "Runs a terminal's command again in the same terminal: stops it first when it still runs. " +
569
+ "Use it for a terminal stopped by a Studio restart (stopped_by_restart in list_terminals) " +
570
+ "or one whose command ended.",
571
+ inputSchema: {
572
+ type: "object",
573
+ properties: { id: TERMINAL_ID_SCHEMA },
574
+ required: ["id"],
575
+ additionalProperties: false,
576
+ },
577
+ call: async (args) => {
578
+ const id = readString(args, "id");
579
+ if (!id.ok)
580
+ return invalid(id.error);
581
+ const result = await client.write("POST", API_PATHS.terminalRestart(id.value));
582
+ if (!result.ok)
583
+ return failure(result.error);
584
+ const terminal = result.value;
585
+ return {
586
+ summary: `Running ${terminal.id} again in ${terminal.cwd}: ${terminal.command || "shell"}. Read its output with read_terminal {"id": "${terminal.id}"}.`,
587
+ data: terminalItem(terminal),
588
+ };
589
+ },
590
+ },
509
591
  {
510
592
  name: "focus_studio",
511
593
  title: "Show something in Studio",
@@ -561,6 +643,8 @@ function terminalItem(terminal) {
561
643
  pid: terminal.pid,
562
644
  named: terminal.naming?.name ?? null,
563
645
  port: terminal.naming?.port ?? null,
646
+ started_by: terminal.startedBy ?? null,
647
+ stopped_by_restart: terminal.stoppedByRestart === true,
564
648
  };
565
649
  }
566
650
  async function readSnapshot(client, includeStudio = false) {
@@ -8,6 +8,7 @@ export function routeItem(route) {
8
8
  project: route.project?.name ?? null,
9
9
  worktree: route.project?.worktree ?? null,
10
10
  pid: route.listenerPid ?? route.pid,
11
+ startedAt: route.startedAt ?? null,
11
12
  };
12
13
  }
13
14
  export function detectedItem(port) {
@@ -19,6 +20,7 @@ export function detectedItem(port) {
19
20
  suggestedName: port.suggestedName,
20
21
  project: port.project?.name ?? null,
21
22
  worktree: port.project?.worktree ?? null,
23
+ startedAt: port.startedAt ?? null,
22
24
  };
23
25
  }
24
26
  export function proxyItem(proxy) {
@@ -51,6 +51,8 @@ export const API_PATHS = {
51
51
  * Studio shows.
52
52
  */
53
53
  terminalsInFolder: "/api/terminals/in-folder",
54
+ /** GET returns `RecentCommandsResponse`: the last commands run in each folder. */
55
+ terminalsRecentCommands: "/api/terminals/recent-commands",
54
56
  /**
55
57
  * GET returns `TerminalInfo`; PATCH `RenameTerminalRequest` renames it; DELETE stops the command
56
58
  * if it runs and closes the terminal.
@@ -67,7 +69,10 @@ export const API_PATHS = {
67
69
  terminalInput: (id) => `/api/terminals/${encodeURIComponent(id)}/input`,
68
70
  /** POST: Ctrl+C, then SIGTERM, then SIGKILL until the command ends. */
69
71
  terminalStop: (id) => `/api/terminals/${encodeURIComponent(id)}/stop`,
70
- /** POST: runs the same command again in the same terminal. */
72
+ /**
73
+ * POST: runs the same command again in the same terminal; also starts a terminal that was
74
+ * stopped by a Studio restart.
75
+ */
71
76
  terminalRestart: (id) => `/api/terminals/${encodeURIComponent(id)}/restart`,
72
77
  /** POST `FocusRequest`: brings a Studio page to the front showing a route, port or terminal. */
73
78
  focus: "/api/focus",
@@ -2,6 +2,14 @@ import { DEFAULT_NAME_TEMPLATE, parseNameTemplate } from "../ports/name-template
2
2
  import { projectIconKind } from "../projects/project-icon.js";
3
3
  import { err, ok } from "../result.js";
4
4
  import { DEFAULT_VIEW, isItemKey, parseViews } from "../views/view.js";
5
+ export const DEFAULT_TERMINAL_APPEARANCE = {
6
+ fontSize: 12,
7
+ lineHeight: 1.2,
8
+ cursorStyle: "block",
9
+ cursorBlink: true,
10
+ scrollback: 10_000,
11
+ theme: "studio",
12
+ };
5
13
  export const DEFAULT_CONFIG = {
6
14
  port: 4949,
7
15
  autoName: false,
@@ -38,6 +46,10 @@ export const DEFAULT_CONFIG = {
38
46
  checkForUpdates: true,
39
47
  shortUrls: false,
40
48
  showStudioRoute: false,
49
+ showEphemeralPorts: false,
50
+ terminal: DEFAULT_TERMINAL_APPEARANCE,
51
+ restoreTerminals: "ask",
52
+ defaultEditor: null,
41
53
  };
42
54
  const MIN_SCAN_INTERVAL_MS = 1000;
43
55
  /**
@@ -61,6 +73,7 @@ export function parseConfig(input) {
61
73
  "checkForUpdates",
62
74
  "shortUrls",
63
75
  "showStudioRoute",
76
+ "showEphemeralPorts",
64
77
  ]) {
65
78
  const value = raw[key];
66
79
  if (value === undefined)
@@ -145,8 +158,80 @@ export function parseConfig(input) {
145
158
  return err(`"nameTemplate" ${parsed.error}`);
146
159
  config.nameTemplate = template;
147
160
  }
161
+ if (raw.terminal !== undefined) {
162
+ const terminal = parseTerminalAppearance(raw.terminal);
163
+ if (!terminal.ok)
164
+ return terminal;
165
+ config.terminal = terminal.value;
166
+ }
167
+ if (raw.restoreTerminals !== undefined) {
168
+ const value = raw.restoreTerminals;
169
+ if (value !== "ask" && value !== "always" && value !== "never") {
170
+ return err(`"restoreTerminals" must be "ask", "always" or "never".`);
171
+ }
172
+ config.restoreTerminals = value;
173
+ }
174
+ if (raw.defaultEditor !== undefined) {
175
+ const value = raw.defaultEditor;
176
+ if (value !== null && (typeof value !== "string" || !APP_ID.test(value))) {
177
+ return err(`"defaultEditor" must be the id of an app in Open with, like "vscode", or null.`);
178
+ }
179
+ config.defaultEditor = value;
180
+ }
148
181
  return ok(config);
149
182
  }
183
+ /** The shape of open-with app ids: `vscode`, `vscode-insiders`, `custom-1`. */
184
+ const APP_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
185
+ function parseTerminalAppearance(input) {
186
+ if (typeof input !== "object" || input === null || Array.isArray(input)) {
187
+ return err(`"terminal" must be an object.`);
188
+ }
189
+ const raw = input;
190
+ const terminal = { ...DEFAULT_TERMINAL_APPEARANCE };
191
+ if (raw.fontSize !== undefined) {
192
+ if (!isWholeBetween(raw.fontSize, 10, 24)) {
193
+ return err(`"terminal.fontSize" must be a whole number from 10 to 24.`);
194
+ }
195
+ terminal.fontSize = raw.fontSize;
196
+ }
197
+ if (raw.lineHeight !== undefined) {
198
+ const value = raw.lineHeight;
199
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 1 || value > 2) {
200
+ return err(`"terminal.lineHeight" must be a number from 1 to 2.`);
201
+ }
202
+ terminal.lineHeight = value;
203
+ }
204
+ if (raw.cursorStyle !== undefined) {
205
+ const value = raw.cursorStyle;
206
+ if (value !== "block" && value !== "bar" && value !== "underline") {
207
+ return err(`"terminal.cursorStyle" must be "block", "bar" or "underline".`);
208
+ }
209
+ terminal.cursorStyle = value;
210
+ }
211
+ if (raw.cursorBlink !== undefined) {
212
+ if (typeof raw.cursorBlink !== "boolean") {
213
+ return err(`"terminal.cursorBlink" must be true or false.`);
214
+ }
215
+ terminal.cursorBlink = raw.cursorBlink;
216
+ }
217
+ if (raw.scrollback !== undefined) {
218
+ if (!isWholeBetween(raw.scrollback, 1000, 100_000)) {
219
+ return err(`"terminal.scrollback" must be a whole number from 1000 to 100000.`);
220
+ }
221
+ terminal.scrollback = raw.scrollback;
222
+ }
223
+ if (raw.theme !== undefined) {
224
+ const value = raw.theme;
225
+ if (value !== "studio" && value !== "dark" && value !== "light") {
226
+ return err(`"terminal.theme" must be "studio", "dark" or "light".`);
227
+ }
228
+ terminal.theme = value;
229
+ }
230
+ return ok(terminal);
231
+ }
232
+ function isWholeBetween(value, min, max) {
233
+ return typeof value === "number" && Number.isInteger(value) && value >= min && value <= max;
234
+ }
150
235
  /** Empty names and values are dropped, so clearing a field in the panel removes its icon. */
151
236
  function parseProjectIcons(value) {
152
237
  if (typeof value !== "object" || value === null || Array.isArray(value))
@@ -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
+ }