portless-studio 0.1.1 → 0.1.2

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 (29) hide show
  1. package/README.md +60 -218
  2. package/dist/cli/mcp/studio-tools.js +20 -7
  3. package/dist/cli/studio-items.js +5 -2
  4. package/dist/core/api/contract.js +17 -1
  5. package/dist/core/config/studio-config.js +4 -0
  6. package/dist/core/doctor/platform-commands.js +5 -2
  7. package/dist/core/ports/direct-url.js +7 -0
  8. package/dist/core/ports/docker-container.js +85 -0
  9. package/dist/core/routes/route.js +14 -2
  10. package/dist/server/application/container-source.js +19 -0
  11. package/dist/server/application/doctor.js +199 -9
  12. package/dist/server/application/port-discovery.js +49 -6
  13. package/dist/server/application/short-urls.js +136 -0
  14. package/dist/server/application/studio-runtime.js +46 -7
  15. package/dist/server/application/terminals.js +142 -15
  16. package/dist/server/infrastructure/system/docker-containers.js +64 -0
  17. package/dist/server/infrastructure/system/doctor-facts.js +43 -1
  18. package/dist/server/infrastructure/system/port-forwarder.js +78 -0
  19. package/dist/server/infrastructure/system/process-runner.js +2 -1
  20. package/dist/server/infrastructure/terminal/process-tree.js +71 -0
  21. package/dist/server/infrastructure/terminal/terminal-spawner.js +44 -3
  22. package/dist/server/interface/http/http-server.js +38 -2
  23. package/dist/server/main.js +41 -6
  24. package/dist/web/assets/index-BDEwEBPS.js +15 -0
  25. package/dist/web/assets/index-C9wkL1J8.css +1 -0
  26. package/dist/web/index.html +2 -2
  27. package/package.json +1 -1
  28. package/dist/web/assets/index-B3KMqQ-l.js +0 -15
  29. package/dist/web/assets/index-CWujmTe4.css +0 -1
@@ -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,28 @@ 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";
24
27
  import { FaviconFetcher } from "./infrastructure/system/favicon-fetcher.js";
25
28
  import { fetchLatestVersion } from "./infrastructure/system/npm-registry.js";
26
29
  import { OsNotifier } from "./infrastructure/system/os-notifier.js";
30
+ import { startForwarder } from "./infrastructure/system/port-forwarder.js";
27
31
  import { ProjectInfoReader } from "./infrastructure/system/project-info.js";
28
32
  import { nodeSystemIo } from "./infrastructure/system/system-io.js";
29
33
  import { createPortScanner, listPortsFor, probeHttpResponse, } from "./infrastructure/system/system-port-scanner.js";
30
34
  import { SystemProcessControl } from "./infrastructure/system/system-process-control.js";
31
- import { createTerminalSpawner, detectTerminalMode, nodeTerminalFolders, } from "./infrastructure/terminal/terminal-spawner.js";
35
+ import { systemProcessTree } from "./infrastructure/terminal/process-tree.js";
36
+ import { createTerminalSpawner, detectTerminalMode, interactiveShellName, nodeTerminalFolders, } from "./infrastructure/terminal/terminal-spawner.js";
32
37
  import { createHttpServer } from "./interface/http/http-server.js";
33
38
  export { defaultConfigPath } from "./infrastructure/config/json-config-store.js";
34
39
  /** Only this machine can connect; see docs/architecture.md, Security. */
35
40
  const LOOPBACK = "127.0.0.1";
41
+ /** `PORTLESS_STUDIO_SHORT_URL_PORT`, when it is a valid port; port 80 otherwise. */
42
+ export function shortUrlPortFrom(env) {
43
+ const raw = env.PORTLESS_STUDIO_SHORT_URL_PORT;
44
+ const port = raw === undefined ? Number.NaN : Number(raw);
45
+ return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : SHORT_URL_PORT;
46
+ }
36
47
  // Lets startServer reuse the runtime behind services built here, so both share one scan cache.
37
48
  const runtimes = new WeakMap();
38
49
  export function createStudioServices(options = {}) {
@@ -45,11 +56,18 @@ export function createStudioServices(options = {}) {
45
56
  });
46
57
  const scanner = createPortScanner();
47
58
  const proxyPids = (pids) => proxyPidsAmong(process.platform, nodeSystemIo, pids);
59
+ const shortUrls = new ShortUrls({
60
+ forwarding: { listen: (port, target) => startForwarder({ port, targetPort: target }) },
61
+ port: options.shortUrlPort ?? shortUrlPortFrom(process.env),
62
+ log: (line) => console.log(line),
63
+ reachesProxy: (port) => reachesPortless(port),
64
+ });
48
65
  // 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));
