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
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { parseTerminalHistory, } from "../../../core/terminals/terminal-history.js";
|
|
4
|
+
import { writeFileAtomic } from "./atomic-write.js";
|
|
5
|
+
const FILE_NAME = "terminals.json";
|
|
6
|
+
/** `terminals.json` in the config file's folder. */
|
|
7
|
+
export function terminalHistoryPathFor(configPath) {
|
|
8
|
+
return path.join(path.dirname(configPath), FILE_NAME);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The terminals running when Studio stopped and the recent commands per folder, as
|
|
12
|
+
* `{ "running": [...], "recentCommands": { "<folder>": [...] } }`. Studio writes it on its own.
|
|
13
|
+
*/
|
|
14
|
+
export class JsonTerminalHistoryStore {
|
|
15
|
+
path;
|
|
16
|
+
constructor(filePath) {
|
|
17
|
+
this.path = filePath;
|
|
18
|
+
}
|
|
19
|
+
read() {
|
|
20
|
+
try {
|
|
21
|
+
return parseTerminalHistory(JSON.parse(fs.readFileSync(this.path, "utf8")));
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return parseTerminalHistory(null);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
write(history) {
|
|
28
|
+
writeFileAtomic(this.path, `${JSON.stringify(history, null, 2)}\n`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { DEFAULT_EPHEMERAL_RANGES } from "../../../core/ports/helper-ports.js";
|
|
2
|
+
/** Reads `/proc/sys/net/ipv4/ip_local_port_range`: two numbers separated by whitespace. */
|
|
3
|
+
export function parseLinuxPortRange(text) {
|
|
4
|
+
const [first, last] = text.trim().split(/\s+/).map(Number);
|
|
5
|
+
return validRange(first, last);
|
|
6
|
+
}
|
|
7
|
+
/** Reads `sysctl -n net.inet.ip.portrange.first net.inet.ip.portrange.last`: one number per line. */
|
|
8
|
+
export function parseSysctlPortRange(text) {
|
|
9
|
+
return parseLinuxPortRange(text);
|
|
10
|
+
}
|
|
11
|
+
/** Reads `netsh int ipv4 show dynamicport tcp`: a start port and a number of ports. */
|
|
12
|
+
export function parseNetshDynamicPorts(text) {
|
|
13
|
+
const numbers = [...text.matchAll(/:\s*(\d+)/g)].map((match) => Number(match[1]));
|
|
14
|
+
const [start, count] = numbers;
|
|
15
|
+
if (start === undefined || count === undefined || count < 1)
|
|
16
|
+
return null;
|
|
17
|
+
return validRange(start, start + count - 1);
|
|
18
|
+
}
|
|
19
|
+
function validRange(first, last) {
|
|
20
|
+
if (first === undefined || last === undefined)
|
|
21
|
+
return null;
|
|
22
|
+
if (!Number.isInteger(first) || !Number.isInteger(last))
|
|
23
|
+
return null;
|
|
24
|
+
if (first < 1 || last > 65535 || first > last)
|
|
25
|
+
return null;
|
|
26
|
+
return { first, last };
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The range the system hands out for port 0, read once and kept: it only changes when someone
|
|
30
|
+
* edits a kernel setting. Falls back to the platform default when it cannot be read.
|
|
31
|
+
*/
|
|
32
|
+
export function ephemeralRangeReader(platform, io) {
|
|
33
|
+
let reading = null;
|
|
34
|
+
return () => {
|
|
35
|
+
reading ??= readEphemeralRange(platform, io).catch(() => fallbackRange(platform));
|
|
36
|
+
return reading;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
async function readEphemeralRange(platform, io) {
|
|
40
|
+
let range = null;
|
|
41
|
+
if (platform === "linux") {
|
|
42
|
+
range = parseLinuxPortRange(io.readFile("/proc/sys/net/ipv4/ip_local_port_range") ?? "");
|
|
43
|
+
}
|
|
44
|
+
else if (platform === "darwin") {
|
|
45
|
+
const out = await io.run("sysctl", [
|
|
46
|
+
"-n",
|
|
47
|
+
"net.inet.ip.portrange.first",
|
|
48
|
+
"net.inet.ip.portrange.last",
|
|
49
|
+
]);
|
|
50
|
+
range = parseSysctlPortRange(out.stdout);
|
|
51
|
+
}
|
|
52
|
+
else if (platform === "win32") {
|
|
53
|
+
const out = await io.run("netsh", ["int", "ipv4", "show", "dynamicport", "tcp"]);
|
|
54
|
+
range = parseNetshDynamicPorts(out.stdout);
|
|
55
|
+
}
|
|
56
|
+
return range ?? fallbackRange(platform);
|
|
57
|
+
}
|
|
58
|
+
function fallbackRange(platform) {
|
|
59
|
+
if (platform === "darwin" || platform === "win32")
|
|
60
|
+
return DEFAULT_EPHEMERAL_RANGES[platform];
|
|
61
|
+
return DEFAULT_EPHEMERAL_RANGES.linux;
|
|
62
|
+
}
|
|
@@ -19,22 +19,51 @@ export function parseProcNetTcp(text) {
|
|
|
19
19
|
}
|
|
20
20
|
return sockets;
|
|
21
21
|
}
|
|
22
|
+
/** Clock ticks per second, the unit of `/proc/<pid>/stat` times; 100 on every common kernel. */
|
|
23
|
+
export const DEFAULT_CLOCK_TICKS = 100;
|
|
24
|
+
/**
|
|
25
|
+
* When a process started, in epoch milliseconds, from field 22 of `/proc/<pid>/stat` (clock ticks
|
|
26
|
+
* since boot) and `btime` in `/proc/stat` (boot time in epoch seconds). Null when unreadable.
|
|
27
|
+
*/
|
|
28
|
+
export function processStartTime(stat, procStat, ticksPerSecond) {
|
|
29
|
+
if (stat === null || procStat === null || ticksPerSecond <= 0)
|
|
30
|
+
return null;
|
|
31
|
+
// The command name in field 2 may hold spaces and parentheses, so count fields after the last ")".
|
|
32
|
+
const fields = stat
|
|
33
|
+
.slice(stat.lastIndexOf(")") + 1)
|
|
34
|
+
.trim()
|
|
35
|
+
.split(/\s+/);
|
|
36
|
+
const ticks = Number(fields[19]);
|
|
37
|
+
const boot = Number(/^btime\s+(\d+)$/m.exec(procStat)?.[1]);
|
|
38
|
+
if (!Number.isFinite(ticks) || !Number.isFinite(boot) || boot <= 0)
|
|
39
|
+
return null;
|
|
40
|
+
return Math.round(boot * 1000 + (ticks * 1000) / ticksPerSecond);
|
|
41
|
+
}
|
|
42
|
+
/** `getconf CLK_TCK`, or the usual 100 when it cannot run. */
|
|
43
|
+
export async function readClockTicks(io) {
|
|
44
|
+
const answer = await io.run("getconf", ["CLK_TCK"]).catch(() => null);
|
|
45
|
+
const ticks = Number(answer?.stdout.trim());
|
|
46
|
+
return Number.isInteger(ticks) && ticks > 0 ? ticks : DEFAULT_CLOCK_TICKS;
|
|
47
|
+
}
|
|
22
48
|
/**
|
|
23
49
|
* Lists listening ports from `/proc`. A socket is linked to its process through the
|
|
24
50
|
* `/proc/<pid>/fd/*` links; processes of other users cannot be read and stay unknown.
|
|
25
51
|
*/
|
|
26
|
-
export async function listLinuxPorts(io) {
|
|
52
|
+
export async function listLinuxPorts(io, ticksPerSecond = async () => DEFAULT_CLOCK_TICKS) {
|
|
27
53
|
const sockets = [
|
|
28
54
|
...parseProcNetTcp(io.readFile("/proc/net/tcp") ?? ""),
|
|
29
55
|
...parseProcNetTcp(io.readFile("/proc/net/tcp6") ?? ""),
|
|
30
56
|
];
|
|
31
57
|
const pids = pidsByInode(io, new Set(sockets.map((socket) => socket.inode)));
|
|
58
|
+
const ticks = pids.size > 0 ? await ticksPerSecond() : DEFAULT_CLOCK_TICKS;
|
|
59
|
+
const procStat = pids.size > 0 ? io.readFile("/proc/stat") : null;
|
|
32
60
|
const byPort = new Map();
|
|
33
61
|
for (const socket of sockets) {
|
|
34
62
|
const known = byPort.get(socket.port);
|
|
35
63
|
if (known?.pid != null)
|
|
36
64
|
continue;
|
|
37
65
|
const pid = pids.get(socket.inode) ?? null;
|
|
66
|
+
const startedAt = pid === null ? null : processStartTime(io.readFile(`/proc/${pid}/stat`), procStat, ticks);
|
|
38
67
|
byPort.set(socket.port, {
|
|
39
68
|
port: socket.port,
|
|
40
69
|
pid,
|
|
@@ -43,6 +72,7 @@ export async function listLinuxPorts(io) {
|
|
|
43
72
|
executable: pid === null
|
|
44
73
|
? null
|
|
45
74
|
: (io.readLink(`/proc/${pid}/exe`)?.replace(DELETED_SUFFIX, "") ?? null),
|
|
75
|
+
...(startedAt === null ? {} : { startedAt }),
|
|
46
76
|
});
|
|
47
77
|
}
|
|
48
78
|
return [...byPort.values()].sort((a, b) => a.port - b.port);
|
|
@@ -48,28 +48,57 @@ export function parsePsExecutables(text) {
|
|
|
48
48
|
}
|
|
49
49
|
return executables;
|
|
50
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Reads `ps -o pid=,etime= -p ...` into pid -> start in epoch milliseconds. `etime` is the time
|
|
53
|
+
* since the process started as `[[dd-]hh:]mm:ss`, which unlike `lstart` reads the same in every
|
|
54
|
+
* locale.
|
|
55
|
+
*/
|
|
56
|
+
export function parsePsStartTimes(text, now) {
|
|
57
|
+
const starts = new Map();
|
|
58
|
+
for (const line of text.split("\n")) {
|
|
59
|
+
const match = /^\s*(\d+)\s+(?:(\d+)-)?(?:(\d{1,2}):)?(\d{1,2}):(\d{2})\s*$/.exec(line);
|
|
60
|
+
if (!match)
|
|
61
|
+
continue;
|
|
62
|
+
const [, pid, days, hours, minutes, seconds] = match;
|
|
63
|
+
const elapsed = ((Number(days ?? 0) * 24 + Number(hours ?? 0)) * 60 + Number(minutes)) * 60 + Number(seconds);
|
|
64
|
+
starts.set(Number(pid), now - elapsed * 1000);
|
|
65
|
+
}
|
|
66
|
+
return starts;
|
|
67
|
+
}
|
|
51
68
|
/** Lists listening ports with `lsof` and `ps`, which ship with macOS. */
|
|
52
|
-
export async function listMacosPorts(io) {
|
|
69
|
+
export async function listMacosPorts(io, starts = new Map(), now = Date.now) {
|
|
53
70
|
const listen = await io.run("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN", "-F", "pcn"]);
|
|
54
71
|
const rows = parseLsofListen(listen.stdout);
|
|
55
72
|
if (rows.length === 0)
|
|
56
73
|
return [];
|
|
57
74
|
const pids = [...new Set(rows.map((row) => row.pid))];
|
|
58
|
-
const
|
|
75
|
+
for (const pid of starts.keys())
|
|
76
|
+
if (!pids.includes(pid))
|
|
77
|
+
starts.delete(pid);
|
|
78
|
+
const unknownStarts = pids.filter((pid) => !starts.has(pid));
|
|
79
|
+
const [cwd, ps, elapsed] = await Promise.all([
|
|
59
80
|
io.run("lsof", ["-a", "-d", "cwd", "-p", pids.join(","), "-F", "pn"]),
|
|
60
81
|
// -ww: without it ps cuts long paths at the terminal width.
|
|
61
82
|
io.run("ps", ["-ww", "-o", "pid=,comm=", "-p", pids.join(",")]),
|
|
83
|
+
unknownStarts.length === 0
|
|
84
|
+
? null
|
|
85
|
+
: io.run("ps", ["-o", "pid=,etime=", "-p", unknownStarts.join(",")]),
|
|
62
86
|
]);
|
|
87
|
+
const read = parsePsStartTimes(elapsed?.stdout ?? "", now());
|
|
88
|
+
for (const pid of unknownStarts)
|
|
89
|
+
starts.set(pid, read.get(pid) ?? null);
|
|
63
90
|
const cwds = parseLsofCwds(cwd.stdout);
|
|
64
91
|
const executables = parsePsExecutables(ps.stdout);
|
|
65
92
|
const byPort = new Map();
|
|
66
93
|
for (const row of rows) {
|
|
67
94
|
if (byPort.has(row.port))
|
|
68
95
|
continue;
|
|
96
|
+
const startedAt = starts.get(row.pid) ?? null;
|
|
69
97
|
byPort.set(row.port, {
|
|
70
98
|
...row,
|
|
71
99
|
cwd: cwds.get(row.pid) ?? null,
|
|
72
100
|
executable: executables.get(row.pid) ?? null,
|
|
101
|
+
...(startedAt === null ? {} : { startedAt }),
|
|
73
102
|
});
|
|
74
103
|
}
|
|
75
104
|
return [...byPort.values()].sort((a, b) => a.port - b.port);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { request } from "node:http";
|
|
2
2
|
import { STUDIO_HEADER } from "../../../core/api/contract.js";
|
|
3
|
-
import {
|
|
3
|
+
import { isDebuggerVersionInfo } from "../../../core/ports/helper-ports.js";
|
|
4
|
+
import { listLinuxPorts, readClockTicks } from "./linux-ports.js";
|
|
4
5
|
import { listMacosPorts } from "./macos-ports.js";
|
|
5
6
|
import { nodeSystemIo } from "./system-io.js";
|
|
6
7
|
import { listWindowsPorts } from "./windows-ports.js";
|
|
@@ -17,9 +18,12 @@ export function listPortsFor(platform, io) {
|
|
|
17
18
|
const cache = new Map();
|
|
18
19
|
return () => listWindowsPorts(io, cache);
|
|
19
20
|
}
|
|
20
|
-
if (platform === "darwin")
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
if (platform === "darwin") {
|
|
22
|
+
const starts = new Map();
|
|
23
|
+
return () => listMacosPorts(io, starts);
|
|
24
|
+
}
|
|
25
|
+
let ticks = null;
|
|
26
|
+
return () => listLinuxPorts(io, () => (ticks ??= readClockTicks(io)));
|
|
23
27
|
}
|
|
24
28
|
/**
|
|
25
29
|
* Wraps a platform lister with the HTTP check. A port is probed once per process that owns it,
|
|
@@ -30,6 +34,7 @@ export class SystemPortScanner {
|
|
|
30
34
|
#timeoutMs;
|
|
31
35
|
#pidByPort = new Map();
|
|
32
36
|
#answers = new Map();
|
|
37
|
+
#debuggers = new Map();
|
|
33
38
|
constructor(list, timeoutMs = PROBE_TIMEOUT_MS) {
|
|
34
39
|
this.#list = list;
|
|
35
40
|
this.#timeoutMs = timeoutMs;
|
|
@@ -44,13 +49,28 @@ export class SystemPortScanner {
|
|
|
44
49
|
}
|
|
45
50
|
this.#pidByPort = new Map(ports.map((port) => [port.port, port.pid]));
|
|
46
51
|
// Forget answers for processes that are gone, so the cache does not grow forever.
|
|
47
|
-
for (const
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
this.#
|
|
52
|
+
for (const cache of [this.#answers, this.#debuggers]) {
|
|
53
|
+
for (const key of cache.keys()) {
|
|
54
|
+
const [port, pid] = key.split(":");
|
|
55
|
+
if (String(this.#pidByPort.get(Number(port))) !== pid)
|
|
56
|
+
cache.delete(key);
|
|
57
|
+
}
|
|
51
58
|
}
|
|
52
59
|
return ports;
|
|
53
60
|
}
|
|
61
|
+
/** Asked once per port and process, like `answersHttp`. */
|
|
62
|
+
probeDebugger(port) {
|
|
63
|
+
const pid = this.#pidByPort.get(port) ?? null;
|
|
64
|
+
if (pid === null)
|
|
65
|
+
return probeDebuggerPort(port, this.#timeoutMs);
|
|
66
|
+
const key = `${port}:${pid}`;
|
|
67
|
+
let answer = this.#debuggers.get(key);
|
|
68
|
+
if (!answer) {
|
|
69
|
+
answer = probeDebuggerPort(port, this.#timeoutMs);
|
|
70
|
+
this.#debuggers.set(key, answer);
|
|
71
|
+
}
|
|
72
|
+
return answer;
|
|
73
|
+
}
|
|
54
74
|
answersHttp(port) {
|
|
55
75
|
const pid = this.#pidByPort.get(port) ?? null;
|
|
56
76
|
// Without a pid we cannot tell a restarted process apart, so the answer is not kept.
|
|
@@ -65,6 +85,49 @@ export class SystemPortScanner {
|
|
|
65
85
|
return answer;
|
|
66
86
|
}
|
|
67
87
|
}
|
|
88
|
+
/** A `/json/version` answer is a few hundred bytes; anything longer is not a debugger. */
|
|
89
|
+
const MAX_VERSION_BODY = 16_384;
|
|
90
|
+
/**
|
|
91
|
+
* Asks `GET /json/version`, which Node's inspector and Chrome DevTools answer with JSON naming
|
|
92
|
+
* the browser and protocol. `other` when the port answered something else, `unknown` when
|
|
93
|
+
* nothing answered in time.
|
|
94
|
+
*/
|
|
95
|
+
export async function probeDebuggerPort(port, timeoutMs = PROBE_TIMEOUT_MS) {
|
|
96
|
+
for (const host of LOOPBACK_HOSTS) {
|
|
97
|
+
const body = await getText(host, port, "/json/version", timeoutMs);
|
|
98
|
+
if (body === null)
|
|
99
|
+
continue;
|
|
100
|
+
try {
|
|
101
|
+
return isDebuggerVersionInfo(JSON.parse(body)) ? "debugger" : "other";
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return "other";
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return "unknown";
|
|
108
|
+
}
|
|
109
|
+
function getText(host, port, path, timeoutMs) {
|
|
110
|
+
return new Promise((resolve) => {
|
|
111
|
+
const options = { host, port, method: "GET", path, timeout: timeoutMs, agent: false };
|
|
112
|
+
const req = request(options, (res) => {
|
|
113
|
+
let body = "";
|
|
114
|
+
res.setEncoding("utf8");
|
|
115
|
+
res.on("data", (chunk) => {
|
|
116
|
+
body += chunk;
|
|
117
|
+
if (body.length <= MAX_VERSION_BODY)
|
|
118
|
+
return;
|
|
119
|
+
resolve("");
|
|
120
|
+
req.destroy();
|
|
121
|
+
});
|
|
122
|
+
res.on("end", () => resolve(body));
|
|
123
|
+
res.on("error", () => resolve(body));
|
|
124
|
+
});
|
|
125
|
+
req.on("timeout", () => req.destroy());
|
|
126
|
+
req.on("error", () => resolve(null));
|
|
127
|
+
req.on("close", () => resolve(null));
|
|
128
|
+
req.end();
|
|
129
|
+
});
|
|
130
|
+
}
|
|
68
131
|
/**
|
|
69
132
|
* True when anything on loopback answers `HEAD /` with an HTTP response, whatever its status,
|
|
70
133
|
* unless the response comes from a Portless Studio.
|
|
@@ -58,10 +58,12 @@ export function parseWin32Processes(text) {
|
|
|
58
58
|
continue;
|
|
59
59
|
const executable = Reflect.get(item, "ExecutablePath");
|
|
60
60
|
const commandLine = Reflect.get(item, "CommandLine");
|
|
61
|
+
const startedAt = Reflect.get(item, "StartedAt");
|
|
61
62
|
processes.set(pid, {
|
|
62
63
|
name: name.replace(/\.exe$/i, ""),
|
|
63
64
|
executable: typeof executable === "string" && executable !== "" ? executable : null,
|
|
64
65
|
commandLine: typeof commandLine === "string" && commandLine !== "" ? commandLine : null,
|
|
66
|
+
...(typeof startedAt === "number" && startedAt > 0 ? { startedAt } : {}),
|
|
65
67
|
});
|
|
66
68
|
}
|
|
67
69
|
return processes;
|
|
@@ -159,6 +161,9 @@ export async function listWindowsPorts(io, cache = new Map(), now = Date.now) {
|
|
|
159
161
|
processName: system ?? info?.name ?? "",
|
|
160
162
|
cwd: folderFromCommandLine(info?.commandLine ?? null),
|
|
161
163
|
executable: info?.executable ?? null,
|
|
164
|
+
...(system === undefined && info?.startedAt !== undefined
|
|
165
|
+
? { startedAt: info.startedAt }
|
|
166
|
+
: {}),
|
|
162
167
|
});
|
|
163
168
|
}
|
|
164
169
|
return [...byPort.values()].sort((a, b) => a.port - b.port);
|
|
@@ -181,7 +186,10 @@ async function readProcesses(io, pids) {
|
|
|
181
186
|
return { processes: new Map(), complete: true };
|
|
182
187
|
const filter = pids.map((pid) => `ProcessId=${pid}`).join(" OR ");
|
|
183
188
|
const script = `Get-CimInstance Win32_Process -Filter '${filter}' | ` +
|
|
184
|
-
"Select-Object ProcessId, Name, ExecutablePath, CommandLine
|
|
189
|
+
"Select-Object ProcessId, Name, ExecutablePath, CommandLine, " +
|
|
190
|
+
// A plain number, because ConvertTo-Json writes dates differently in each PowerShell version.
|
|
191
|
+
"@{n='StartedAt';e={if ($_.CreationDate) { [DateTimeOffset]::new($_.CreationDate).ToUnixTimeMilliseconds() }}} | " +
|
|
192
|
+
"ConvertTo-Json -Compress";
|
|
185
193
|
const queried = await io.run("powershell", powershellArgs(script));
|
|
186
194
|
const processes = queried.exitCode === 0 ? parseWin32Processes(queried.stdout) : new Map();
|
|
187
195
|
if (processes.size > 0)
|
|
@@ -243,6 +243,10 @@ export function createHttpServer(options) {
|
|
|
243
243
|
return sendAppError(res, created.error);
|
|
244
244
|
return sendJson(res, 201, created.value);
|
|
245
245
|
}
|
|
246
|
+
if (pathname === API_PATHS.terminalsRecentCommands && method === "GET") {
|
|
247
|
+
const body = { recentCommands: terminals.recentCommands() };
|
|
248
|
+
return sendJson(res, 200, body);
|
|
249
|
+
}
|
|
246
250
|
if (pathname === API_PATHS.terminalsEvents && method === "GET") {
|
|
247
251
|
terminalStreams.list(res);
|
|
248
252
|
return;
|
|
@@ -23,9 +23,13 @@ const CONTENT_TYPES = {
|
|
|
23
23
|
};
|
|
24
24
|
const COMPRESSIBLE = new Set([".html", ".js", ".mjs", ".css", ".json", ".map", ".svg", ".txt"]);
|
|
25
25
|
const MIN_COMPRESS_BYTES = 1024;
|
|
26
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* No inline scripts, no third parties, never framed. Inline styles are allowed because the
|
|
28
|
+
* terminal (xterm.js) paints ANSI colors through <style> elements it creates at runtime; scripts
|
|
29
|
+
* stay locked to the page's own files.
|
|
30
|
+
*/
|
|
27
31
|
const HTML_HEADERS = {
|
|
28
|
-
"Content-Security-Policy": "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; " +
|
|
32
|
+
"Content-Security-Policy": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; " +
|
|
29
33
|
"font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; " +
|
|
30
34
|
"form-action 'none'; frame-ancestors 'none'",
|
|
31
35
|
"X-Frame-Options": "DENY",
|
package/dist/server/main.js
CHANGED
|
@@ -18,12 +18,14 @@ import { createDetachedLauncher } from "./infrastructure/apps/detached-launcher.
|
|
|
18
18
|
import { JsonCustomAppStore, JsonRouteProjectStore, openWithPathFor, routeProjectsPathFor, } from "./infrastructure/apps/json-stores.js";
|
|
19
19
|
import { autoNamedPathFor, JsonAutoNamedStore, } from "./infrastructure/config/json-auto-named-store.js";
|
|
20
20
|
import { defaultConfigPath, JsonConfigStore } from "./infrastructure/config/json-config-store.js";
|
|
21
|
+
import { JsonTerminalHistoryStore, terminalHistoryPathFor, } from "./infrastructure/config/json-terminal-history-store.js";
|
|
21
22
|
import { JsonUpdateCache, updateCheckPathFor } from "./infrastructure/config/json-update-cache.js";
|
|
22
23
|
import { proxyStartRecordPathFor, readProxyStartRecord, recordProxyStarts, } from "./infrastructure/config/proxy-start-record.js";
|
|
23
24
|
import { PortlessRegistry } from "./infrastructure/portless/portless-registry.js";
|
|
24
25
|
import { inspectRoutesFile, resolveStateDir } from "./infrastructure/portless/state-files.js";
|
|
25
26
|
import { DockerContainers } from "./infrastructure/system/docker-containers.js";
|
|
26
27
|
import { collectDoctorFacts, proxyPidsAmong, reachesPortless, testBind, } from "./infrastructure/system/doctor-facts.js";
|
|
28
|
+
import { ephemeralRangeReader } from "./infrastructure/system/ephemeral-ports.js";
|
|
27
29
|
import { FaviconFetcher } from "./infrastructure/system/favicon-fetcher.js";
|
|
28
30
|
import { fetchLatestVersion } from "./infrastructure/system/npm-registry.js";
|
|
29
31
|
import { OsNotifier } from "./infrastructure/system/os-notifier.js";
|
|
@@ -82,6 +84,7 @@ export function createStudioServices(options = {}) {
|
|
|
82
84
|
routeProjectStore: new JsonRouteProjectStore(routeProjectsPathFor(configPath)),
|
|
83
85
|
containers,
|
|
84
86
|
shortUrls,
|
|
87
|
+
ephemeralRange: ephemeralRangeReader(process.platform, nodeSystemIo),
|
|
85
88
|
});
|
|
86
89
|
const openWith = new OpenWith({
|
|
87
90
|
catalog: systemAppCatalog(),
|
|
@@ -94,6 +97,7 @@ export function createStudioServices(options = {}) {
|
|
|
94
97
|
folders: nodeTerminalFolders(),
|
|
95
98
|
processTree: systemProcessTree(),
|
|
96
99
|
homeDir: os.homedir(),
|
|
100
|
+
history: new JsonTerminalHistoryStore(terminalHistoryPathFor(configPath)),
|
|
97
101
|
workspace: {
|
|
98
102
|
openableFolders: () => runtime.openableFolders(),
|
|
99
103
|
snapshot: () => runtime.readSnapshot(),
|
|
@@ -176,6 +180,7 @@ export function createUninstallServices(configPath = defaultConfigPath()) {
|
|
|
176
180
|
routeProjectsPathFor(configPath),
|
|
177
181
|
openWithPathFor(configPath),
|
|
178
182
|
updateCheckPathFor(configPath),
|
|
183
|
+
terminalHistoryPathFor(configPath),
|
|
179
184
|
];
|
|
180
185
|
const processes = new SystemProcessControl({
|
|
181
186
|
platform: process.platform,
|
|
@@ -236,6 +241,11 @@ export async function startServer(options) {
|
|
|
236
241
|
studio.start();
|
|
237
242
|
// The short-URL forwarder settles before anyone prints or registers a URL.
|
|
238
243
|
await studio.ready();
|
|
244
|
+
// Terminals that were running when Studio stopped; never holds up the start.
|
|
245
|
+
void studio
|
|
246
|
+
.settings()
|
|
247
|
+
.then((config) => services.terminals?.restore(config.restoreTerminals))
|
|
248
|
+
.catch(() => { });
|
|
239
249
|
return {
|
|
240
250
|
url: `http://${LOOPBACK}:${port}`,
|
|
241
251
|
port,
|