portless-studio 0.1.3 → 0.1.5
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/mcp/studio-tools.js +36 -2
- package/dist/cli/studio-items.js +2 -0
- package/dist/core/api/contract.js +6 -1
- package/dist/core/config/studio-config.js +83 -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 +1 -0
- package/dist/server/application/stop-process.js +44 -3
- package/dist/server/application/studio-runtime.js +10 -0
- package/dist/server/application/terminals.js +267 -53
- package/dist/server/infrastructure/config/json-terminal-history-store.js +30 -0
- package/dist/server/infrastructure/system/dev-process-tree.js +70 -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/port-closed.js +15 -0
- package/dist/server/infrastructure/system/system-port-scanner.js +7 -4
- package/dist/server/infrastructure/system/system-process-control.js +44 -0
- package/dist/server/infrastructure/system/windows-ports.js +9 -1
- package/dist/server/interface/http/http-server.js +5 -1
- package/dist/server/interface/http/static-files.js +6 -2
- package/dist/server/main.js +14 -2
- package/dist/web/assets/index-DhggsQyU.js +23 -0
- package/dist/web/assets/index-Q29k6Jdf.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-9WPxv6pu.js +0 -17
- package/dist/web/assets/index-Bckc4cIr.css +0 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { request } from "node:http";
|
|
2
2
|
import { STUDIO_HEADER } from "../../../core/api/contract.js";
|
|
3
3
|
import { isDebuggerVersionInfo } from "../../../core/ports/helper-ports.js";
|
|
4
|
-
import { listLinuxPorts } from "./linux-ports.js";
|
|
4
|
+
import { listLinuxPorts, readClockTicks } from "./linux-ports.js";
|
|
5
5
|
import { listMacosPorts } from "./macos-ports.js";
|
|
6
6
|
import { nodeSystemIo } from "./system-io.js";
|
|
7
7
|
import { listWindowsPorts } from "./windows-ports.js";
|
|
@@ -18,9 +18,12 @@ export function listPortsFor(platform, io) {
|
|
|
18
18
|
const cache = new Map();
|
|
19
19
|
return () => listWindowsPorts(io, cache);
|
|
20
20
|
}
|
|
21
|
-
if (platform === "darwin")
|
|
22
|
-
|
|
23
|
-
|
|
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)));
|
|
24
27
|
}
|
|
25
28
|
/**
|
|
26
29
|
* Wraps a platform lister with the HTTP check. A port is probed once per process that owns it,
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { err, ok } from "../../../core/result.js";
|
|
2
2
|
import { appError } from "../../application/app-error.js";
|
|
3
3
|
import { isProcessAlive } from "../portless/state-files.js";
|
|
4
|
+
import { parseStatParent } from "../terminal/process-tree.js";
|
|
5
|
+
import { devProcessTree, parseProcessTable } from "./dev-process-tree.js";
|
|
4
6
|
/** How long a dev server gets to shut down cleanly before it is forced. */
|
|
5
7
|
const GRACE_MS = 5000;
|
|
6
8
|
const POLL_MS = 100;
|
|
@@ -40,6 +42,48 @@ export class SystemProcessControl {
|
|
|
40
42
|
constructor(options) {
|
|
41
43
|
this.#options = options;
|
|
42
44
|
}
|
|
45
|
+
async devTree(pid) {
|
|
46
|
+
const { platform, io } = this.#options;
|
|
47
|
+
let table;
|
|
48
|
+
if (platform === "linux") {
|
|
49
|
+
table = io
|
|
50
|
+
.readDir("/proc")
|
|
51
|
+
.filter((name) => /^\d+$/.test(name))
|
|
52
|
+
.flatMap((name) => {
|
|
53
|
+
const stat = io.readFile(`/proc/${name}/stat`);
|
|
54
|
+
const command = io.readFile(`/proc/${name}/cmdline`);
|
|
55
|
+
const parent = stat === null ? null : parseStatParent(stat);
|
|
56
|
+
return parent === null || !command
|
|
57
|
+
? []
|
|
58
|
+
: [{ pid: Number(name), parent, command: command.replaceAll("\0", " ").trim() }];
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
else if (platform === "darwin") {
|
|
62
|
+
const answer = await io.run("ps", ["-ww", "-axo", "pid=,ppid=,args="]);
|
|
63
|
+
table = answer.exitCode === 0 ? parseProcessTable(answer.stdout) : [];
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
const answer = await io.run("powershell.exe", [
|
|
67
|
+
"-NoProfile",
|
|
68
|
+
"-NonInteractive",
|
|
69
|
+
"-Command",
|
|
70
|
+
"Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,CommandLine | ConvertTo-Json -Compress",
|
|
71
|
+
]);
|
|
72
|
+
try {
|
|
73
|
+
const rows = JSON.parse(answer.stdout);
|
|
74
|
+
table = (Array.isArray(rows) ? rows : [rows]).flatMap((row) => row &&
|
|
75
|
+
typeof row.ProcessId === "number" &&
|
|
76
|
+
typeof row.ParentProcessId === "number" &&
|
|
77
|
+
typeof row.CommandLine === "string"
|
|
78
|
+
? [{ pid: row.ProcessId, parent: row.ParentProcessId, command: row.CommandLine }]
|
|
79
|
+
: []);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
table = [];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return devProcessTree(pid, table);
|
|
86
|
+
}
|
|
43
87
|
async ownerOf(pid) {
|
|
44
88
|
const { platform, io, uid, userName } = this.#options;
|
|
45
89
|
if (platform === "win32") {
|
|
@@ -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)
|
|
@@ -172,7 +172,7 @@ export function createHttpServer(options) {
|
|
|
172
172
|
if (stop?.[1] && method === "POST") {
|
|
173
173
|
if (!options.stopProcess)
|
|
174
174
|
return sendAppError(res, unsupported);
|
|
175
|
-
const result = await options.stopProcess(stop[1]);
|
|
175
|
+
const result = await options.stopProcess(stop[1], query.get("scope") === "tree");
|
|
176
176
|
if (!result.ok)
|
|
177
177
|
return sendAppError(res, result.error);
|
|
178
178
|
res.writeHead(204, { "Cache-Control": "no-store" }).end();
|
|
@@ -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,6 +18,7 @@ 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";
|
|
@@ -28,6 +29,7 @@ import { ephemeralRangeReader } from "./infrastructure/system/ephemeral-ports.js
|
|
|
28
29
|
import { FaviconFetcher } from "./infrastructure/system/favicon-fetcher.js";
|
|
29
30
|
import { fetchLatestVersion } from "./infrastructure/system/npm-registry.js";
|
|
30
31
|
import { OsNotifier } from "./infrastructure/system/os-notifier.js";
|
|
32
|
+
import { portClosed } from "./infrastructure/system/port-closed.js";
|
|
31
33
|
import { startForwarder } from "./infrastructure/system/port-forwarder.js";
|
|
32
34
|
import { ProjectInfoReader } from "./infrastructure/system/project-info.js";
|
|
33
35
|
import { nodeSystemIo } from "./infrastructure/system/system-io.js";
|
|
@@ -96,6 +98,7 @@ export function createStudioServices(options = {}) {
|
|
|
96
98
|
folders: nodeTerminalFolders(),
|
|
97
99
|
processTree: systemProcessTree(),
|
|
98
100
|
homeDir: os.homedir(),
|
|
101
|
+
history: new JsonTerminalHistoryStore(terminalHistoryPathFor(configPath)),
|
|
99
102
|
workspace: {
|
|
100
103
|
openableFolders: () => runtime.openableFolders(),
|
|
101
104
|
snapshot: () => runtime.readSnapshot(),
|
|
@@ -152,11 +155,14 @@ export function createStudioServices(options = {}) {
|
|
|
152
155
|
detectPorts: () => runtime.detectPorts(),
|
|
153
156
|
doctor,
|
|
154
157
|
controlProxy: proxy,
|
|
155
|
-
stopProcess: (pid) => stopProcess(pid, {
|
|
158
|
+
stopProcess: (pid, all) => stopProcess(pid, {
|
|
156
159
|
processes,
|
|
157
160
|
stoppablePids: () => runtime.stoppablePids(),
|
|
158
161
|
selfPid: process.pid,
|
|
159
|
-
|
|
162
|
+
listeners: () => scanner.scan(),
|
|
163
|
+
portClosed,
|
|
164
|
+
refresh: () => runtime.refreshProcesses(),
|
|
165
|
+
}, all),
|
|
160
166
|
stopContainer: (name) => stopContainer(name, containers),
|
|
161
167
|
studioService,
|
|
162
168
|
openWith,
|
|
@@ -178,6 +184,7 @@ export function createUninstallServices(configPath = defaultConfigPath()) {
|
|
|
178
184
|
routeProjectsPathFor(configPath),
|
|
179
185
|
openWithPathFor(configPath),
|
|
180
186
|
updateCheckPathFor(configPath),
|
|
187
|
+
terminalHistoryPathFor(configPath),
|
|
181
188
|
];
|
|
182
189
|
const processes = new SystemProcessControl({
|
|
183
190
|
platform: process.platform,
|
|
@@ -238,6 +245,11 @@ export async function startServer(options) {
|
|
|
238
245
|
studio.start();
|
|
239
246
|
// The short-URL forwarder settles before anyone prints or registers a URL.
|
|
240
247
|
await studio.ready();
|
|
248
|
+
// Terminals that were running when Studio stopped; never holds up the start.
|
|
249
|
+
void studio
|
|
250
|
+
.settings()
|
|
251
|
+
.then((config) => services.terminals?.restore(config.restoreTerminals))
|
|
252
|
+
.catch(() => { });
|
|
241
253
|
return {
|
|
242
254
|
url: `http://${LOOPBACK}:${port}`,
|
|
243
255
|
port,
|