66
+ // so `uninstall` stops only Studio's own. Route URLs drop the port while Studio forwards it.
67
+ const registry = publicUrls(recordProxyStarts(confirmProxyStarts(portless, { scan: () => scanner.scan(), proxyPids }), proxyStartRecordPathFor(configPath)), () => shortUrls.publicPort());
51
68
  const configStore = new JsonConfigStore(configPath);
52
69
  const notifier = new OsNotifier({ platform: process.platform });
70
+ const containers = new DockerContainers();
53
71
  const runtime = new StudioRuntime({
54
72
  registry,
55
73
  scanner,
@@ -62,6 +80,8 @@ export function createStudioServices(options = {}) {
62
80
  notifier,
63
81
  icons: new FaviconFetcher(),
64
82
  routeProjectStore: new JsonRouteProjectStore(routeProjectsPathFor(configPath)),
83
+ containers,
84
+ shortUrls,
65
85
  });
66
86
  const openWith = new OpenWith({
67
87
  catalog: systemAppCatalog(),
@@ -72,6 +92,8 @@ export function createStudioServices(options = {}) {
72
92
  const terminals = new TerminalManager({
73
93
  spawner: lazySpawner(),
74
94
  folders: nodeTerminalFolders(),
95
+ processTree: systemProcessTree(),
96
+ homeDir: os.homedir(),
75
97
  workspace: {
76
98
  openableFolders: () => runtime.openableFolders(),
77
99
  snapshot: () => runtime.readSnapshot(),
@@ -104,9 +126,13 @@ export function createStudioServices(options = {}) {
104
126
  ...(studioService ? { loginService: () => studioService.status() } : {}),
105
127
  systemNotifier: () => notifier.availability(),
106
128
  routesFile: () => inspectRoutesFile(stateDir),
129
+ docker: () => containers.read(),
130
+ lowPort: testBind,
131
+ shortUrls: (config) => shortUrls.inspect(config),
107
132
  }),
108
133
  controlProxy: proxy,
109
134
  updateConfig: (config) => runtime.updateConfig(config),
135
+ syncShortUrls: (config) => shortUrls.sync(config),
110
136
  });
111
137
  const version = options.version;
112
138
  const updates = version === undefined
@@ -129,11 +155,13 @@ export function createStudioServices(options = {}) {
129
155
  stoppablePids: () => runtime.stoppablePids(),
130
156
  selfPid: process.pid,
131
157
  }),
158
+ stopContainer: (name) => stopContainer(name, containers),
132
159
  studioService,
133
160
  openWith,
134
161
  terminals,
135
162
  openUrl: options.openUrl,
136
163
  updates,
164
+ shortUrls,
137
165
  };
138
166
  runtimes.set(services, runtime);
139
167
  return services;
@@ -178,6 +206,7 @@ export async function startServer(options) {
178
206
  scanner: services.scanner,
179
207
  configStore: services.configStore,
180
208
  projects: new ProjectInfoReader(),
209
+ ...(services.shortUrls ? { shortUrls: services.shortUrls } : {}),
181
210
  });
182
211
  const token = randomBytes(32).toString("base64url");
183
212
  const http = createHttpServer({
@@ -188,6 +217,7 @@ export async function startServer(options) {
188
217
  doctor: services.doctor,
189
218
  controlProxy: services.controlProxy,
190
219
  stopProcess: services.stopProcess,
220
+ stopContainer: services.stopContainer,
191
221
  ...(services.studioService ? { studioService: services.studioService } : {}),
192
222
  ...(services.openWith ? { openWith: services.openWith } : {}),
193
223
  ...(services.terminals ? { terminals: services.terminals } : {}),
@@ -204,6 +234,8 @@ export async function startServer(options) {
204
234
  const { port } = http.server.address();
205
235
  studio.setStudioPort(port);
206
236
  studio.start();
237
+ // The short-URL forwarder settles before anyone prints or registers a URL.
238
+ await studio.ready();
207
239
  return {
208
240
  url: `http://${LOOPBACK}:${port}`,
209
241
  port,
@@ -214,8 +246,10 @@ export async function startServer(options) {
214
246
  studio.stop();
215
247
  services.terminals?.closeAll();
216
248
  http.closeStreams();
217
- http.server.close(() => resolve());
218
- http.server.closeAllConnections();
249
+ void (services.shortUrls?.close() ?? Promise.resolve()).finally(() => {
250
+ http.server.close(() => resolve());
251
+ http.server.closeAllConnections();
252
+ });
219
253
  }),
220
254
  };
221
255
  }
@@ -230,6 +264,7 @@ function lazySpawner() {
230
264
  get mode() {
231
265
  return mode;
232
266
  },
267
+ shellName: interactiveShellName(),
233
268
  spawn: async (request) => {
234
269
  real ??= detectTerminalMode(process.platform).then((detected) => createTerminalSpawner({ mode: detected }));
235
270
  const spawner = await real;