portless-studio 0.1.1 → 0.1.3

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 (36) hide show
  1. package/README.md +60 -218
  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 +70 -7
  6. package/dist/cli/studio-items.js +5 -2
  7. package/dist/core/api/contract.js +17 -1
  8. package/dist/core/config/studio-config.js +6 -0
  9. package/dist/core/doctor/platform-commands.js +5 -2
  10. package/dist/core/ports/direct-url.js +7 -0
  11. package/dist/core/ports/docker-container.js +85 -0
  12. package/dist/core/ports/helper-ports.js +86 -0
  13. package/dist/core/routes/env-lines.js +72 -0
  14. package/dist/core/routes/route.js +14 -2
  15. package/dist/server/application/container-source.js +19 -0
  16. package/dist/server/application/doctor.js +199 -9
  17. package/dist/server/application/port-discovery.js +84 -9
  18. package/dist/server/application/short-urls.js +136 -0
  19. package/dist/server/application/studio-runtime.js +82 -24
  20. package/dist/server/application/terminals.js +142 -15
  21. package/dist/server/infrastructure/system/docker-containers.js +64 -0
  22. package/dist/server/infrastructure/system/doctor-facts.js +43 -1
  23. package/dist/server/infrastructure/system/ephemeral-ports.js +62 -0
  24. package/dist/server/infrastructure/system/port-forwarder.js +78 -0
  25. package/dist/server/infrastructure/system/process-runner.js +2 -1
  26. package/dist/server/infrastructure/system/system-port-scanner.js +64 -4
  27. package/dist/server/infrastructure/terminal/process-tree.js +71 -0
  28. package/dist/server/infrastructure/terminal/terminal-spawner.js +44 -3
  29. package/dist/server/interface/http/http-server.js +38 -2
  30. package/dist/server/main.js +43 -6
  31. package/dist/web/assets/index-9WPxv6pu.js +17 -0
  32. package/dist/web/assets/index-Bckc4cIr.css +1 -0
  33. package/dist/web/index.html +2 -2
  34. package/package.json +1 -1
  35. package/dist/web/assets/index-B3KMqQ-l.js +0 -15
  36. package/dist/web/assets/index-CWujmTe4.css +0 -1
