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.
Files changed (28) hide show
  1. package/README.md +1 -1
  2. package/dist/cli/mcp/studio-tools.js +36 -2
  3. package/dist/cli/studio-items.js +2 -0
  4. package/dist/core/api/contract.js +6 -1
  5. package/dist/core/config/studio-config.js +83 -0
  6. package/dist/core/terminals/terminal-history.js +53 -0
  7. package/dist/core/terminals/terminal.js +0 -4
  8. package/dist/server/application/port-discovery.js +1 -0
  9. package/dist/server/application/stop-process.js +44 -3
  10. package/dist/server/application/studio-runtime.js +10 -0
  11. package/dist/server/application/terminals.js +267 -53
  12. package/dist/server/infrastructure/config/json-terminal-history-store.js +30 -0
  13. package/dist/server/infrastructure/system/dev-process-tree.js +70 -0
  14. package/dist/server/infrastructure/system/linux-ports.js +31 -1
  15. package/dist/server/infrastructure/system/macos-ports.js +31 -2
  16. package/dist/server/infrastructure/system/port-closed.js +15 -0
  17. package/dist/server/infrastructure/system/system-port-scanner.js +7 -4
  18. package/dist/server/infrastructure/system/system-process-control.js +44 -0
  19. package/dist/server/infrastructure/system/windows-ports.js +9 -1
  20. package/dist/server/interface/http/http-server.js +5 -1
  21. package/dist/server/interface/http/static-files.js +6 -2
  22. package/dist/server/main.js +14 -2
  23. package/dist/web/assets/index-DhggsQyU.js +23 -0
  24. package/dist/web/assets/index-Q29k6Jdf.css +1 -0
  25. package/dist/web/index.html +2 -2
  26. package/package.json +1 -1
  27. package/dist/web/assets/index-9WPxv6pu.js +0 -17
  28. 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
- return () => listMacosPorts(io);
23
- return () => listLinuxPorts(io);
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 | ConvertTo-Json -Compress";
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
- /** No inline scripts, no third parties, never framed. Vite builds need nothing more. */
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",
@@ -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,