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.
- package/README.md +1 -1
- 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 +86 -2
- package/dist/cli/studio-items.js +2 -0
- package/dist/core/api/contract.js +6 -1
- package/dist/core/config/studio-config.js +85 -0
- package/dist/core/ports/helper-ports.js +86 -0
- package/dist/core/routes/env-lines.js +72 -0
- package/dist/core/terminals/terminal-history.js +53 -0
- package/dist/core/terminals/terminal.js +0 -4
- package/dist/server/application/port-discovery.js +37 -4
- package/dist/server/application/studio-runtime.js +37 -17
- package/dist/server/application/terminals.js +221 -49
- package/dist/server/infrastructure/config/json-terminal-history-store.js +30 -0
- package/dist/server/infrastructure/system/ephemeral-ports.js +62 -0
- package/dist/server/infrastructure/system/linux-ports.js +31 -1
- package/dist/server/infrastructure/system/macos-ports.js +31 -2
- package/dist/server/infrastructure/system/system-port-scanner.js +71 -8
- package/dist/server/infrastructure/system/windows-ports.js +9 -1
- package/dist/server/interface/http/http-server.js +4 -0
- package/dist/server/interface/http/static-files.js +6 -2
- package/dist/server/main.js +10 -0
- package/dist/web/assets/index-CZVCj1cX.css +1 -0
- package/dist/web/assets/index-DODZ4DTe.js +23 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-BDEwEBPS.js +0 -15
- 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
|
-

|
|
20
20
|
|
|
21
21
|
The screenshots use the built-in sample data (`?demo`), not a real machine.
|
|
22
22
|
|
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",
|
|
@@ -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
|
|
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) {
|
package/dist/cli/studio-items.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
+
}
|