@@ -0,0 +1,71 @@
1
+ import * as fs from "node:fs";
2
+ import { runProcess } from "../system/process-runner.js";
3
+ /** Deeper than any real chain of shells and tools; a guard against a bad read. */
4
+ const MAX_DEPTH = 64;
5
+ /** One `ps` answer serves every pid of a scan on macOS. */
6
+ const PS_CACHE_MS = 2_000;
7
+ /** The parent pid in the text of `/proc/<pid>/stat`; the name in parentheses may hold anything. */
8
+ export function parseStatParent(stat) {
9
+ const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
10
+ const parent = Number(fields[1]);
11
+ return stat.includes(")") && Number.isInteger(parent) ? parent : null;
12
+ }
13
+ /** Every pid's parent from `ps -A -o pid=,ppid=`. */
14
+ export function parsePsParents(output) {
15
+ const parents = new Map();
16
+ for (const line of output.split("\n")) {
17
+ const match = /^\s*(\d+)\s+(\d+)\s*$/.exec(line);
18
+ if (match)
19
+ parents.set(Number(match[1]), Number(match[2]));
20
+ }
21
+ return parents;
22
+ }
23
+ /** The parent, its parent and so on, ending before pid 0 or at a pid already seen. */
24
+ export function walkAncestors(pid, parentOf) {
25
+ const ancestors = [];
26
+ const seen = new Set([pid]);
27
+ let current = parentOf(pid);
28
+ while (current !== null && current > 0 && !seen.has(current) && ancestors.length < MAX_DEPTH) {
29
+ ancestors.push(current);
30
+ seen.add(current);
31
+ current = parentOf(current);
32
+ }
33
+ return ancestors;
34
+ }
35
+ /**
36
+ * Parent processes on this machine: `/proc` on Linux, one `ps` on macOS. Windows answers nothing,
37
+ * so terminals there list no ports.
38
+ */
39
+ export function systemProcessTree(platform = process.platform) {
40
+ if (platform === "linux") {
41
+ return {
42
+ ancestors: async (pid) => walkAncestors(pid, (current) => {
43
+ try {
44
+ return parseStatParent(fs.readFileSync(`/proc/${current}/stat`, "utf8"));
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }),
50
+ };
51
+ }
52
+ if (platform === "darwin") {
53
+ let cached = null;
54
+ const table = () => {
55
+ if (!cached || Date.now() - cached.at > PS_CACHE_MS) {
56
+ cached = {
57
+ at: Date.now(),
58
+ parents: runProcess("ps", ["-A", "-o", "pid=,ppid="], { timeoutMs: 3_000 }).then((outcome) => parsePsParents(outcome.exitCode === 0 ? outcome.stdout : "")),
59
+ };
60
+ }
61
+ return cached.parents;
62
+ };
63
+ return {
64
+ ancestors: async (pid) => {
65
+ const parents = await table();
66
+ return walkAncestors(pid, (current) => parents.get(current) ?? null);
67
+ },
68
+ };
69
+ }
70
+ return { ancestors: async () => [] };
71
+ }
@@ -1,7 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
- import { runProcess, stopProcessTree } from "../system/process-runner.js";
4
+ import { nodeWindowsLookup, resolveWindowsCommand, runProcess, stopProcessTree, } from "../system/process-runner.js";
5
5
  /** Output still in the pipes when a process exits arrives right after; a child holding them does not. */
6
6
  const DRAIN_AFTER_EXIT_MS = 500;
7
7
  /**
@@ -15,6 +15,16 @@ export function planTerminal(request, context) {
15
15
  const env = { ...request.env };
16
16
  if (context.platform === "win32") {
17
17
  env.FORCE_COLOR = "1";
18
+ if (request.command === null && context.windowsShell) {
19
+ // With pipes PowerShell shows no prompt, but reads and runs each line it is given.
20
+ return {
21
+ file: context.windowsShell,
22
+ args: ["-NoLogo", "-Command", "-"],
23
+ env,
24
+ detached: false,
25
+ windowsVerbatimArguments: false,
26
+ };
27
+ }
18
28
  const args = request.command === null ? ["/d"] : ["/d", "/s", "/c", `"${request.command}"`];
19
29
  return {
20
30
  file: context.comSpec,
@@ -27,11 +37,12 @@ export function planTerminal(request, context) {
27
37
  if (context.mode === "pipes") {
28
38
  env.FORCE_COLOR = "1";
29
39
  const args = request.command === null
30
- ? ["-c", `exec ${quote(context.shell)} -i`]
40
+ ? ["-c", `exec ${quote(context.shell)} -l -i`]
31
41
  : ["-c", request.command];
32
42
  return { file: "/bin/sh", args, env, detached: true, windowsVerbatimArguments: false };
33
43
  }
34
- const run = request.command ?? `exec ${quote(context.shell)}`;
44
+ // A login shell reads the profile the person's own terminal reads (PATH, nvm and the like).
45
+ const run = request.command ?? `exec ${quote(context.shell)} -l`;
35
46
  const inner = `stty cols ${request.cols} rows ${request.rows} 2>/dev/null; ${run}`;
36
47
  // util-linux script runs `$SHELL -c`; /bin/sh keeps the command's meaning the same everywhere.
37
48
  env.SHELL = "/bin/sh";
@@ -40,6 +51,15 @@ export function planTerminal(request, context) {
40
51
  : ["-q", "-f", "-e", "-c", inner, "/dev/null"];
41
52
  return { file: "script", args, env, detached: true, windowsVerbatimArguments: false };
42
53
  }
54
+ /** The interactive shell's short name, e.g. `zsh` or `pwsh`: the default title of a shell terminal. */
55
+ export function shellName(context) {
56
+ if (context.platform === "win32") {
57
+ if (!context.windowsShell)
58
+ return "cmd";
59
+ return path.win32.basename(context.windowsShell).replace(/\.exe$/i, "");
60
+ }
61
+ return path.posix.basename(context.shell) || "shell";
62
+ }
43
63
  /**
44
64
  * Whether this machine can run pseudo-terminals through `script`: util-linux's on Linux (BusyBox
45
65
  * and others take different flags), the system one on macOS, never on Windows.
@@ -62,9 +82,15 @@ export function createTerminalSpawner(options) {
62
82
  mode,
63
83
  shell: baseEnv.SHELL || "/bin/sh",
64
84
  comSpec: baseEnv.ComSpec ?? "cmd.exe",
85
+ windowsShell: platform === "win32"
86
+ ? options.windowsShell === undefined
87
+ ? findWindowsShell()
88
+ : options.windowsShell
89
+ : null,
65
90
  };
66
91
  return {
67
92
  mode,
93
+ shellName: shellName(context),
68
94
  spawn: async (request) => {
69
95
  const plan = planTerminal(request, context);
70
96
  const child = spawn(plan.file, plan.args, {
@@ -171,6 +197,21 @@ export function nodeTerminalFolders(platform = process.platform) {
171
197
  },
172
198
  };
173
199
  }
200
+ /** The short name of the shell a terminal with no command starts on this machine. */
201
+ export function interactiveShellName(platform = process.platform, env = process.env) {
202
+ return shellName({
203
+ platform,
204
+ mode: "pipes",
205
+ shell: env.SHELL || "/bin/sh",
206
+ comSpec: env.ComSpec ?? "cmd.exe",
207
+ windowsShell: platform === "win32" ? findWindowsShell() : null,
208
+ });
209
+ }
210
+ /** `pwsh` when installed, else Windows PowerShell, else null for cmd.exe. */
211
+ function findWindowsShell() {
212
+ const lookup = nodeWindowsLookup();
213
+ return resolveWindowsCommand("pwsh", lookup) ?? resolveWindowsCommand("powershell", lookup);
214
+ }
174
215
  /** Single quotes for sh, with any single quote inside closed, escaped and reopened. */
175
216
  function quote(value) {
176
217
  return `'${value.replaceAll("'", `'\\''`)}'`;
@@ -1,5 +1,5 @@
1
1
  import { createServer } from "node:http";
2
- import { API_PATHS, STUDIO_HEADER, TOKEN_HEADER, TOKEN_PARAM, } from "../../../core/api/contract.js";
2
+ import { API_PATHS, INCLUDE_STUDIO_PARAM, STUDIO_HEADER, TOKEN_HEADER, TOKEN_PARAM, } from "../../../core/api/contract.js";
3
3
  import { createAlias, installPortless, removeAlias, renameAlias, } from "../../application/route-use-cases.js";
4
4
  import { FocusBroker, focusQuery, resolveFocusTarget } from "./focus-broker.js";
5
5
  import { MAX_BODY_BYTES, readJsonBody, sendError, sendJson } from "./json-io.js";
@@ -13,6 +13,7 @@ const DOCTOR_FIX = /^\/api\/doctor\/fix\/([^/]+)$/;
13
13
  const PROXY_ACTION = /^\/api\/proxy\/(start|stop|restart)$/;
14
14
  const STUDIO_ACTION = /^\/api\/studio\/(restart|stop)$/;
15
15
  const STOP_PROCESS = /^\/api\/processes\/([^/]+)\/stop$/;
16
+ const STOP_CONTAINER = /^\/api\/containers\/([^/]+)\/stop$/;
16
17
  const TERMINAL = /^\/api\/terminals\/([^/]+)(?:\/(output|stream|input|stop|restart))?$/;
17
18
  /** Lets the 202 reach the browser before Studio restarts or exits. */
18
19
  const STUDIO_ACTION_DELAY_MS = 200;
@@ -99,7 +100,8 @@ export function createHttpServer(options) {
99
100
  return;
100
101
  }
101
102
  if (pathname === API_PATHS.snapshot && method === "GET") {
102
- sendJson(res, 200, await options.studio.readSnapshot());
103
+ const includeStudio = query.get(INCLUDE_STUDIO_PARAM) === "1";
104
+ sendJson(res, 200, await options.studio.readSnapshot({ includeStudio }));
103
105
  return;
104
106
  }
105
107
  if (pathname === API_PATHS.update && method === "GET") {
@@ -176,6 +178,16 @@ export function createHttpServer(options) {
176
178
  res.writeHead(204, { "Cache-Control": "no-store" }).end();
177
179
  return;
178
180
  }
181
+ const stopBox = STOP_CONTAINER.exec(pathname);
182
+ if (stopBox?.[1] && method === "POST") {
183
+ if (!options.stopContainer)
184
+ return sendAppError(res, unsupported);
185
+ const result = await options.stopContainer(decodeName(stopBox[1]) ?? "");
186
+ if (!result.ok)
187
+ return sendAppError(res, result.error);
188
+ res.writeHead(204, { "Cache-Control": "no-store" }).end();
189
+ return;
190
+ }
179
191
  if (pathname.startsWith(`${API_PATHS.openWith}/`) || pathname === API_PATHS.openWith) {
180
192
  return handleOpenWith(req, res, pathname, method);
181
193
  }
@@ -235,6 +247,18 @@ export function createHttpServer(options) {
235
247
  terminalStreams.list(res);
236
248
  return;
237
249
  }
250
+ if (pathname === API_PATHS.terminalsInFolder && method === "POST") {
251
+ const body = await readJsonBody(req);
252
+ if (!body.ok)
253
+ return sendBodyError(res, body.error);
254
+ const created = await terminals.create(toTerminalRequest(body.value), {
255
+ trusted: isLocalProgram(req),
256
+ picked: true,
257
+ });
258
+ if (!created.ok)
259
+ return sendAppError(res, created.error);
260
+ return sendJson(res, 201, created.value);
261
+ }
238
262
  const match = TERMINAL.exec(pathname);
239
263
  const id = match?.[1] ? decodeName(match[1]) : null;
240
264
  if (id === null)
@@ -246,6 +270,18 @@ export function createHttpServer(options) {
246
270
  return sendError(res, "not_found", `No terminal has the id "${id}".`);
247
271
  return sendJson(res, 200, info);
248
272
  }
273
+ if (action === undefined && method === "PATCH") {
274
+ const body = await readJsonBody(req);
275
+ if (!body.ok)
276
+ return sendBodyError(res, body.error);
277
+ const title = asRecord(body.value).title;
278
+ if (typeof title !== "string")
279
+ return sendError(res, "invalid_request", "title must be text.");
280
+ const renamed = terminals.rename(id, title);
281
+ if (!renamed.ok)
282
+ return sendAppError(res, renamed.error);
283
+ return sendJson(res, 200, renamed.value);
284
+ }
249
285
  if (action === undefined && method === "DELETE") {
250
286
  return sendResult(res, terminals.remove(id), 204);
251
287
  }
@@ -2,11 +2,13 @@ import { randomBytes } from "node:crypto";
2
2
  import * as fs from "node:fs";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
+ import { stopContainer } from "./application/container-source.js";
5
6
  import { createDoctorService } from "./application/doctor.js";
6
7
  import { OpenWith } from "./application/open-with.js";
7
8
  import { controlProxy } from "./application/proxy-control.js";
8
9
  import { confirmProxyStarts } from "./application/proxy-start-check.js";
9
10
  import { createAlias, renameAlias } from "./application/route-use-cases.js";
11
+ import { publicUrls, SHORT_URL_PORT, ShortUrls } from "./application/short-urls.js";
10
12
  import { stopProcess } from "./application/stop-process.js";
11
13
  import { StudioRuntime } from "./application/studio-runtime.js";
12
14
  import { TerminalManager } from "./application/terminals.js";
@@ -20,19 +22,29 @@ import { JsonUpdateCache, updateCheckPathFor } from "./infrastructure/config/jso
20
22
  import { proxyStartRecordPathFor, readProxyStartRecord, recordProxyStarts, } from "./infrastructure/config/proxy-start-record.js";
21
23
  import { PortlessRegistry } from "./infrastructure/portless/portless-registry.js";
22
24
  import { inspectRoutesFile, resolveStateDir } from "./infrastructure/portless/state-files.js";
23
- import { collectDoctorFacts, proxyPidsAmong } from "./infrastructure/system/doctor-facts.js";
25
+ import { DockerContainers } from "./infrastructure/system/docker-containers.js";
26
+ import { collectDoctorFacts, proxyPidsAmong, reachesPortless, testBind, } from "./infrastructure/system/doctor-facts.js";
27
+ import { ephemeralRangeReader } from "./infrastructure/system/ephemeral-ports.js";
24
28
  import { FaviconFetcher } from "./infrastructure/system/favicon-fetcher.js";
25
29
  import { fetchLatestVersion } from "./infrastructure/system/npm-registry.js";
26
30
  import { OsNotifier } from "./infrastructure/system/os-notifier.js";
31
+ import { startForwarder } from "./infrastructure/system/port-forwarder.js";
27
32
  import { ProjectInfoReader } from "./infrastructure/system/project-info.js";
28
33
  import { nodeSystemIo } from "./infrastructure/system/system-io.js";
29
34
  import { createPortScanner, listPortsFor, probeHttpResponse, } from "./infrastructure/system/system-port-scanner.js";
30
35
  import { SystemProcessControl } from "./infrastructure/system/system-process-control.js";
31
- import { createTerminalSpawner, detectTerminalMode, nodeTerminalFolders, } from "./infrastructure/terminal/terminal-spawner.js";
36
+ import { systemProcessTree } from "./infrastructure/terminal/process-tree.js";
37
+ import { createTerminalSpawner, detectTerminalMode, interactiveShellName, nodeTerminalFolders, } from "./infrastructure/terminal/terminal-spawner.js";
32
38
  import { createHttpServer } from "./interface/http/http-server.js";
33
39
  export { defaultConfigPath } from "./infrastructure/config/json-config-store.js";
34
40
  /** Only this machine can connect; see docs/architecture.md, Security. */
35
41
  const LOOPBACK = "127.0.0.1";
42
+ /** `PORTLESS_STUDIO_SHORT_URL_PORT`, when it is a valid port; port 80 otherwise. */
43
+ export function shortUrlPortFrom(env) {
44
+ const raw = env.PORTLESS_STUDIO_SHORT_URL_PORT;
45
+ const port = raw === undefined ? Number.NaN : Number(raw);
46
+ return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : SHORT_URL_PORT;
47
+ }
36
48
  // Lets startServer reuse the runtime behind services built here, so both share one scan cache.
37
49
  const runtimes = new WeakMap();
38
50
  export function createStudioServices(options = {}) {
@@ -45,11 +57,18 @@ export function createStudioServices(options = {}) {
45
57
  });
46
58
  const scanner = createPortScanner();
47
59
  const proxyPids = (pids) => proxyPidsAmong(process.platform, nodeSystemIo, pids);
60
+ const shortUrls = new ShortUrls({
61
+ forwarding: { listen: (port, target) => startForwarder({ port, targetPort: target }) },
62
+ port: options.shortUrlPort ?? shortUrlPortFrom(process.env),
63
+ log: (line) => console.log(line),
64
+ reachesProxy: (port) => reachesPortless(port),
65
+ });
48
66
  // A start counts only once the proxy listens, and every proxy Studio starts is written down,
49
- // so `uninstall` stops only Studio's own.
50
- const registry = recordProxyStarts(confirmProxyStarts(portless, { scan: () => scanner.scan(), proxyPids }), proxyStartRecordPathFor(configPath));
67
+ // so `uninstall` stops only Studio's own. Route URLs drop the port while Studio forwards it.
68
+ const registry = publicUrls(recordProxyStarts(confirmProxyStarts(portless, { scan: () => scanner.scan(), proxyPids }), proxyStartRecordPathFor(configPath)), () => shortUrls.publicPort());
51
69
  const configStore = new JsonConfigStore(configPath);
52
70
  const notifier = new OsNotifier({ platform: process.platform });
71
+ const containers = new DockerContainers();
53
72
  const runtime = new StudioRuntime({
54
73
  registry,
55
74
  scanner,
@@ -62,6 +81,9 @@ export function createStudioServices(options = {}) {
62
81
  notifier,
63
82
  icons: new FaviconFetcher(),
64
83
  routeProjectStore: new JsonRouteProjectStore(routeProjectsPathFor(configPath)),
84
+ containers,
85
+ shortUrls,
86
+ ephemeralRange: ephemeralRangeReader(process.platform, nodeSystemIo),
65
87
  });
66
88
  const openWith = new OpenWith({
67
89
  catalog: systemAppCatalog(),
@@ -72,6 +94,8 @@ export function createStudioServices(options = {}) {
72
94
  const terminals = new TerminalManager({
73
95
  spawner: lazySpawner(),
74
96
  folders: nodeTerminalFolders(),
97
+ processTree: systemProcessTree(),
98
+ homeDir: os.homedir(),
75
99
  workspace: {
76
100
  openableFolders: () => runtime.openableFolders(),
77
101
  snapshot: () => runtime.readSnapshot(),
@@ -104,9 +128,13 @@ export function createStudioServices(options = {}) {
104
128
  ...(studioService ? { loginService: () => studioService.status() } : {}),
105
129
  systemNotifier: () => notifier.availability(),
106
130
  routesFile: () => inspectRoutesFile(stateDir),
131
+ docker: () => containers.read(),
132
+ lowPort: testBind,
133
+ shortUrls: (config) => shortUrls.inspect(config),
107
134
  }),
108
135
  controlProxy: proxy,
109
136
  updateConfig: (config) => runtime.updateConfig(config),
137
+ syncShortUrls: (config) => shortUrls.sync(config),
110
138
  });
111
139
  const version = options.version;
112
140
  const updates = version === undefined
@@ -129,11 +157,13 @@ export function createStudioServices(options = {}) {
129
157
  stoppablePids: () => runtime.stoppablePids(),
130
158
  selfPid: process.pid,
131
159
  }),
160
+ stopContainer: (name) => stopContainer(name, containers),
132
161
  studioService,
133
162
  openWith,
134
163
  terminals,
135
164
  openUrl: options.openUrl,
136
165
  updates,
166
+ shortUrls,
137
167
  };
138
168
  runtimes.set(services, runtime);
139
169
  return services;
@@ -178,6 +208,7 @@ export async function startServer(options) {
178
208
  scanner: services.scanner,
179
209
  configStore: services.configStore,
180
210
  projects: new ProjectInfoReader(),
211
+ ...(services.shortUrls ? { shortUrls: services.shortUrls } : {}),
181
212
  });
182
213
  const token = randomBytes(32).toString("base64url");
183
214
  const http = createHttpServer({
@@ -188,6 +219,7 @@ export async function startServer(options) {
188
219
  doctor: services.doctor,
189
220
  controlProxy: services.controlProxy,
190
221
  stopProcess: services.stopProcess,
222
+ stopContainer: services.stopContainer,
191
223
  ...(services.studioService ? { studioService: services.studioService } : {}),
192
224
  ...(services.openWith ? { openWith: services.openWith } : {}),
193
225
  ...(services.terminals ? { terminals: services.terminals } : {}),
@@ -204,6 +236,8 @@ export async function startServer(options) {
204
236
  const { port } = http.server.address();
205
237
  studio.setStudioPort(port);
206
238
  studio.start();
239
+ // The short-URL forwarder settles before anyone prints or registers a URL.
240
+ await studio.ready();
207
241
  return {
208
242
  url: `http://${LOOPBACK}:${port}`,
209
243
  port,
@@ -214,8 +248,10 @@ export async function startServer(options) {
214
248
  studio.stop();
215
249
  services.terminals?.closeAll();
216
250
  http.closeStreams();
217
- http.server.close(() => resolve());
218
- http.server.closeAllConnections();
251
+ void (services.shortUrls?.close() ?? Promise.resolve()).finally(() => {
252
+ http.server.close(() => resolve());
253
+ http.server.closeAllConnections();
254
+ });
219
255
  }),
220
256
  };
221
257
  }
@@ -230,6 +266,7 @@ function lazySpawner() {
230
266
  get mode() {
231
267
  return mode;
232
268
  },
269
+ shellName: interactiveShellName(),
233
270
  spawn: async (request) => {
234
271
  real ??= detectTerminalMode(process.platform).then((detected) => createTerminalSpawner({ mode: detected }));
235
272
  const spawner = await real;