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.
- package/README.md +60 -218
- package/dist/cli/mcp/studio-tools.js +20 -7
- package/dist/cli/studio-items.js +5 -2
- package/dist/core/api/contract.js +17 -1
- package/dist/core/config/studio-config.js +4 -0
- package/dist/core/doctor/platform-commands.js +5 -2
- package/dist/core/ports/direct-url.js +7 -0
- package/dist/core/ports/docker-container.js +85 -0
- package/dist/core/routes/route.js +14 -2
- package/dist/server/application/container-source.js +19 -0
- package/dist/server/application/doctor.js +199 -9
- package/dist/server/application/port-discovery.js +49 -6
- package/dist/server/application/short-urls.js +136 -0
- package/dist/server/application/studio-runtime.js +46 -7
- package/dist/server/application/terminals.js +142 -15
- package/dist/server/infrastructure/system/docker-containers.js +64 -0
- package/dist/server/infrastructure/system/doctor-facts.js +43 -1
- package/dist/server/infrastructure/system/port-forwarder.js +78 -0
- package/dist/server/infrastructure/system/process-runner.js +2 -1
- package/dist/server/infrastructure/terminal/process-tree.js +71 -0
- package/dist/server/infrastructure/terminal/terminal-spawner.js +44 -3
- package/dist/server/interface/http/http-server.js +38 -2
- package/dist/server/main.js +41 -6
- package/dist/web/assets/index-BDEwEBPS.js +15 -0
- package/dist/web/assets/index-C9wkL1J8.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-B3KMqQ-l.js +0 -15
- 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
|
-
|
|
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
|
}
|
package/dist/server/main.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
-
|
|
218
|
-
|
|
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;
|